Compare commits

...
14 Commits
84 changed files with 23632 additions and 181 deletions
+75
View File
@@ -0,0 +1,75 @@
# AGENTS.md
Инструкции для агентов (и IDE-ассистентов) по работе с этим репозиторием.
## Стек
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
- Менеджер пакетов: **pnpm**. Запускать команды из `web/`, либо через
обёртки в корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`).
Не использовать npm.
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`,
тесты: `pnpm --dir web test`.
- Форматирование: `pnpm --dir web format` (Prettier + `prettier-plugin-svelte`,
конфиг `web/.prettierrc`, игнор `web/.prettierignore`). Проверка без записи:
`pnpm --dir web exec prettier --check .`.
- Линтинг: `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`).
## Правила кода
### Svelte 5: типизация props через `interface Props`
Все типизированные пропсы компонентов описываются через локальный
`interface Props`, а деструктуризация идёт через аннотацию типа при `$props()`:
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
accent?: boolean;
children?: Snippet;
}
let { label, accent = false, children }: Props = $props();
</script>
```
Не использовать инлайн-дженерик `$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`. Ключевые
ограничения при работе с веткой `feat/redesign`:
- Глобальные стили старого и нового дизайна разнесены по layout без
наследования: старое в `web/src/app.css` (группа `(old)/`), новое в
`web/src/lib/styles/design2.css` (ветка `preview/*`). Префикс `.ds2` не
используется.
- Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/kit/`, даже «просто div с двумя стилями».
- Токены нового дизайна живут в `design2.css`; `refs/` и `refs-html/`
одноразовый источник, не поддерживаемый (правки дизайна — сразу в код).
- Коммиты мелкие (< ~500 строк), атомарные
+84
View File
@@ -0,0 +1,84 @@
# Demo fidelity: /preview/demo vs refs-html/demo.html
Метод: Playwright, viewport 1440×900, скриншоты обеих страниц + замер вычисленных
стилей ключевых элементов + пиксель-дифф (без чтения картинки моделью).
Пиксель-дифф: **24.9%** (наш скролл 1731px, реф 1579px). Зоны различий размазаны
по всей высоте → править надо глобально, не точечно.
Теперь процесс автоматизирован: `pnpm audit` (Playwright) пишет
`web/audit/audit-report.md` — light/dark токены + поэлементный дифф стилей по тексту.
Пиксель-дифф убран (неудобен для работы). Фиксы можно делать в любой момент и
перепроверять аудитом.
## Баги (объективно, правим в любом случае)
- **B1. Кнопки/инпуты рендерятся шрифтом Arial, а не IBM Plex Sans.**
Доказательство: пробы `Reset pipeline``fontFamily: Arial`. Причина: у `button/input`
не задан `font-family: inherit`. Фикс: добавить в reset `design2.css`
`button, input, select, textarea { font-family: inherit; }`. Влияет на все страницы. (P1)
- **B2. Двойной топбар.** Страница демо рендерит свой `.topbar` (brand / AUTO PIPELINE /
help / theme / lang), который перекрывается глобальным `fixed` Header из
`preview/+layout.svelte` (в нём только theme-toggle). В рефе — один топбар.
Контент демо-топбара (brand, статус, язык) визуально спрятан за фикс-хедером.
Фикс: убрать кастомный `.topbar` из демо и сделать хедер лейаута реф-стайл топбаром
(brand + version + status + help + theme + lang) — улучшит и C9/C10. (P1)
## Расхождения дизайна (нужно решение)
- **F1. Фон холста.** Реф: плоский серый `#EEF1F4`. Наш: белый + blueprint-сетка.
Решение: совпасть с рефом (убрать сетку на демо) или оставить сетку как сигнатуру нового дизайна.
- **F2. Бейдж типа шага** (BACKGROUND/TRANSFORM/STYLE). Реф: приглушённый серый `#6D787F`.
Наш (`StepCard` type → `Badge accent`): синий `#1769D2`. Решение: сделать приглушённым
(меняет `StepCard` глобально) или оставить синим.
- **F3. Подписи превью-тайлов** (SOURCE / STEP 01 / FINAL OUTPUT). Реф: синий `#1769D2`,
weight 400. Наш (`PreviewTile` label): серый, weight 500.
- **F4. Раскладка preview-stack.** Реф: одна колонка (5 строк, выше). Наш: 2 колонки (grid).
Решение: одна колонка (как реф) или оставить 2 колонки как выбор нового дизайна.
- **F5. Вес лейблов контролов.** Реф: 400. Наш (`MonoLabel`): 500. Мелко.
- **F6. Мета-подписи** (DIMENSIONS/FORMAT/SIZE): реф fs 9px, наш 10px. Мелко.
- **F7. Копирайт:** `Add tool` у нас `+ Add tool`; кнопка `Download result` — сверить стиль
(в рефе синяя с белым текстом 478×42). Пробы `Download result`/`Add tool`/`DIRECTION`
вернули NOT FOUND — скорее артефакт замера (вложенный текст/чип размера), проверить визуально.
- **F8. h1 line-height** чуть выше (наш 150px vs реф 115px) — мелкий зазор.
- **F9. Вертикальный ритм:** контент на ~152px выше, зазоры между секциями отличаются
(pipeline-head y 434 vs 400 и т.д.) — подровнять паддинги под реф.
- **F10. Внутренние отступы preview-панели** (Hide intermediate x 907 vs 1218) — паддинг панели.
## Темы (отложено — F1-F4)
Серый фон `#EEF1F4` — это **светлая** тема. Уточнение по F1: наш токен `--background`
уже равен `#EEF1F4` (совпадает с рефом); видимое отличие — только blueprint-сетка
поверх фона, а не «белый vs серый». То есть F1 = «сетка против плоского», не цвет фона.
Перед тем как сверять цвета бейджей/тайлов/раскладок (F2-F4), нужно сначала
зафиксировать палитры light/dark как единый источник токенов. Сейчас они есть в
`web/src/lib/styles/design2.css` (`:root` + `[data-theme="dark"]`), но не сверены с рефом.
**Как проверить темы в refs-html:**
- Открыть `refs-html/demo.html` (и соседние `gradient.html`, `background-remover.html`)
через Playwright/браузер.
- Нажать theme-toggle в топбаре рефа (иконка луны/солнца) — переключает тему
(скорее всего меняет атрибут/класс на `<html>` или `.page` и переопределяет CSS-переменные).
- Снять скриншоты светлой и тёмной темы, замерить computed-стиль `:root` переменных
рефа (через `getComputedStyle(document.documentElement).getPropertyValue('--...')`)
и сопоставить с нашими токенами в `design2.css`.
- Рефы, похоже, не доделаны по тёмной теме — зафиксировать, какие именно токены
меняются и где расхождения. На основе этого выровнять наши токены, затем вернуться
к F2 (бейдж типа), F3 (тайлы), F4 (раскладка preview-stack).
Решение по F1-F4 принимается только после сверки токенов светлой/тёмной темы с рефом.
**Состояние тем (уточнение):** наша dark-тема реализована в `design2.css`
(`[data-theme="dark"]`), но пока подключена только на preview-маршрутах (старые
страницы грузят `app.css`). Этого достаточно для сверки: токены, отличные от light,
соберём по всем `refs-html/*.html` (не только `demo.html`), остальные токены
не меняются. Light-токены уже совпадают с рефом (`--background`, `--panel`,
`--foreground`, `--muted`, `--line`, `--blue` равны).
## План действий (предлагаю)
1. Сразу поправить B1 + B2 (чёткие баги, улучшают все страницы).
2. По F1–F4 — решение за тобой (совпадать с рефом дословно ИЛИ оставить как сигнатуру нового дизайна).
3. F5–F10 — мелочи, подчистить в том же проходе, если решаем «совпадать с рефом».
4. Переснять скриншоты и перепроверить пиксель-дифф.
+409
View File
@@ -0,0 +1,409 @@
# План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: план к выполнению.
>
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
>
> **Стратегия изменена.** Вместо in-place рестайла (старый план A–F, правка
> общих компонентов на месте с алиасами токенов) — **параллельная сборка**:
> новый дизайн строится отдельно, изолируется от старого, проверяется
> целиком, затем становится основным и старый удаляется. Старое приложение
> остаётся рабочим и шippable на всём протяжении перехода.
## 0. Почему меняем стратегию
- **Старый план (A–F):** правим `ui/*` и страницы на месте, держим старые
токены как алиасы, чтобы ничего не сломалось до этапа F. Риск: общие
компоненты редактируются вживую → регресс старого UI во время работы,
новый дизайн нельзя принять изолированно.
- **Новый подход (5 фаз):** (1) строим новый ui-kit, (2) строим новые
страницы рядом, не трогая старые, (3) проверяем новый дизайн, (4) делаем
его основным, (5) удаляем старый. Ниже фазы 1–5 — это требуемые шаги.
- **Главный технический риск и его решение.** Сейчас `web/src/app.css`
импортируется в **корневом** `web/src/routes/+layout.svelte:2` и потому
грузится на ВСЕХ маршрутах, включая новые. Решение — **не префиксовать
новый CSS (никакого `.ds2`), а разнести глобальные стили по layout'ам без
наследования**: (а) убрать `import '../app.css'` из корневого layout;
(б) старые маршруты обернуть группой `(old)/` с собственным layout,
импортирующим `app.css`; (в) новые маршруты (`preview/*`) — собственный
layout, импортирующий `design2.css`. SvelteKit делает code-splitting CSS
по маршрутам, а стили компонентов скоупятся сами — поэтому глобальные
стили каждого дизайна живут только в своей ветке, и префикс-обёртка
`.ds2` не нужна. Старые `ui/` и страницы не трогаются до фазы 5.
## 1. Источники и их оценка
### `refs-html/` — витрина разметки
Покрытие достаточное:
| Файл | Что показывает | Оценка |
| ------------------------- | ----------------------------------------- | ---------------- |
| `index.html` | Главная: pipeline-воркспейс + превью | ✅ |
| `demo.html` | Зеркало `index.html` (идентичны) | ⚠️ дубликат |
| `gradient.html` | Инструмент «Градиент»: настройки + превью | ✅ (тёмная тема) |
| `background-remover.html` | Инструмент «Удаление фона»: source/result | ✅ (тёмная тема) |
| `list-tools.html` | Каталог инструментов | ✅ |
- **Статика без JS** — интерактив (тумблеры/слайдеры/перетаскивание) брать из
кода `refs/`, не из HTML. HTML — только визуальная правда по разметке/классам.
- По 4 файлам собран инвентарь классов — это и есть спецификация kit'а:
`app-shell`, `page-grid`, `eyebrow`, `brand-mark`, `topbar`, `footer`,
`panel-heading`, `setting-group`, `settings-footer`, `settings-panel`,
`segmented`, `status`, `step-card` (`step-index`/`step-heading`/`step-type`/
`step-body`/`drag`), `color-field` (`swatch`/`native-color`),
`preview-toolbar`/`checker` (`tile-canvas`/`remover-canvas`/`comparison-grid`),
`result-meta`/`meta-row`, `tool-card`/`image-card` (`tool-icon`/`tool-index`/
`tool-copy`), `code-block`, `icon-btn`, `download-btn`, `reset-btn`, `lede`,
`auto-note`, `label`, `toggle`/`toggle-group`, `direction-grid`, `gradient-bar`.
- **Дыры в refs-html:** нет состояний empty/error/loading, нет глобальных
модалок/тултипов, i18n не представлен (все лейблы — английские брендовые).
Решения — см. §9.
## 2. Токены (фундамент нового дизайна)
Живут в `src/lib/styles/design2.css` как обычный `:root { … }` (плюс
`[data-theme='dark'] { … }`). Файл грузится **только** layout'ом новой
ветки (§5), поэтому глобально не конфликтует со старым `app.css`. Маппинг
старых токенов `web/src/app.css` → новые:
| Старый | Новый | Light | Dark |
| -------------- | -------------- | --------- | ------------------------ |
| `--bg` | `--background` | `#eef1f4` | `#11171d` |
| `--surface` | `--panel` | `#f8fafb` | `#182129` |
| `--text` | `--foreground` | `#17212b` | `#e8eef2` |
| `--text-muted` | `--muted` | `#6d7883` | `#91a0ac` |
| `--border` | `--line` | `#cbd3da` | `#33414c` |
| `--accent` | `--blue` | `#1769d2` | `#54a2ff` |
| — | `--cyan` | `#00a8c7` | `#00a8c7` |
| — | `--amber` | `#bd7411` | `#bd7411` |
| — | `--success` | `#25a96a` | `#25a96a` (статус-точки) |
| `--danger` | без изменений | `#e5484d` | `#ff7479` |
Прочее:
- Радиусы: один `--radius: 4px` (мягкость старой темы уходит).
- Шрифты: `IBM Plex Sans` + `IBM Plex Mono`, self-host через `@fontsource`
(offline-first, CDN нельзя). Mono — рабочий шрифт для лейблов/метаданных/
цифр; Sans — заголовки и текст. Задаются на `.app-shell` (корень новой
ветки), чтобы не зависеть от старых глобальных правил `body`.
- Фон-сетка (32px blueprint-grid) и шахматка канвасов — утилиты `.page-grid`
и `.checker-canvas` внутри `.ds2`, не токены.
- Тёмная тема — на `[data-theme='dark']` (инлайн-скрипт уже есть в
`app.html`); класс `.dark-mode` из рефа не переносим, используем наш
механизм. На переходный период **никаких алиасов старых токенов** (старые
страницы сами живут на своём `app.css`).
## 3. Базовые примитивы дизайн-системы (все — компоненты)
Правило фазы 1: **любой повторяющийся паттерн — компонент**, даже «просто
div с двумя стилями». Никакой копипасты классов между страницами. Каждый
примитив ниже — отдельный `.svelte` в `src/lib/components/kit/`.
- `MonoLabel` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em,
muted; слот-модификатор accent (синие eyebrow/version).
- `StatusDot` / `StatusLine` — точка `--success` + подпись
(«AUTO PIPELINE», «LIVE PREVIEW»).
- `Panel` / `PanelHeading` / `SettingGroup` / `SettingsFooter` — панель с
1px-бордером, составные части с разделителями.
- `CheckerCanvas` — рабочая поверхность превью: тёмная подложка + шахматка.
- `MetaRow` / `MetaList` — пары «CAPTION значение».
- `Segmented` — сегмент-переключатель (язык, тип градиента, пресеты).
- `Badge` — тег типа шага (BACKGROUND/TRANSFORM/STYLE), чип «✓ AUTO».
- `StepCard` — нумерованная карточка этапа: индекс `01`, drag-handle,
Badge типа, заголовок, кнопка удаления; слот для параметров.
- `ToolCard` — карточка каталога (icon, index, title, copy, arrow).
- `ImageCard` / `PreviewTile` — label сверху + canvas-превью.
- `AppShell` — корневой контейнер `.app-shell` + `.page-grid` (обёртка
новой ветки маршрутов); задаёт шрифты и фон нового дизайна.
- `BlueprintGrid` — фоновая сетка (используется в `AppShell`).
- `IconButton`, `Icon` (обёртка `lucide-svelte`, 1617px, muted).
## 4. Минимальный UI-kit (`src/lib/components/kit/`)
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн
(обратной совместимости со старым `ui/` не нужно — старые страницы его
продолжают использовать).
| Компонент | Назначение |
| ------------------------- | ----------------------------------------------------------------- |
| `Button.svelte` | `primary` (solid blue) / `secondary` (outline) / `ghost` + `icon` |
| `Field.svelte` | обёртка: `MonoLabel` + слот значения/контрола |
| `SliderField.svelte` | range + `<output>`, опц. строка подсказок |
| `ColorField.svelte` | swatch + hex-input + нативный picker |
| `TextField.svelte` | текстовый ввод в стиле дизайна |
| `SelectField.svelte` | селект в стиле дизайна |
| `CheckboxField.svelte` | чекбокс → switch-toggle 32×18 |
| `Toggle.svelte` | переключатель (для toggle-group) |
| `SegmentedControl.svelte` | на базе `Segmented` — RU/EN, Linear/Radial, пресеты |
| `Badge.svelte` | на базе `Badge` |
| `MetaList.svelte` | на базе `MetaRow` |
| `EmptyState.svelte` | пустое состояние под новый стиль |
| `CodeBlock.svelte` | блок кода (gradient-art / direction-grid) |
| `DownloadButton.svelte` | кнопка скачивания (можно переиспользовать идею старой) |
Иконки: `lucide-svelte` (те же пути, MIT), 1617px, stroke, muted.
## 5. Изоляция старого и нового (критично)
Без префиксов и обёрток — только за счёт того, какие layout грузят какой CSS.
- **Корневой layout `src/routes/+layout.svelte`** перестаёт импортировать
`../app.css`. В нём остаётся только общая логика (init темы/locale) и
нейтральный `<div>{children}</div>` без глобальных стилей дизайна.
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css`
и содержит текущую шапку/подвал (разметку из нынешнего корневого layout).
Все старые страницы переносятся внутрь `(old)/` (группа в скобках не
меняет URL): `(old)/+page.svelte`, `(old)/demo/...`,
`(old)/list-tools/...`, `(old)/tools/[id]/...`.
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует
`design2.css` и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится
только `app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
Svelte-компонентов добавляет второй уровень изоляции. Никаких `.ds2`.
- Переключатель для ручной проверки: временный флаг `previewDesign` в
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на
`/preview`. На фазе 4 флаг убираем.
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы
> не могут одновременно определять один и тот же URL (`(old)/+page.svelte`
> и `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая
> ветка живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на
> реальные пути и группа `(old)` удаляется.
>
> Это совпадает с рекомендованным в документации SvelteKit паттерном:
> «put your entire app inside one or more groups except the routes that
> should not inherit the common layouts» — старое целиком в `(old)`, новое
> снаружи. Синтаксис `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь
> не требуется: «выломаться» можно лишь до корня, а нам нужно, чтобы именно
> у корня НЕ было `app.css`; если позже захотим держать новые маршруты внутри
> группы, `@` пригодится для сброса родительского layout.
## 6. Маршрутизация параллельных страниц
Новые страницы живут в **отдельной ветке `preview/*`**, не трогая старые:
```txt
src/routes/+layout.svelte # нейтральный, БЕЗ app.css
src/routes/(old)/+layout.svelte # импорт app.css + старая шапка/подвал
src/routes/(old)/+page.svelte # / (старое)
src/routes/(old)/demo/+page.svelte # /demo
src/routes/(old)/list-tools/...
src/routes/(old)/tools/[id]/...
src/routes/preview/+layout.svelte # импорт design2.css, <AppShell>
src/routes/preview/+page.svelte # /preview — воркспейс (index/demo)
src/routes/preview/demo/+page.svelte # /preview/demo
src/routes/preview/list-tools/+page.svelte
src/routes/preview/tools/[id]/+page.svelte
```
- Каждая новая страница импортирует **только** `kit/*`. Старые страницы
не изменяются (только перемещены в `(old)/`).
- Фаза 4: содержимое `preview/*` переносится на реальные маршруты
(move-файлы), группа `(old)/` удаляется, `design2.css` подключается в
корневом `+layout.svelte` (как новый основной слой), временный флаг/кнопка
убираются.
## 7. Фазы выполнения (1–5 по запросу)
Каждая фаза заканчивается зелёными `test`/`check`/`build` и ревью в браузере
(обе темы) против `refs-html/`.
- **Фаза 1 — Фундамент + UI-kit.** Токены §2, `design2.css` по §5, все
примитивы §3 и контролы §4 как отдельные компоненты. Критерий:
демо-страница kit'а (`/preview` или временная) показывает все состояния
(light/dark, hover/disabled, все варианты Button/Segmented/Badge);
unit-тесты форм не падают; **старые страницы визуально не изменились**
(изоляция работает).
- **Фаза 2 — Новые страницы.** Под `preview/*` (§6): воркспейс, demo,
list-tools, tool-page (двухпанельная раскладка из рефа: settings-panel +
preview-panel, step-cards для chain). Критерий: разметка соответствует
`refs-html/index.html`, `gradient.html`, `background-remover.html`,
`list-tools.html`; интерактив (слайдеры/тоглы/загрузка/автозапуск) работает
в новом стиле; старые маршруты продолжают работать как раньше.
- **Фазa 3 — Проверка нового дизайна.** Смоук §8 (обе темы, узкий экран,
800px), скриншоты против `refs-html/`, прогон старого тест-сьюта для
доказательства отсутствия регресса, RU/EN-локализация новых лейблов.
Критерий: визуальное совпадение с рефом, зелёные тесты, записанные
скриншоты приняты.
- **Фаза 4 — Сделать новый дизайн основным.** Перенос `preview/*`
реальные маршруты; `design2.css` подключается в корневом `+layout.svelte`;
удаление preview-группы и временного флага/кнопки. Старые страницы теперь
не используются (мёртвый код), но физически ещё в репо.
Критерий: реальные маршруты отдают новый дизайн, тесты зелёные, билд без
предупреждений.
- **Фаза 5 — Удалить старый дизайн.** Удаление старых страниц, старого
`ui/`, старого `app.css` (и его токенов), неиспользуемых классов;
переименование `kit/``ui/` (финальное имя) либо оставление `kit/` как
основного — решается на фазе 1. Финальный проход по брейкпоинтам
1200/1100/800/480. Критерий: `grep` по старым токенам/классам пуст,
build чистый, старый дизайн нигде не упоминается.
Оценка объёма: Фаза 1 — фундамент+kit (~1 день), Фаза 2 — страницы
(~1–2 дня), Фаза 3 — проверка (~полдня), Фаза 4–5 — переключение и чистка
(~полдня).
## 8. Смоук (после фазы 3, контроль на фазе 4)
1. Светлая и тёмная тема: сетка фона, панели, шахматка канвасов читаются,
контраст mono-лейблов достаточный.
2. Инструмент с параметрами (градиент): slider/color/toggle в стиле
setting-groups, автозапуск не регрессировал.
3. Удаление фона: сравнение source/result, маска, пипетка работают на новой
шахматке.
4. Chain из 3+ инструментов: step-cards рендерятся, удаление/сброс работают.
5. Узкий экран 800px: настройки над превью, sticky выключается как в рефе.
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа — либо ключи i18n,
либо осознанно английские брендовые элементы (см. §9).
## 9. Открытые вопросы
- **Имя финального каталога компонентов:** оставить `kit/` как основное имя
или переименовать в `ui/` на фазе 5 (удалив старое `ui/`). Рекомендую
сразу писать в `kit/`, на фазе 5 переименовать в `ui/`. Решить на фазе 1.
- **Изоляция через layout:** `app.css` вынесен из корневого layout в
`(old)/+layout.svelte`; новый `design2.css` — только в `preview/+layout`.
Проверить на фазе 1, что на `/preview/*` не попадают стили старого
дизайна (DevTools → вкладка Styles, нет правил из `app.css`).
- **Переключатель для проверки:** рекомендую `localStorage`-флаг +
кнопка «Preview v2» в старой шапке + группа `preview/*`. Убрать на фазе 4.
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
ключи, но допустить часть английских брендовых («приборная панель»).
- **Версия в шапке/футере:** из `package.json` через `define` или
захардкодить — мелочь, решить при сборке topbar.
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.**
После внедрения нового дизайна все правки дизайна делаются сразу в
продакшен-коде (`design2.css` + `kit/`), а не в `refs/`. Механизм
синхронизации «ref → код» не нужен и не создаётся. `refs/` и `refs-html/`
можно удалить из репо на фазе 5 (по желанию), они не являются частью
рантайма.
## 10. План исполнения (мелкие коммиты)
Ветка `feat/redesign`. Каждый шаг — отдельный коммит, цель < ~500 строк.
Перед стартом: `test`/`build` зелёные на базе (якорь регрессии).
### Шаг 0. Изоляция layout (без дизайна)
- [x] **[C1]** Сделать корневой `routes/+layout.svelte` нейтральным: убрать
`import '../app.css'`, оставить только init темы/locale и
`<div>{children}</div>`.
- [x] **[C2]** Создать `routes/(old)/+layout.svelte` (импорт `app.css` + старая
шапка/подвал) и перенести старые страницы (`+page`, `demo`, `list-tools`,
`tools/[id]`) в `(old)/`. URL не меняются. Проверка: старый сайт идентичен.
_(файлы — move, объём строк минимален)_
### Шаг 1. Фундамент нового дизайна
- [x] **[C3]** Создать `src/lib/styles/design2.css` (токены `:root` + `[data-theme='dark']` по §2) и подключить `@fontsource/ibm-plex-sans` + `@fontsource/ibm-plex-mono` (self-host, offline-first).
подключить шрифты в `design2.css` (self-host, offline-first).
- [x] **[C4]** _(слит в C3: шрифты нельзя подключить до создания `design2.css`)_
`[data-theme='dark']` (таблица §2). Без утилитарных классов.
- [x] **[C5]** Примитивы batch 1: `AppShell`, `BlueprintGrid`, `CheckerCanvas`,
`MonoLabel`, `StatusDot`/`StatusLine`.
- [x] **[C6]** Примитивы batch 2: `Panel`, `PanelHeading`, `SettingGroup`,
`SettingsFooter`, `MetaRow`/`MetaList`, `Segmented`, `Badge`, `StepCard`,
`ToolCard`, `ImageCard`/`PreviewTile`.
- [x] **[C7]** Контролы: `Icon`, `IconButton`, `Button`, `Field`, `SliderField`,
`ColorField`, `TextField`, `SelectField`, `CheckboxField`, `Toggle`,
`SegmentedControl` (= `Segmented` из C6), `EmptyState`, `CodeBlock`,
`DownloadButton`.
### Шаг 2. Витрина kit (проверка компонентов)
- [x] **[C8]** `preview/+layout.svelte` (импорт `design2.css` + `<AppShell>` + переключатель темы) и
временная `preview/kit` страница со всеми состояниями (light/dark,
hover/disabled, варианты Button/Segmented/Badge и пр.)
### Шаг 3. Новые страницы
- [x] **[C9]** `/preview` — воркспейс (eyebrow, h1, lede, поиск, «открыть последний»).
Поиск фильтрует `TOOLS` из `$lib/registry`; карточки результата пока
ведут на `#` (реальные `/preview/tools/[id]` подключим на C12, иначе
prerender падает на 404).
- [x] **[C10]** `/preview/list-tools` — каталог карточек (группировка по
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
подключим на C12).
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
- [ ] **[C12]** `/preview/tools/[id]` — раскладка gradient (settings-panel + preview-panel).
- [ ] **[C13]** `/preview/tools/[id]` — раскладка background-remover (comparison-grid).
- [ ] **[C14]** step-cards для chain в tool-page.
### Шаг 4. Переключатель и проверка
- [ ] **[C15]** Кнопка «Preview v2» + `localStorage`-флаг в старой шапке (ведёт на
`/preview`).
- [ ] **[C16]** Смоук §8, скриншоты против `refs-html/`, прогон старых тестов
(доказательство отсутствия регресса). Фиксы по результатам — отдельными
мелкими коммитами.
### Шаг 5. Сделать новый дизайн основным
- [ ] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый shell.
Убрать флаг/кнопку. **И обновить `newCode` в `web/eslint.config.js`:** заменить
`src/routes/preview/**` на `src/routes/**` (после переезда все маршруты —
новый дизайн), компонентный glob `src/lib/components/kit/**` пока оставить.
- [ ] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
### Шаг 6. Удаление старого
- [ ] **[C19]** Удалить старое `ui/`, прочие старые компоненты, старый `app.css`,
неиспользуемые классы. Переименовать `kit/``ui/` (если решено на C1 —
тогда в `eslint.config.js` обновить и компонентный glob).
**И раскрыть 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/` из репо — они
больше не источник правды.
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7),
> разбивать на под-коммиты по 2–4 компонента. Тесты/билд прогонять после
> каждого коммита, чтобы регрессия локализовалась одним шагом назад.
>
> **Атомарность и точки «поломки» между коммитами.**
>
> - C1+C2 уже сделаны вместе (связаны: нейтральный root без `(old)`-layout
> ломает старый сайт). Аналогично связанных пар больше нет.
> - C3 и C4 слиты: шрифты подключаются внутри `design2.css`, поэтому файл
> должен существовать в том же коммите.
> - Коммиты **C3→C16 не трогают старый сайт** `(old)/` — они только
> добавляют новые файлы/маршруты, сломать старый сайт на них нельзя.
> - **C17 — единственный атомарный переключатель**: перенос `preview/*` на
> реальные пути + удаление `(old)/` + смена root-layout должны быть в
> ОДНОМ коммите (иначе конфликт маршрутов или пропажа страниц). Не дробить.
> - **C19 должен идти строго после C17** (старый `app.css`/`ui/` удаляем
> только когда old-сайт уже выключен).
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7)
> должны существовать до страниц, которые их импортируют (C8–C14).
## Бэклог полировки (после C9–C21, отдельным проходом)
Конкретные расхождения витрины `preview/kit` с `refs-html/`. Править
системно в конце, а не по ходу сборки базы.
- **Toggle** в дизайне прямоугольный, у нас — закруглённый. Сделать
прямоугольным (или уточнить радиус у свича отдельно от общих токенов).
- **Радиусы бордеров** не совпадают: в `refs` радиусы вообще 0, а мы взяли
ненулевой `--radius` для всех бордеров. Проверить токен `--radius` против
референсов; возможно он должен быть 0 (или близко), а скругления оставить
только там, где они реально есть в дизайне.
- **DownloadButton**: нет отступа между текстом и размером файла
(`size`-чип). Добавить `gap`/отступ внутри кнопки.
- **Поля ввода (controls)**: выглядят без паддингов (возможно артефакт самой
витрины, но визуально плохо). Проверить паддинги в `TextField`/`SelectField`/
`ColorField`/`SliderField` против референса и поправить.
- **Segmented**: на витрине последняя кнопка растянута и нечитаемо, что это
сегмент. Проверить, как `Segmented` выглядит в реальных раскладках
(C9–C14); возможно нужен фиксированный/авто размер сегментов или перенос.
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build"
"build": "pnpm --dir web build",
"format": "pnpm --dir web format"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
+37
View File
@@ -0,0 +1,37 @@
# refs-html — статические снимки нового дизайна
Отформатированный HTML, вытащенный из Next-референса `refs/`, чтобы смотреть
макеты как референс по разметке и классам: открыл файл в браузере — увидел
дизайн.
## Файлы
| Файл | Маршрут в refs | Что показывает |
| ------------------------- | ------------------------------------ | --------------------------------------------------- |
| `index.html` | `/` | Главная: пайплайн-воркспейс + панель превью |
| `demo.html` | `/easy-png-tools/demo` | То же, что главная (зеркало) |
| `gradient.html` | `/easy-png-tools/gradient` | Инструмент «Градиент»: настройки + превью + CSS |
| `background-remover.html` | `/easy-png-tools/background-remover` | Инструмент «Удаление фона»: сравнение source/result |
## Особенности
- **Это статика без 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`.
File diff suppressed because it is too large Load Diff
+2576
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2576
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,29 @@
'use client'
import { useMemo, useState } from 'react'
import { Download, Moon, RotateCcw, Sun, 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 [dark, setDark] = useState(true)
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 ${dark ? 'dark-mode' : ''}`}>
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ BACKGROUND REMOVER</span></div><div className="top-actions"><span className="status"><i /> AUTO PROCESSING</span><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button></div></header>
<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
View File
@@ -0,0 +1 @@
export { default } from '../../page'
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { useMemo, useState } from 'react'
import { Check, ChevronDown, Copy, Download, Moon, RotateCcw, Sun } 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 [dark, setDark] = useState(true)
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 ${dark ? 'dark-mode' : ''}`}>
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ GRADIENT</span></div><div className="top-actions"><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button></div></header>
<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>
}
@@ -0,0 +1,27 @@
"use client"
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="catalog-page"><header className="catalog-nav"><Link href="/" className="catalog-brand">easy-png-tools</Link><nav><Link href="/">Workspace</Link><Link className="current" href="/easy-png-tools/list-tools">Catalog</Link></nav><span className="catalog-nav-status">LOCAL MODE / READY</span></header>
<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>
</main>
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
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>
}
+40
View File
@@ -0,0 +1,40 @@
'use client'
import { useMemo, useState } from 'react'
import { ArrowDownToLine, Check, ChevronDown, CircleHelp, Download, GripVertical, Link2, Moon, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Sun, 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 [dark, setDark] = useState(false)
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={dark ? 'app-shell dark-mode' : 'app-shell'}>
<header className="topbar"><div className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ DEMO</span></div><div className="top-actions"><span className="status"><i /> AUTO PIPELINE</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={() => setDark(!dark)}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className={language === 'RU' ? 'active' : ''} onClick={() => setLanguage('RU')}>RU</button><button className={language === 'EN' ? 'active' : ''} onClick={() => setLanguage('EN')}>EN</button></div></div></header>
<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>
}
+21
View File
@@ -0,0 +1,21 @@
{
"$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"
}
+58
View File
@@ -0,0 +1,58 @@
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 }
+118
View File
@@ -0,0 +1,118 @@
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
// 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];
// --- CSS: find the compiled chunk and pretty-print it ---
const cssChunks = join(root, '..', '..', 'static', 'chunks');
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'))
.map((rel) => [rel, basename(rel)]);
console.log(`discovered ${pages.length} page(s):`, pages.map((p) => p[1]).join(', '));
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>',
);
html = fmtHtml(html);
const file = join(dest, out);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, html);
console.log('written:', file);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+7
View File
@@ -0,0 +1,7 @@
/// <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.
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig
+38
View File
@@ -0,0 +1,38 @@
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"html": "node extract-static.mjs .next\\server\\app ..\\refs-html",
"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"
}
}
}
+3931
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
msw: set this to true or false
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.2 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"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"]
}
+8
View File
@@ -0,0 +1,8 @@
node_modules
build
.svelte-kit
dist
pnpm-lock.yaml
static
*.log
.DS_Store
+23
View File
@@ -0,0 +1,23 @@
{
"useTabs": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"proseWrap": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.md",
"options": {
"useTabs": false
}
},
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
# Style audit: http://127.0.0.1:5179/preview/demo
vs C:\+XBOCTuK\+life_projects\easy-png-tools\refs-html\demo.html
_2026-08-27T18:45:14.461Z_
## Сводка
- Токены light: **12** расх.
- Токены dark: **18** расх.
- Элементы (стиль/геометрия): **48** расх.
- Только у нас: 14, только в рефе: 7
## Токены — Light
| token | ours | ref |
|---|---|---|
| `--danger` | #e5484d | — |
| `--success` | #25a96a | — |
| `--font-sans` | "IBM Plex Sans", system-ui, -apple-system, sans-serif | "IBM Plex Sans", sans-serif |
| `--font-mono` | "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace | "IBM Plex Mono", monospace |
| `--text-sm--line-height` | — | calc(1.25 / .875) |
| `--text-xs--line-height` | — | calc(1 / .75) |
| `--font-weight-medium` | — | 500 |
| `--spacing` | — | .25rem |
| `--text-xs` | — | .75rem |
| `--radius-md` | — | .375rem |
| `--radius-lg` | — | .5rem |
| `--text-sm` | — | .875rem |
## Токены — Dark
| token | ours | ref |
|---|---|---|
| `--panel` | #182129 | #f8fafb |
| `--blue` | #54a2ff | #1769d2 |
| `--muted` | #91a0ac | #6d7883 |
| `--background` | #11171d | #eef1f4 |
| `--danger` | #ff7479 | — |
| `--foreground` | #e8eef2 | #17212b |
| `--success` | #25a96a | — |
| `--line` | #33414c | #cbd3da |
| `--font-sans` | "IBM Plex Sans", system-ui, -apple-system, sans-serif | "IBM Plex Sans", sans-serif |
| `--font-mono` | "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace | "IBM Plex Mono", monospace |
| `--text-sm--line-height` | — | calc(1.25 / .875) |
| `--text-xs--line-height` | — | calc(1 / .75) |
| `--font-weight-medium` | — | 500 |
| `--spacing` | — | .25rem |
| `--text-xs` | — | .75rem |
| `--radius-md` | — | .375rem |
| `--radius-lg` | — | .5rem |
| `--text-sm` | — | .875rem |
## Расхождения элементов (топ 48)
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(3)` — "BACKGROUND"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(23, 105, 210)``rgb(109, 120, 131)`
- **padding**: `2.4px 7.2px``0px`
- **letterSpacing**: `0.8px``1.2px`
- **borderRadius**: `4px``0px`
- **display**: `flex``inline`
- **gap**: `4.8px``normal`
- **rect**: `116,507 84x20``143,489 67x12`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(3)` — "TRANSFORM"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(23, 105, 210)``rgb(109, 120, 131)`
- **padding**: `2.4px 7.2px``0px`
- **letterSpacing**: `0.8px``1.2px`
- **borderRadius**: `4px``0px`
- **display**: `flex``inline`
- **gap**: `4.8px``normal`
- **rect**: `116,670 78x20``143,651 60x12`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(3)` — "STYLE"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(23, 105, 210)``rgb(109, 120, 131)`
- **padding**: `2.4px 7.2px``0px`
- **letterSpacing**: `0.8px``1.2px`
- **borderRadius**: `4px``0px`
- **display**: `flex``inline`
- **gap**: `4.8px``normal`
- **rect**: `116,785 50x20``143,799 34x12`
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(2)>div:nth-child(4)>button:nth-child(1)` — "RU"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `11px``10px`
- **color**: `rgb(255, 255, 255)``rgb(238, 241, 244)`
- **backgroundColor**: `rgb(23, 105, 210)``rgb(23, 33, 43)`
- **padding**: `4.8px 11.2px``6px 9px`
- **letterSpacing**: `0.66px``normal`
- **rect**: `1307,20 37x24``1323,20 29x24`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>span:nth-child(1)` — "COLOR"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontWeight**: `500``400`
- **margin**: `0px``0px 0px 8px`
- **letterSpacing**: `1px``0.8px`
- **lineHeight**: `12px``normal`
- **display**: `block``flex`
- **rect**: `71,576 203x12``143,546 189x12`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>span:nth-child(1)` — "SOURCE"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **fontWeight**: `500``400`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **letterSpacing**: `1px``normal`
- **lineHeight**: `12px``normal`
- **rect**: `806,320 262x12``808,663 30x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(2)>span:nth-child(1)` — "STEP 01"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **fontWeight**: `500``400`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **letterSpacing**: `1px``normal`
- **lineHeight**: `12px``normal`
- **rect**: `1094,320 262x12``1096,663 35x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(3)>span:nth-child(1)` — "STEP 02"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **fontWeight**: `500``400`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **letterSpacing**: `1px``normal`
- **lineHeight**: `12px``normal`
- **rect**: `806,752 262x12``808,1021 35x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(4)>span:nth-child(1)` — "STEP 03"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **fontWeight**: `500``400`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **letterSpacing**: `1px``normal`
- **lineHeight**: `12px``normal`
- **rect**: `1094,752 262x12``1096,1021 35x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(5)>span:nth-child(1)` — "FINAL OUTPUT"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **fontWeight**: `500``400`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **letterSpacing**: `1px``normal`
- **lineHeight**: `12px``normal`
- **rect**: `806,1183 262x12``808,1378 59x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>button:nth-child(2)>span:nth-child(2)>span:nth-child(1)` — "1.2 MB"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``11px`
- **color**: `rgb(255, 255, 255)``rgb(23, 33, 43)`
- **margin**: `0px``5px 0px 0px`
- **display**: `inline``block`
- **rect**: `1090,240 36x13``1246,291 79x13`
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(2)>div:nth-child(4)>button:nth-child(2)` — "EN"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `11px``10px`
- **padding**: `4.8px 11.2px``6px 9px`
- **letterSpacing**: `0.66px``normal`
- **rect**: `1344,20 38x24``1352,20 29x24`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(1)` — "100"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `11px``10px`
- **padding**: `4.8px 11.2px``0px`
- **letterSpacing**: `0.66px``normal`
- **rect**: `491,597 44x24``520,567 46x30`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(2)` — "75"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `11px``10px`
- **padding**: `4.8px 11.2px``0px`
- **letterSpacing**: `0.66px``normal`
- **rect**: `535,597 38x24``566,567 46x30`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(3)` — "50"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `11px``10px`
- **padding**: `4.8px 11.2px``0px`
- **letterSpacing**: `0.66px``normal`
- **rect**: `573,597 38x24``612,567 46x30`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>button:nth-child(4)` — "25"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `11px``10px`
- **padding**: `4.8px 11.2px``0px`
- **letterSpacing**: `0.66px``normal`
- **rect**: `611,597 38x24``658,567 46x30`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(2)>div:nth-child(2)>label:nth-child(2)>span:nth-child(1)` — "Preserve aspect ratio"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Sans", sans-serif`
- **fontSize**: `13.6px``16px`
- **lineHeight**: `normal``24px`
- **display**: `block``inline`
- **rect**: `399,1036 129x18``432,1021 156x17`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(2)` — "01"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **padding**: `0px``17px 0px 0px 15px`
- **rect**: `95,510 13x14``59,466 46x150`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(1)>div:nth-child(1)>span:nth-child(4)` — "Gradient background"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Mono", monospace`
- **fontSize**: `14.4px``15px`
- **margin**: `0px``5px 0px 18px`
- **rect**: `208,508 467x19``143,510 157x18`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(2)` — "02"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **padding**: `0px``17px 0px 0px 15px`
- **rect**: `95,673 13x14``59,628 46x136`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(2)>div:nth-child(1)>span:nth-child(4)` — "Remove background"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Mono", monospace`
- **fontSize**: `14.4px``15px`
- **margin**: `0px``5px 0px 18px`
- **rect**: `201,671 473x19``143,672 140x18`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(2)` — "03"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **padding**: `0px``17px 0px 0px 15px`
- **rect**: `95,788 13x14``59,776 46x150`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(3)>div:nth-child(1)>span:nth-child(4)` — "Add outline"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Mono", monospace`
- **fontSize**: `14.4px``15px`
- **margin**: `0px``5px 0px 18px`
- **rect**: `174,785 501x19``143,820 91x18`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(1)>span:nth-child(2)` — "04"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(109, 120, 131)``rgb(23, 105, 210)`
- **padding**: `0px``17px 0px 0px 15px`
- **rect**: `95,951 13x14``59,938 46x142`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(4)>div:nth-child(4)>div:nth-child(1)>span:nth-child(4)` — "Round corners"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Mono", monospace`
- **fontSize**: `14.4px``15px`
- **margin**: `0px``5px 0px 18px`
- **rect**: `174,948 501x19``143,982 107x18`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(1)>span:nth-child(1)` — "DIMENSIONS"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **letterSpacing**: `1px``normal`
- **rect**: `1150,215 70x13``1073,271 79x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>span:nth-child(1)` — "FORMAT"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **letterSpacing**: `1px``normal`
- **rect**: `1150,239 42x13``1159,271 79x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(3)>span:nth-child(1)` — "SIZE"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **fontSize**: `10px``9px`
- **letterSpacing**: `1px``normal`
- **rect**: `1150,264 28x13``1246,271 79x10`
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(1)>span:nth-child(2)` — "easy-png-tools"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(0, 0, 0)``rgb(23, 33, 43)`
- **rect**: `98,23 118x18``98,23 108x17`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "/"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **color**: `rgb(23, 105, 210)``rgb(109, 120, 131)`
- **rect**: `173,124 7x13``165,124 13x12`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(1)>h1:nth-child(1)` — "Build your image pipeline."
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Sans", sans-serif`
- **lineHeight**: `normal``57.6px`
- **rect**: `58,155 474x150``58,154 482x115`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(1)>span:nth-child(2)` — "1200 × 800 px"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **margin**: `0px``5px 0px 0px`
- **rect**: `1236,214 86x14``1073,291 79x13`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>span:nth-child(2)` — "PNG-24"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **margin**: `0px``5px 0px 0px`
- **rect**: `1282,238 40x14``1159,291 79x13`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(1)>p:nth-child(2)` — "Chain simple tools together. Every chang"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Sans", sans-serif`
- **rect**: `58,321 474x51``58,285 482x51`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "source.png"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `597,347 66x14``606,311 60x13`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(2)>div:nth-child(2)>b:nth-child(3)` — "1.8 MB"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `671,347 40x14``674,311 36x13`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(3)>div:nth-child(1)>span:nth-child(1)` — "PROCESSING PIPELINE"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `58,434 137x13``58,400 127x12`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(1)>div:nth-child(3)>div:nth-child(1)>strong:nth-child(2)>em:nth-child(1)` — "• LIVE"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `184,457 36x13``173,425 33x12`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "PIPELINE OUTPUTS"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `799,177 58x33``799,188 54x36`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(1)>div:nth-child(1)>strong:nth-child(2)` — "Visual history"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `799,216 93x36``799,233 87x34`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "PNG"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `923,498 28x19``923,478 27x18`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "original.png · 1200 × 800"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `925,706 135x11``942,663 124x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(2)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "gradient applied"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `1262,706 86x11``1275,663 79x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(3)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "background removed"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `962,1138 97x11``977,1021 89x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(4)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "outline added"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `1278,1138 70x11``1290,1021 64x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>div:nth-child(2)>div:nth-child(5)>div:nth-child(2)>div:nth-child(2)>span:nth-child(2)` — "ready · PNG-24"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
- **rect**: `984,1569 76x11``997,1378 69x10`
### `body>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)>div:nth-child(1)>section:nth-child(2)>p:nth-child(3)` — "Output is generated in-browser. Your fil"
- **fontFamily**: `"IBM Plex Sans", system-ui, -apple-system, sans-serif``"IBM Plex Sans", sans-serif`
- **rect**: `799,1624 565x17``799,1425 565x17`
### `body>div:nth-child(1)>div:nth-child(1)>header:nth-child(1)>div:nth-child(1)>span:nth-child(1)` — "EP"
- **fontFamily**: `"IBM Plex Mono", ui-monospace, SFMono-Regular, monospace``"IBM Plex Mono", monospace`
## Только у нас (структурно)
- `+ Add tool`
- `AUTO`
- `Gradient color`
- `DIRECTION`
- `135°`
- `OPACITY`
- `Automatic subject detection enabled`
- `WIDTH`
- `Outline width`
- `2px`
- `Outline color`
- `RADIUS`
- `Corner radius`
- `18px`
## Только в рефе (структурно)
- `/ DEMO`
- `135
°`
- `100%`
- `2
px`
- `18
px`
- `v2.4.0`
- `© 2024`
+91
View File
@@ -0,0 +1,91 @@
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
// Когда старый дизайн удалим (C19), этот scoped-блок убирается и recommended
// включается на весь код (см. план-redesign §10, шаг 6).
const newCode = ["**/src/lib/components/kit/**", "**/src/routes/preview/**"];
export default tseslint.config(
{
ignores: [
"**/node_modules/**",
"**/build/**",
"**/.svelte-kit/**",
"**/dist/**",
"**/static/**",
],
},
// Минимально на весь код: парсинг TS/Svelte + запрет инлайн-тип-импортов.
{
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js", "**/*.svelte"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
projectService: {
allowDefaultProject: [
"vitest.config.ts",
"eslint.config.js",
"svelte.config.js",
],
},
extraFileExtensions: [".svelte"],
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
// Инлайн-тип-импорты (import('x').Y) запрещены — только
// `import type { Y } from 'x'` наверху файла.
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "separate-type-imports" },
],
},
},
{
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parser: tseslint.parser,
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tseslint.parser,
},
},
},
// Полные recommended-наборы — только на новый код.
...[
...(Array.isArray(js.configs.recommended)
? js.configs.recommended
: [js.configs.recommended]),
...tseslint.configs.recommended,
...svelte.configs["flat/recommended"],
].map((cfg) => ({
...cfg,
files: newCode,
})),
// В Svelte 5 пропсы деструктурируются через `let` (конвенция документации и
// наш AGENTS.md), поэтому prefer-const на них — ложноположительный.
{
files: ["**/*.svelte"],
rules: {
"prefer-const": "off",
},
},
// prettier — последним, чтобы гасить форматирующие правила из recommended.
prettier,
);
+16 -1
View File
@@ -10,19 +10,34 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run"
"test": "vitest run",
"format": "prettier --write .",
"lint": "eslint .",
"audit": "node scripts/audit.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"eslint": "^10.9.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"playwright": "^1.62.1",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"svelte-eslint-parser": "^1.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vite": "^8.0.16",
"vitest": "^4.1.11"
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@lucide/svelte": "^1.33.0"
}
}
+1014 -20
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
// Стиль-аудит: снимает design-токены (light/dark) и дерево вычисленных стилей
// нашей preview-страницы и refs-html страницы, сравнивает и пишет отчёт.
// Запуск: pnpm audit (опции: --ours <url> --ref <file> --no-serve --route <path>)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
const args = (() => {
const a = {};
for (let i = 0; i < process.argv.length; i++) {
const v = process.argv[i];
if (v === "--no-serve") a.noServe = true;
else if (v === "--ours") a.ours = process.argv[++i];
else if (v === "--ref") a.ref = process.argv[++i];
else if (v === "--route") a.route = process.argv[++i];
}
return a;
})();
const WEB = process.cwd();
const PORT = 5179;
const ROUTE = args.route || "/preview/demo";
const OURS = args.ours || `http://127.0.0.1:${PORT}${ROUTE}`;
const REF = args.ref || resolve(WEB, "../refs-html/demo.html");
const FIELDS = [
"fontFamily",
"fontSize",
"fontWeight",
"color",
"backgroundColor",
"border",
"padding",
"margin",
"letterSpacing",
"lineHeight",
"borderRadius",
"display",
"gap"
];
const waitFor = async (url, ms = 60000) => {
const t = Date.now();
while (Date.now() - t < ms) {
try {
const r = await fetch(url);
if (r.ok) return;
} catch {}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("dev server not up: " + url);
};
const snapshot = (page, url) =>
page.evaluate((url) => {
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
const out = {};
for (const k of s) if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
return out;
};
const tokensLight = readTokens();
const prev = document.documentElement.getAttribute("data-theme");
document.documentElement.setAttribute("data-theme", "dark");
const tokensDark = readTokens();
if (prev) document.documentElement.setAttribute("data-theme", prev);
else document.documentElement.removeAttribute("data-theme");
const sig = (el) => {
const s = getComputedStyle(el);
const o = {};
for (const f of [
"fontFamily",
"fontSize",
"fontWeight",
"color",
"backgroundColor",
"borderTopWidth",
"borderTopColor",
"padding",
"margin",
"letterSpacing",
"lineHeight",
"borderRadius",
"display",
"gap"
])
o[f] = s[f];
return o;
};
const rect = (el) => {
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
};
const walk = (el, path) => {
if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(el.tagName)) return [];
const out = [];
const kids = [...el.children];
out.push({
path,
tag: el.tagName.toLowerCase(),
text: el.children.length === 0 ? el.textContent.trim().slice(0, 40) : "",
rect: rect(el),
style: sig(el)
});
for (let i = 0; i < kids.length; i++)
out.push(...walk(kids[i], `${path}>${kids[i].tagName.toLowerCase()}:nth-child(${i + 1})`));
return out;
};
return { tokensLight, tokensDark, tree: walk(document.body, "body") };
}, url);
async function main() {
let server;
if (!args.noServe) {
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
cwd: WEB,
stdio: "ignore",
shell: true,
detached: process.platform !== "win32"
});
await waitFor(OURS);
}
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
try {
await page.goto(OURS, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(400);
const ours = await snapshot(page, OURS);
await page.goto(pathToFileURL(REF).href, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(400);
const ref = await snapshot(page, pathToFileURL(REF).href);
// token diff (игнорируем служебный мусор Tailwind/lightningcss — он
// ref-only и не относится к нашим смысловым токенам)
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
const tokDiff = (a, b) => {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
const rows = [];
for (const k of keys) {
if (TOK_NOISE.test(k)) continue;
const av = a[k] ?? "—";
const bv = b[k] ?? "—";
if (av !== bv) rows.push({ key: k, ours: av, ref: bv });
}
return rows;
};
const tokenDiffs = {
light: tokDiff(ours.tokensLight, ref.tokensLight),
dark: tokDiff(ours.tokensDark, ref.tokensDark)
};
// element diff: совпадающие по тексту элементы (структуры разные,
// поэтому nth-child-пути не совпадают — матчим по видимому тексту)
const byText = (tree) => {
const m = new Map();
for (const e of tree) if (e.text) if (!m.has(e.text)) m.set(e.text, e);
return m;
};
const txtO = byText(ours.tree);
const txtR = byText(ref.tree);
const onlyOurs = [];
const onlyRef = [];
const elementDiffs = [];
for (const [k, o] of txtO) {
const r = txtR.get(k);
if (!r) {
onlyOurs.push(k);
continue;
}
const deltas = {};
for (const f of FIELDS) {
const ov = (o.style[f] ?? "").toString();
const rv = (r.style[f] ?? "").toString();
if (ov !== rv) deltas[f] = { ours: ov, ref: rv };
}
const dr = o.rect;
const rr = r.rect;
if (Math.abs(dr.x - rr.x) > 2 || Math.abs(dr.y - rr.y) > 2 || Math.abs(dr.w - rr.w) > 2 || Math.abs(dr.h - rr.h) > 2)
deltas.rect = { ours: dr, ref: rr };
if (Object.keys(deltas).length) elementDiffs.push({ path: o.path, tag: o.tag, text: k, deltas });
}
for (const [k] of txtR) if (!txtO.has(k)) onlyRef.push(k);
const report = {
generatedAt: new Date().toISOString(),
ours: OURS,
ref: REF,
tokenDiffs,
elementDiffs,
onlyOurs: onlyOurs.slice(0, 60),
onlyRef: onlyRef.slice(0, 60),
counts: {
tokenLight: tokenDiffs.light.length,
tokenDark: tokenDiffs.dark.length,
elements: elementDiffs.length,
onlyOurs: onlyOurs.length,
onlyRef: onlyRef.length
}
};
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(resolve(WEB, "audit/audit-report.json"), JSON.stringify(report, null, 2));
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(report, ours, ref));
console.log(
`audit done: tokens light/dark=${tokenDiffs.light.length}/${tokenDiffs.dark.length}, ` +
`elements=${elementDiffs.length}, onlyOurs=${onlyOurs.length}, onlyRef=${onlyRef.length}`
);
console.log(`report: web/audit/audit-report.md`);
} finally {
await browser.close();
if (server) {
try {
if (process.platform === "win32")
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], { stdio: "ignore" });
else server.kill("SIGTERM");
} catch {}
}
}
}
function toMarkdown(report, ours, ref) {
const tokTable = (rows, a, b) =>
rows.length
? `| token | ours | ref |\n|---|---|---|\n` +
rows.map((r) => `| \`${r.key}\` | ${r.ours} | ${r.ref} |`).join("\n")
: "_все совпадают_";
const sorted = [...report.elementDiffs].sort(
(x, y) => Object.keys(y.deltas).length - Object.keys(x.deltas).length
);
const elRows = sorted
.slice(0, 100)
.map((e) => {
const d = Object.entries(e.deltas)
.map(([k, v]) => {
if (k === "rect") {
const f = (r) => `${r.x},${r.y} ${r.w}x${r.h}`;
return ` - **rect**: \`${f(v.ours)}\`\`${f(v.ref)}\``;
}
return ` - **${k}**: \`${v.ours}\`\`${v.ref}\``;
})
.join("\n");
return `### \`${e.path}\`${e.text ? ` — "${e.text}"` : ""}\n${d}`;
})
.join("\n\n");
return `# Style audit: ${report.ours}\n\nvs ${report.ref}\n\n_${report.generatedAt}_\n\n` +
`## Сводка\n\n- Токены light: **${report.counts.tokenLight}** расх.\n` +
`- Токены dark: **${report.counts.tokenDark}** расх.\n` +
`- Элементы (стиль/геометрия): **${report.counts.elements}** расх.\n` +
`- Только у нас: ${report.counts.onlyOurs}, только в рефе: ${report.counts.onlyRef}\n\n` +
`## Токены — Light\n\n${tokTable(report.tokenDiffs.light)}\n\n` +
`## Токены — Dark\n\n${tokTable(report.tokenDiffs.dark)}\n\n` +
`## Расхождения элементов (топ ${Math.min(100, sorted.length)})\n\n${elRows}\n\n` +
`## Только у нас (структурно)\n\n` +
(report.onlyOurs.length ? report.onlyOurs.map((p) => `- \`${p}\``).join("\n") : "_—_") +
`\n\n## Только в рефе (структурно)\n\n` +
(report.onlyRef.length ? report.onlyRef.map((p) => `- \`${p}\``).join("\n") : "_—_") +
`\n`;
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,27 @@
<script lang="ts">
import BlueprintGrid from "./BlueprintGrid.svelte";
let { children } = $props();
</script>
<div class="app-shell">
<BlueprintGrid />
<div class="app-content">
{@render children()}
</div>
</div>
<style>
.app-shell {
position: relative;
min-height: 100dvh;
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
.app-content {
position: relative;
z-index: 1;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Tone = "default" | "accent" | "success" | "amber";
interface Props {
tone?: Tone;
children: Snippet;
}
let { tone = "default", children }: Props = $props();
</script>
<span class="badge badge--{tone}">
{#if children}{@render children()}{/if}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.15rem 0.45rem;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
}
.badge--accent {
color: var(--blue);
border-color: color-mix(in srgb, var(--blue) 40%, var(--line));
}
.badge--success {
color: var(--success);
border-color: color-mix(in srgb, var(--success) 40%, var(--line));
}
.badge--amber {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 40%, var(--line));
}
</style>
@@ -0,0 +1,22 @@
<div class="blueprint-grid" aria-hidden="true"></div>
<style>
.blueprint-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(
to right,
color-mix(in srgb, var(--line) 45%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--line) 45%, transparent) 1px,
transparent 1px
);
background-size: 32px 32px;
opacity: 0.55;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
children: Snippet;
onclick?: () => void;
type?: "button" | "submit";
variant?: "primary" | "ghost" | "accent" | "danger";
icon?: Component<{ size?: number; class?: string }>;
disabled?: boolean;
}
let {
children,
onclick,
type = "button",
variant = "primary",
icon,
disabled = false,
}: Props = $props();
</script>
<button
{type}
class="btn btn--{variant}"
{disabled}
onclick={() => onclick?.()}
>
{#if icon}<Icon {icon} size={14} />{/if}
<span class="btn-label">{@render children()}</span>
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--foreground);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.btn--primary {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.btn--ghost:hover:not(:disabled) {
border-color: var(--blue);
}
.btn--accent {
background: var(--cyan);
color: #042;
border-color: var(--cyan);
}
.btn--danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
label: string;
checked: boolean;
onchange?: (checked: boolean) => void;
}
let { label, checked = $bindable(), onchange }: Props = $props();
function handle(e: Event) {
checked = (e.target as HTMLInputElement).checked;
onchange?.(checked);
}
</script>
<label class="checkbox-field">
<input type="checkbox" {checked} onchange={handle} />
<span class="checkbox-label">{label}</span>
</label>
<style>
.checkbox-field {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--foreground);
cursor: pointer;
}
.checkbox-field input {
accent-color: var(--blue);
}
</style>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
size?: "sm" | "large";
}
let { children, size = "large" }: Props = $props();
</script>
<div class="checker-canvas checker-canvas--{size}">
{#if children}
{@render children()}
{/if}
</div>
<style>
.checker-canvas {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
background-color: #0b0f14;
background-image:
linear-gradient(45deg, #1a2129 25%, transparent 25%),
linear-gradient(-45deg, #1a2129 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a2129 75%),
linear-gradient(-45deg, transparent 75%, #1a2129 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
}
.checker-canvas--sm {
min-height: 180px;
}
.checker-canvas--large {
min-height: 360px;
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
import { Copy } from "@lucide/svelte";
import IconButton from "./IconButton.svelte";
interface Props {
code: string;
lang?: string;
copyable?: boolean;
oncopy?: () => void;
}
let { code, lang, copyable = false, oncopy }: Props = $props();
</script>
<div class="code-block">
{#if lang}<span class="code-lang">{lang}</span>{/if}
<pre class="code-pre"><code>{code}</code></pre>
{#if copyable}
<div class="code-copy">
<IconButton icon={Copy} label="Copy" onclick={() => oncopy?.()} />
</div>
{/if}
</div>
<style>
.code-block {
position: relative;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--background);
padding: 0.6rem 0.8rem;
}
.code-lang {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
color: var(--muted);
}
.code-pre {
margin: 0;
overflow: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
}
.code-copy {
position: absolute;
top: 0.4rem;
right: 0.4rem;
}
</style>
@@ -0,0 +1,56 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
label: string;
value: string;
oninput?: (value: string) => void;
}
let { label, value = $bindable(), oninput }: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
oninput?.(value);
}
</script>
<Field {label}>
<div class="color-field">
<input type="color" class="color-swatch" {value} oninput={handle} />
<input
type="text"
class="color-hex"
{value}
oninput={handle}
spellcheck="false"
/>
</div>
</Field>
<style>
.color-field {
display: flex;
align-items: center;
gap: 0.4rem;
}
.color-swatch {
width: 2rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
cursor: pointer;
}
.color-hex {
flex: 1;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.3rem 0.5rem;
text-transform: uppercase;
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import { Download } from "@lucide/svelte";
import Button from "./Button.svelte";
interface Props {
label: string;
size?: string;
onclick?: () => void;
}
let { label, size, onclick }: Props = $props();
</script>
<Button icon={Download} variant="primary" onclick={() => onclick?.()}>
{label}{#if size}<span class="dl-size">{size}</span>{/if}
</Button>
<style>
.dl-size {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.8;
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
title: string;
description?: string;
icon?: Component<{ size?: number; class?: string }>;
children?: Snippet;
}
let { title, description, icon, children }: Props = $props();
</script>
<div class="empty-state">
{#if icon}<span class="empty-icon"><Icon {icon} size={28} /></span>{/if}
<strong class="empty-title">{title}</strong>
{#if description}<span class="empty-desc">{description}</span>{/if}
{#if children}<div class="empty-action">{@render children()}</div>{/if}
</div>
<style>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
text-align: center;
color: var(--muted);
}
.empty-icon {
color: var(--muted);
}
.empty-title {
color: var(--foreground);
font-size: 0.95rem;
}
.empty-desc {
font-size: 0.8rem;
max-width: 32ch;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
children: Snippet;
hint?: string;
}
let { label, children, hint }: Props = $props();
</script>
<div class="field">
<MonoLabel>{label}</MonoLabel>
<div class="field-control">{@render children()}</div>
{#if hint}<span class="field-hint">{hint}</span>{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-hint {
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import type { Component } from "svelte";
interface Props {
icon: Component<{ size?: number; class?: string }>;
size?: number;
}
let { icon: IconComp, size = 16 }: Props = $props();
</script>
<IconComp {size} />
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Component } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
icon: Component<{ size?: number; class?: string }>;
label: string;
onclick?: () => void;
variant?: "ghost" | "solid" | "accent";
size?: number;
disabled?: boolean;
}
let {
icon,
label,
onclick,
variant = "ghost",
size = 16,
disabled = false,
}: Props = $props();
</script>
<button
type="button"
class="icon-btn icon-btn--{variant}"
aria-label={label}
{disabled}
onclick={() => onclick?.()}
>
<Icon {icon} {size} />
</button>
<style>
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
color: var(--foreground);
border-color: var(--blue);
}
.icon-btn--solid {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.icon-btn--accent {
background: var(--cyan);
color: #042;
border-color: var(--cyan);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
children: Snippet;
}
let { label, children }: Props = $props();
</script>
<figure class="image-card">
<MonoLabel>{label}</MonoLabel>
<div class="image-canvas">{@render children()}</div>
</figure>
<style>
.image-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.6rem;
background: var(--panel);
}
.image-canvas {
border-radius: var(--radius);
overflow: hidden;
}
</style>
@@ -0,0 +1,22 @@
<script lang="ts">
import MetaRow from "./MetaRow.svelte";
interface Props {
items: { caption: string; value: string }[];
}
let { items }: Props = $props();
</script>
<div class="meta-list">
{#each items as item (item.caption)}
<MetaRow caption={item.caption} value={item.value} />
{/each}
</div>
<style>
.meta-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
interface Props {
caption: string;
value: string;
}
let { caption, value }: Props = $props();
</script>
<div class="meta-row">
<span class="meta-caption">{caption}</span>
<span class="meta-value">{value}</span>
</div>
<style>
.meta-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0;
}
.meta-caption {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.meta-value {
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
accent?: boolean;
children?: Snippet;
}
let { accent = false, children }: Props = $props();
</script>
<span class="mono-label" class:accent>
{#if children}
{@render children()}
{/if}
</span>
<style>
.mono-label {
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.2;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.accent {
color: var(--blue);
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<section class="panel">
{@render children()}
</section>
<style>
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
title: string;
eyebrow?: string;
actions?: Snippet;
}
let { title, eyebrow, actions }: Props = $props();
</script>
<header class="panel-heading">
<div class="heading-text">
{#if eyebrow}<MonoLabel accent>{eyebrow}</MonoLabel>{/if}
<strong class="heading-title">{title}</strong>
</div>
{#if actions}<div class="heading-actions">{@render actions()}</div>{/if}
</header>
<style>
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
}
.heading-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.heading-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--foreground);
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
children: Snippet;
}
let { label, children }: Props = $props();
</script>
<div class="preview-tile">
<MonoLabel>{label}</MonoLabel>
<div class="tile-canvas">{@render children()}</div>
</div>
<style>
.preview-tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.4rem;
background: var(--panel);
}
.tile-canvas {
border-radius: var(--radius);
overflow: hidden;
min-height: 120px;
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
interface Option {
value: string;
label: string;
}
interface Props {
options: Option[];
value?: string;
onchange?: (value: string) => void;
}
let { options, value = $bindable(), onchange }: Props = $props();
function select(v: string) {
value = v;
onchange?.(v);
}
</script>
<div class="segmented" role="group">
{#each options as opt (opt.value)}
<button
type="button"
class="segment"
class:active={value === opt.value}
aria-pressed={value === opt.value}
onclick={() => select(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<style>
.segmented {
display: inline-flex;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
background: var(--background);
}
.segment {
border: none;
background: transparent;
color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.3rem 0.7rem;
cursor: pointer;
}
.segment + .segment {
border-left: 1px solid var(--line);
}
.segment.active {
background: var(--blue);
color: #fff;
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Option {
value: string;
label: string;
}
interface Props {
label: string;
value: string;
options: Option[];
onchange?: (value: string) => void;
}
let { label, value = $bindable(), options, onchange }: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLSelectElement).value;
onchange?.(value);
}
</script>
<Field {label}>
<select class="select-field" {value} onchange={handle}>
{#each options as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</Field>
<style>
.select-field {
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.35rem 0.5rem;
}
</style>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
children: Snippet;
}
let { label, children }: Props = $props();
</script>
<div class="setting-group">
<MonoLabel>{label}</MonoLabel>
<div class="setting-control">{@render children()}</div>
</div>
<style>
.setting-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--line);
}
.setting-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<footer class="settings-footer">{@render children()}</footer>
<style>
.settings-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--line);
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
suffix?: string;
oninput?: (value: number) => void;
}
let {
label,
value = $bindable(),
min = 0,
max = 100,
step = 1,
suffix = "",
oninput,
}: Props = $props();
function handle(e: Event) {
value = Number((e.target as HTMLInputElement).value);
oninput?.(value);
}
</script>
<Field {label}>
<div class="slider-field">
<input type="range" {min} {max} {step} {value} oninput={handle} />
<span class="slider-value">{value}{suffix}</span>
</div>
</Field>
<style>
.slider-field {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
}
input[type="range"] {
flex: 1;
accent-color: var(--blue);
}
.slider-value {
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
min-width: 4ch;
text-align: right;
}
</style>
@@ -0,0 +1,12 @@
<span class="status-dot" aria-hidden="true"></span>
<style>
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success) 25%, transparent);
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import StatusDot from "./StatusDot.svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
}
let { label }: Props = $props();
</script>
<span class="status-line">
<StatusDot />
<MonoLabel>{label}</MonoLabel>
</span>
<style>
.status-line {
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>
@@ -0,0 +1,82 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { GripVertical, X } from "@lucide/svelte";
import Badge from "./Badge.svelte";
interface Props {
index: number;
title?: string;
type?: string;
children?: Snippet;
onremove?: () => void;
}
let { index, title, type, children, onremove }: Props = $props();
</script>
<div class="step-card">
<div class="step-heading">
<span class="drag" aria-hidden="true"><GripVertical size={16} /></span>
<span class="step-index">{index.toString().padStart(2, "0")}</span>
{#if type}<Badge tone="accent">{type}</Badge>{/if}
{#if title}<span class="step-title">{title}</span>{/if}
<button
type="button"
class="step-remove"
aria-label="Remove step"
onclick={() => onremove?.()}
>
<X size={16} />
</button>
</div>
{#if children}
<div class="step-body">{@render children()}</div>
{/if}
</div>
<style>
.step-card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.step-heading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--line);
}
.drag {
color: var(--muted);
cursor: grab;
display: inline-flex;
}
.step-index {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
}
.step-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--foreground);
flex: 1;
}
.step-remove {
margin-left: auto;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
display: inline-flex;
}
.step-remove:hover {
color: var(--danger);
}
.step-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
interface Props {
value: string;
type?: string;
placeholder?: string;
readonly?: boolean;
label?: string;
oninput?: (value: string) => void;
}
let {
value = $bindable(),
type = "text",
placeholder = "",
readonly = false,
label,
oninput,
}: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
oninput?.(value);
}
</script>
<input
class="text-field"
{type}
{placeholder}
{readonly}
{value}
aria-label={label}
oninput={handle}
/>
<style>
.text-field {
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.35rem 0.5rem;
}
.text-field:focus {
outline: none;
border-color: var(--blue);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
checked: boolean;
label?: string;
onchange?: (checked: boolean) => void;
}
let { checked = $bindable(), label, onchange }: Props = $props();
function handle() {
checked = !checked;
onchange?.(checked);
}
</script>
<button
type="button"
class="toggle"
class:on={checked}
role="switch"
aria-checked={checked}
aria-label={label}
onclick={handle}
>
<span class="toggle-knob"></span>
</button>
<style>
.toggle {
position: relative;
width: 2.4rem;
height: 1.3rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--background);
cursor: pointer;
padding: 0;
}
.toggle.on {
background: var(--blue);
border-color: var(--blue);
}
.toggle-knob {
position: absolute;
top: 1px;
left: 1px;
width: calc(1.3rem - 4px);
height: calc(1.3rem - 4px);
border-radius: 50%;
background: var(--foreground);
transition: transform 0.12s ease;
}
.toggle.on .toggle-knob {
transform: translateX(1.1rem);
background: #fff;
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
import { ArrowUpRight } from "@lucide/svelte";
import type { Component } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
title: string;
href: string;
description?: string;
index?: number;
icon?: Component<{ size?: number; class?: string }>;
}
let { title, href, description, index, icon }: Props = $props();
</script>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="tool-card" {href}>
{#if icon}<span class="tool-icon"><Icon {icon} size={18} /></span>{/if}
<span class="tool-index">
{index !== undefined ? index.toString().padStart(2, "0") : ""}
</span>
<span class="tool-copy">
<span class="tool-title">{title}</span>
{#if description}<span class="tool-desc">{description}</span>{/if}
</span>
<span class="tool-arrow"><ArrowUpRight size={16} /></span>
</a>
<style>
.tool-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
color: var(--foreground);
text-decoration: none;
}
.tool-card:hover {
border-color: var(--blue);
}
.tool-icon {
color: var(--blue);
display: inline-flex;
}
.tool-index {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
}
.tool-copy {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.tool-title {
font-weight: 600;
font-size: 0.9rem;
}
.tool-desc {
font-size: 0.8rem;
color: var(--muted);
}
.tool-arrow {
color: var(--muted);
}
.tool-card:hover .tool-arrow {
color: var(--blue);
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import IconButton from "./IconButton.svelte";
import Segmented from "./Segmented.svelte";
import StatusDot from "./StatusDot.svelte";
import { CircleHelp, Moon, Sun } from "@lucide/svelte";
interface Props {
theme: "light" | "dark";
ontoggle: () => void;
}
let { theme, ontoggle }: Props = $props();
let lang = $state("RU");
</script>
<header class="topbar">
<div class="brand">
<span class="brand-mark">EP</span>
<span>easy-png-tools</span>
</div>
<div class="top-actions">
<span class="status"><StatusDot /> AUTO PIPELINE</span>
<IconButton icon={CircleHelp} label="Help" onclick={() => {}} />
<IconButton
icon={theme === "light" ? Moon : Sun}
label="Toggle theme"
onclick={ontoggle}
/>
<Segmented
bind:value={lang}
options={[
{ value: "RU", label: "RU" },
{ value: "EN", label: "EN" },
]}
/>
</div>
</header>
<style>
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
padding: 0 clamp(20px, 4vw, 64px);
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font: 600 14px var(--font-mono);
}
.brand-mark {
background: var(--blue);
color: #fff;
width: 30px;
height: 30px;
font-size: 11px;
display: grid;
place-items: center;
}
.top-actions {
display: flex;
align-items: center;
gap: 16px;
}
.status {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted);
font: 10px var(--font-mono);
letter-spacing: 0.08em;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
@import "@fontsource/ibm-plex-sans/400.css";
@import "@fontsource/ibm-plex-sans/500.css";
@import "@fontsource/ibm-plex-sans/600.css";
@import "@fontsource/ibm-plex-sans/700.css";
@import "@fontsource/ibm-plex-mono/400.css";
@import "@fontsource/ibm-plex-mono/500.css";
/* Минимальный reset (новый дизайн грузится только на preview-маршрутах). */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
/* Форм-контролы не наследуют шрифт семьи по умолчанию (браузер ставит Arial). */
button,
input,
select,
textarea,
optgroup {
font-family: inherit;
}
/* Новый дизайн: токены (§2 плана redesign).
Файл грузится только layout'ом новой ветки (preview/+layout), поэтому
глобально не конфликтует со старым app.css. */
:root {
--font-sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
--background: #eef1f4;
--panel: #f8fafb;
--foreground: #17212b;
--muted: #6d7883;
--line: #cbd3da;
--blue: #1769d2;
--cyan: #00a8c7;
--amber: #bd7411;
--success: #25a96a;
--danger: #e5484d;
--radius: 4px;
}
[data-theme="dark"] {
--background: #11171d;
--panel: #182129;
--foreground: #e8eef2;
--muted: #91a0ac;
--line: #33414c;
--blue: #54a2ff;
--cyan: #00a8c7;
--amber: #bd7411;
--success: #25a96a;
--danger: #ff7479;
}
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import '../../app.css';
import favicon from '$lib/assets/favicon.svg';
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
import { getTheme, initTheme, setTheme } from '$lib/theme.svelte';
import { t } from '$lib/i18n/t';
import { LOCALES, type Locale } from '$lib/i18n/dict';
let { children } = $props();
onMount(() => {
initLocale();
initTheme();
});
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="app">
<header>
<a href={resolve('/')} class="brand">easy-png-tools</a>
<nav aria-label={t('header.sectionsAria')}>
<a class="nav-link workspace-link" href={resolve('/')}>{t('header.workspace')}</a>
<a class="nav-link" href={resolve('/list-tools')}>{t('header.catalog')}</a>
<div class="lang-switch" role="group" aria-label="Language / Язык">
{#each LOCALES as l (l)}
<button
type="button"
class="lang-btn"
class:active={getLocale() === l}
aria-pressed={getLocale() === l}
onclick={() => setLocale(l)}
>
{LANG_LABELS[l]}
</button>
{/each}
</div>
<div class="theme-switch" role="group" aria-label="Theme / Тема">
<button
type="button"
class="lang-btn"
class:active={getTheme() === 'light'}
aria-pressed={getTheme() === 'light'}
title={t('ui.themeLight')}
aria-label={t('ui.themeLight')}
onclick={() => setTheme('light')}
>
</button>
<button
type="button"
class="lang-btn"
class:active={getTheme() === 'dark'}
aria-pressed={getTheme() === 'dark'}
title={t('ui.themeDark')}
aria-label={t('ui.themeDark')}
onclick={() => setTheme('dark')}
>
</button>
</div>
</nav>
</header>
<main>
{@render children()}
</main>
<footer>
<p class="text-caption text-muted">{t('header.footerNote')}</p>
</footer>
</div>
<style>
.app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--text);
}
.brand:hover {
text-decoration: none;
color: var(--link);
}
nav {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.nav-link {
color: var(--text-muted);
font-size: var(--text-s);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-s);
}
.nav-link:hover {
color: var(--link);
background: var(--bg);
text-decoration: none;
}
.lang-switch,
.theme-switch {
display: flex;
gap: 2px;
margin-left: var(--space-2);
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--bg);
}
.lang-btn {
border: none;
background: transparent;
color: var(--text-muted);
font-size: var(--text-s);
font-weight: 600;
padding: 2px 8px;
border-radius: calc(var(--radius-s) - 1px);
cursor: pointer;
}
.lang-btn:hover {
color: var(--link);
}
.lang-btn.active {
background: var(--accent);
color: var(--bg);
}
.workspace-link {
font-weight: 600;
color: var(--link);
}
main {
flex: 1;
width: 100%;
padding: var(--space-4) var(--space-5);
}
footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
text-align: center;
}
</style>
+3 -159
View File
@@ -1,12 +1,8 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
import { getTheme, initTheme, setTheme } from '$lib/theme.svelte';
import { t } from '$lib/i18n/t';
import { LOCALES, type Locale } from '$lib/i18n/dict';
import { initLocale } from '$lib/i18n/locale.svelte';
import { initTheme } from '$lib/theme.svelte';
let { children } = $props();
@@ -14,162 +10,10 @@
initLocale();
initTheme();
});
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="app">
<header>
<a href={resolve('/')} class="brand">easy-png-tools</a>
<nav aria-label={t('header.sectionsAria')}>
<a class="nav-link workspace-link" href={resolve('/')}>{t('header.workspace')}</a>
<a class="nav-link" href={resolve('/list-tools')}>{t('header.catalog')}</a>
<div class="lang-switch" role="group" aria-label="Language / Язык">
{#each LOCALES as l (l)}
<button
type="button"
class="lang-btn"
class:active={getLocale() === l}
aria-pressed={getLocale() === l}
onclick={() => setLocale(l)}
>
{LANG_LABELS[l]}
</button>
{/each}
</div>
<div class="theme-switch" role="group" aria-label="Theme / Тема">
<button
type="button"
class="lang-btn"
class:active={getTheme() === 'light'}
aria-pressed={getTheme() === 'light'}
title={t('ui.themeLight')}
aria-label={t('ui.themeLight')}
onclick={() => setTheme('light')}
>
</button>
<button
type="button"
class="lang-btn"
class:active={getTheme() === 'dark'}
aria-pressed={getTheme() === 'dark'}
title={t('ui.themeDark')}
aria-label={t('ui.themeDark')}
onclick={() => setTheme('dark')}
>
</button>
</div>
</nav>
</header>
<main>
{@render children()}
</main>
<footer>
<p class="text-caption text-muted">{t('header.footerNote')}</p>
</footer>
</div>
<style>
.app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--text);
}
.brand:hover {
text-decoration: none;
color: var(--link);
}
nav {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.nav-link {
color: var(--text-muted);
font-size: var(--text-s);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-s);
}
.nav-link:hover {
color: var(--link);
background: var(--bg);
text-decoration: none;
}
.lang-switch,
.theme-switch {
display: flex;
gap: 2px;
margin-left: var(--space-2);
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--bg);
}
.lang-btn {
border: none;
background: transparent;
color: var(--text-muted);
font-size: var(--text-s);
font-weight: 600;
padding: 2px 8px;
border-radius: calc(var(--radius-s) - 1px);
cursor: pointer;
}
.lang-btn:hover {
color: var(--link);
}
.lang-btn.active {
background: var(--accent);
color: var(--bg);
}
.workspace-link {
font-weight: 600;
color: var(--link);
}
main {
flex: 1;
width: 100%;
padding: var(--space-4) var(--space-5);
}
footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
text-align: center;
}
</style>
{@render children()}
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import "$lib/styles/design2.css";
import type { Snippet } from "svelte";
import AppShell from "$lib/components/kit/AppShell.svelte";
import TopBar from "$lib/components/kit/TopBar.svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
let theme = $state<"light" | "dark">("light");
function toggle() {
theme = theme === "light" ? "dark" : "light";
}
</script>
<div class="preview-root" data-theme={theme}>
<TopBar {theme} ontoggle={toggle} />
<AppShell>{@render children()}</AppShell>
</div>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { TOOLS } from "$lib/registry";
import Panel from "$lib/components/kit/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import TextField from "$lib/components/kit/TextField.svelte";
import ToolCard from "$lib/components/kit/ToolCard.svelte";
import Button from "$lib/components/kit/Button.svelte";
import { Search } from "@lucide/svelte";
let query = $state("");
const results = $derived(
TOOLS.filter((t) =>
(t.title + " " + t.description)
.toLowerCase()
.includes(query.toLowerCase()),
).slice(0, 24),
);
</script>
<section class="workspace">
<header class="ws-head">
<span class="ws-eyebrow">EASY PNG TOOLS</span>
<h1 class="ws-title">Workspace</h1>
<p class="ws-lede">
Откройте PNG-инструмент, загрузите изображение и получите результат без
установки и регистрации.
</p>
</header>
<Panel>
<PanelHeading title="Search" eyebrow="tools" />
<div class="ws-search">
<div class="ws-field">
<TextField
label="Search tools"
bind:value={query}
placeholder="Search tools…"
/>
</div>
<Button icon={Search} variant="primary" onclick={() => {}}>Search</Button>
</div>
<div class="ws-results">
{#each results as tool (tool.id)}
<ToolCard title={tool.title} href="#" description={tool.description} />
{/each}
</div>
<SettingsFooter>
<Button onclick={() => {}}>Open last project</Button>
</SettingsFooter>
</Panel>
</section>
<style>
.workspace {
max-width: 880px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ws-head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ws-eyebrow {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.18em;
color: var(--muted);
}
.ws-title {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: var(--foreground);
}
.ws-lede {
margin: 0;
max-width: 56ch;
color: var(--muted);
font-size: 0.95rem;
}
.ws-search {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
align-items: center;
}
.ws-field {
flex: 1;
}
.ws-results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
padding: 0.5rem 1rem 1rem;
}
</style>
+513
View File
@@ -0,0 +1,513 @@
<script lang="ts">
import { Upload, Settings2, RotateCcw, ChevronDown } from "@lucide/svelte";
import StepCard from "$lib/components/kit/StepCard.svelte";
import Segmented from "$lib/components/kit/Segmented.svelte";
import SliderField from "$lib/components/kit/SliderField.svelte";
import ColorField from "$lib/components/kit/ColorField.svelte";
import Toggle from "$lib/components/kit/Toggle.svelte";
import IconButton from "$lib/components/kit/IconButton.svelte";
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
import Badge from "$lib/components/kit/Badge.svelte";
import StatusDot from "$lib/components/kit/StatusDot.svelte";
import MonoLabel from "$lib/components/kit/MonoLabel.svelte";
import MetaList from "$lib/components/kit/MetaList.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
let gradColor = $state("#DCEBFF");
let direction = $state(135);
let opacity = $state("100");
let outlineWidth = $state(2);
let outlineColor = $state("#16202B");
let radius = $state(18);
let preserveAspect = $state(true);
const previewTiles = [
{ label: "SOURCE", caption: "original.png · 1200 × 800", bg: "#8d9aa5" },
{
label: "STEP 01",
caption: "gradient applied",
bg: "linear-gradient(135deg, #DCEBFF, #8BC8F5)",
},
{
label: "STEP 02",
caption: "background removed",
bg: "linear-gradient(135deg, #DCEBFF, #8BC8F5)",
},
{
label: "STEP 03",
caption: "outline added",
bg: "linear-gradient(135deg, #DCEBFF, #8BC8F5)",
ring: "0 0 0 2px #16202B",
},
{
label: "FINAL OUTPUT",
caption: "ready · PNG-24",
bg: "linear-gradient(135deg, #DCEBFF, #8BC8F5)",
radius: "18px",
ring: "0 0 0 2px #16202B",
active: true,
},
];
</script>
<svelte:head>
<title>easy-png-tools / Demo</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<div class="page-grid">
<section class="workspace">
<div class="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div>
<div class="title-row">
<div>
<h1>Build your image pipeline.</h1>
<p class="lede">
Chain simple tools together. Every change is processed automatically
and previewed at each stage.
</p>
</div>
<div class="file-chip">
<Upload size={15} />
<span>source.png</span>
<b>1.8 MB</b>
</div>
</div>
<div class="pipeline-head">
<div>
<span class="label">PROCESSING PIPELINE</span>
<strong>4 active steps <em>• LIVE</em></strong>
</div>
<button class="add-btn" onclick={() => {}}>+ Add tool</button>
</div>
<div class="steps-list">
<StepCard
index={1}
type="BACKGROUND"
title="Gradient background"
onremove={() => {}}
>
<div class="step-tools"><Badge tone="success">AUTO</Badge></div>
<div class="controls">
<div class="control-block">
<MonoLabel>COLOR</MonoLabel>
<ColorField label="Gradient color" bind:value={gradColor} />
</div>
<SliderField
label="DIRECTION"
bind:value={direction}
min={0}
max={360}
suffix="°"
/>
<div class="control-block">
<MonoLabel>OPACITY</MonoLabel>
<Segmented
bind:value={opacity}
options={[
{ value: "100", label: "100" },
{ value: "75", label: "75" },
{ value: "50", label: "50" },
{ value: "25", label: "25" },
]}
/>
</div>
</div>
</StepCard>
<StepCard
index={2}
type="TRANSFORM"
title="Remove background"
onremove={() => {}}
>
<div class="step-tools"><Badge tone="success">AUTO</Badge></div>
<div class="transform-note">Automatic subject detection enabled</div>
</StepCard>
<StepCard index={3} type="STYLE" title="Add outline" onremove={() => {}}>
<div class="step-tools"><Badge tone="success">AUTO</Badge></div>
<div class="controls compact">
<div class="control-block">
<MonoLabel>WIDTH</MonoLabel>
<SliderField
label="Outline width"
bind:value={outlineWidth}
min={0}
max={8}
suffix="px"
/>
</div>
<div class="control-block">
<MonoLabel>COLOR</MonoLabel>
<ColorField label="Outline color" bind:value={outlineColor} />
</div>
</div>
</StepCard>
<StepCard
index={4}
type="STYLE"
title="Round corners"
onremove={() => {}}
>
<div class="step-tools"><Badge tone="success">AUTO</Badge></div>
<div class="controls compact">
<div class="control-block">
<MonoLabel>RADIUS</MonoLabel>
<SliderField
label="Corner radius"
bind:value={radius}
min={0}
max={48}
suffix="px"
/>
</div>
<label class="toggle-row">
<span>Preserve aspect ratio</span>
<Toggle
bind:checked={preserveAspect}
label="Preserve aspect ratio"
/>
</label>
</div>
</StepCard>
</div>
<div class="pipeline-footer">
<button class="reset-btn" onclick={() => {}}>
<RotateCcw size={14} /> Reset pipeline
</button>
<span class="auto-note"
><StatusDot /> changes are applied automatically</span
>
</div>
</section>
<section class="preview-panel">
<div class="preview-top">
<div>
<span class="label">PIPELINE OUTPUTS</span>
<strong>Visual history</strong>
</div>
<div class="preview-actions">
<button class="history-toggle" onclick={() => {}}>
Hide intermediate <ChevronDown size={15} />
</button>
<DownloadButton
label="Download result"
size="1.2 MB"
onclick={() => {}}
/>
<MetaList
items={[
{ caption: "DIMENSIONS", value: "1200 × 800 px" },
{ caption: "FORMAT", value: "PNG-24" },
{ caption: "SIZE", value: "1.2 MB" },
]}
/>
<IconButton
icon={Settings2}
label="Preview settings"
onclick={() => {}}
/>
</div>
</div>
<div class="preview-stack">
{#each previewTiles as tile (tile.label)}
<PreviewTile label={tile.label}>
<CheckerCanvas size="large">
<div
class="ph"
class:active={tile.active}
style="background:{tile.bg}; border-radius:{tile.radius ??
'0'}; box-shadow:{tile.ring ?? 'none'};"
>
<span class="sample-icon">PNG</span>
<span>easy-png-tools</span>
</div>
</CheckerCanvas>
<div class="tile-label-wrap">
<span class="tile-label">{tile.label}</span>
<span class="tile-caption">{tile.caption}</span>
</div>
</PreviewTile>
{/each}
</div>
<p class="preview-note">
Output is generated in-browser. Your files never leave this device.
</p>
</section>
</div>
<style>
.page-grid {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(520px, 0.95fr);
align-items: start;
gap: 56px;
max-width: 1680px;
margin: 0 auto;
padding: 60px clamp(24px, 4vw, 72px) 72px;
}
.workspace {
min-width: 0;
}
.eyebrow {
font: 10px var(--font-mono);
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 18px;
}
.eyebrow span {
margin: 0 7px;
color: var(--blue);
}
.title-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 30px;
margin-bottom: 56px;
}
h1 {
margin: 0 0 16px;
font-size: clamp(36px, 4vw, 64px);
font-weight: 650;
letter-spacing: -0.06em;
color: var(--foreground);
}
.lede {
margin: 0;
max-width: 550px;
color: var(--muted);
line-height: 1.6;
}
.file-chip {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
border: 1px solid var(--line);
background: var(--panel);
font: 11px var(--font-mono);
color: var(--muted);
padding: 10px 12px;
}
.file-chip b {
color: var(--blue);
font-weight: 500;
}
.pipeline-head {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.label {
font: 10px var(--font-mono);
letter-spacing: 0.12em;
color: var(--muted);
}
.pipeline-head strong {
display: block;
margin-top: 5px;
font: 600 14px var(--font-mono);
color: var(--foreground);
}
.pipeline-head em {
color: #25a96a;
font-style: normal;
font-size: 10px;
}
.add-btn {
display: flex;
align-items: center;
gap: 7px;
color: var(--blue);
background: none;
border: 0;
font-size: 12px;
cursor: pointer;
}
.steps-list {
display: grid;
gap: 10px;
padding-top: 14px;
}
.step-tools {
display: flex;
gap: 8px;
align-items: center;
}
.controls {
display: grid;
grid-template-columns: 1.1fr 1fr 1.2fr;
gap: 16px;
}
.controls.compact {
grid-template-columns: 1fr 1fr;
}
.control-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.transform-note {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font: 11px var(--font-mono);
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
color: var(--foreground);
font-size: 0.85rem;
cursor: pointer;
}
.pipeline-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 18px;
}
.reset-btn {
display: flex;
align-items: center;
gap: 7px;
color: var(--blue);
background: none;
border: 0;
cursor: pointer;
font-size: 12px;
}
.auto-note {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font: 11px var(--font-mono);
}
.preview-panel {
position: sticky;
top: 24px;
border: 1px solid var(--line);
background: var(--panel);
padding: 18px;
min-width: 0;
}
.preview-top {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 15px;
gap: 16px;
}
.preview-top strong {
display: block;
margin-top: 5px;
font: 600 14px var(--font-mono);
}
.preview-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.history-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 18px;
border: 1px solid var(--line);
color: var(--muted);
font: 10px var(--font-mono);
letter-spacing: 0.04em;
background: none;
padding: 11px 14px;
cursor: pointer;
}
.history-toggle:hover {
color: var(--foreground);
background: var(--background);
}
.preview-stack {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px 0;
}
.ph {
width: 80%;
aspect-ratio: 1.35;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
color: #16202b;
font: 600 10px var(--font-mono);
}
.ph.active {
border-color: var(--blue);
}
.sample-icon {
border: 1px solid;
padding: 3px 5px;
font-size: 9px;
}
.tile-label-wrap {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 8px;
border-top: 1px solid var(--line);
font: 9px var(--font-mono);
color: var(--blue);
}
.tile-caption {
color: var(--muted);
font-weight: 400;
text-align: right;
}
.preview-note {
margin: 12px 0 0;
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
@media (max-width: 800px) {
.page-grid {
grid-template-columns: 1fr;
gap: 24px;
padding: 36px 16px 48px;
}
.title-row {
flex-direction: column;
align-items: flex-start;
}
.preview-panel {
position: static;
}
.controls,
.controls.compact {
grid-template-columns: 1fr;
}
.preview-stack {
grid-template-columns: 1fr;
}
}
</style>
+251
View File
@@ -0,0 +1,251 @@
<script lang="ts">
import Panel from "$lib/components/kit/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import SettingGroup from "$lib/components/kit/SettingGroup.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import MetaList from "$lib/components/kit/MetaList.svelte";
import Badge from "$lib/components/kit/Badge.svelte";
import Segmented from "$lib/components/kit/Segmented.svelte";
import StepCard from "$lib/components/kit/StepCard.svelte";
import ToolCard from "$lib/components/kit/ToolCard.svelte";
import ImageCard from "$lib/components/kit/ImageCard.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
import StatusLine from "$lib/components/kit/StatusLine.svelte";
import Button from "$lib/components/kit/Button.svelte";
import IconButton from "$lib/components/kit/IconButton.svelte";
import SliderField from "$lib/components/kit/SliderField.svelte";
import ColorField from "$lib/components/kit/ColorField.svelte";
import TextField from "$lib/components/kit/TextField.svelte";
import SelectField from "$lib/components/kit/SelectField.svelte";
import CheckboxField from "$lib/components/kit/CheckboxField.svelte";
import Toggle from "$lib/components/kit/Toggle.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte";
import CodeBlock from "$lib/components/kit/CodeBlock.svelte";
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
import { Plus, Trash2, Download, ImageOff, Palette } from "@lucide/svelte";
let mode = $state("preview");
let radius = $state(8);
let tint = $state("#3b82f6");
let fileName = $state("image.png");
let format = $state("png");
let lossless = $state(true);
let animated = $state(false);
const tools = [
{
title: "Gradient",
href: "#",
description: "Linear & radial gradients",
},
{
title: "Resize",
href: "#",
description: "Pixel-perfect scaling",
},
{
title: "Quantize",
href: "#",
description: "Reduce color depth",
},
];
</script>
<div class="showcase">
<header class="showcase-head">
<span class="showcase-eyebrow">KIT SHOWCASE</span>
<StatusLine label="rendering ok" />
</header>
<Panel>
<PanelHeading title="Buttons" eyebrow="controls" />
<div class="stack">
<SettingGroup label="variants">
<Button onclick={() => {}}>Primary</Button>
<Button variant="ghost" onclick={() => {}}>Ghost</Button>
<Button variant="accent" onclick={() => {}}>Accent</Button>
<Button variant="danger" onclick={() => {}}>Danger</Button>
</SettingGroup>
<SettingGroup label="states">
<Button disabled onclick={() => {}}>Disabled</Button>
<IconButton icon={Plus} label="Add" onclick={() => {}} />
<IconButton
icon={Trash2}
label="Delete"
variant="solid"
onclick={() => {}}
/>
<IconButton
icon={Download}
label="Download"
disabled
onclick={() => {}}
/>
</SettingGroup>
</div>
<SettingsFooter>
<DownloadButton label="Download PNG" size="1.2 MB" onclick={() => {}} />
</SettingsFooter>
</Panel>
<Panel>
<PanelHeading title="Inputs" eyebrow="controls" />
<div class="stack">
<SliderField
label="corner radius"
bind:value={radius}
min={0}
max={32}
suffix="px"
/>
<ColorField label="accent tint" bind:value={tint} />
<TextField
label="file name"
bind:value={fileName}
placeholder="image.png"
/>
<SelectField
label="format"
bind:value={format}
options={[
{ value: "png", label: "PNG" },
{ value: "webp", label: "WebP" },
{ value: "avif", label: "AVIF" },
]}
/>
<SettingGroup label="options">
<CheckboxField label="lossless" bind:checked={lossless} />
<Toggle bind:checked={animated} label="animate" />
</SettingGroup>
</div>
</Panel>
<Panel>
<PanelHeading title="Segmented & badges" eyebrow="controls" />
<div class="stack">
<Segmented
bind:value={mode}
options={[
{ value: "preview", label: "Preview" },
{ value: "compare", label: "Compare" },
{ value: "code", label: "Code" },
]}
/>
<SettingGroup label="tones">
<Badge>default</Badge>
<Badge tone="accent">accent</Badge>
<Badge tone="success">success</Badge>
<Badge tone="amber">amber</Badge>
</SettingGroup>
</div>
</Panel>
<Panel>
<PanelHeading title="Steps" eyebrow="pipeline" />
<div class="stack">
<StepCard index={1} type="BACKGROUND" title="Gradient fill">
<ColorField label="color" bind:value={tint} />
</StepCard>
<StepCard index={2} type="TRANSFORM" title="Resize" onremove={() => {}}>
<SliderField label="scale" bind:value={radius} suffix="%" />
</StepCard>
</div>
</Panel>
<Panel>
<PanelHeading title="Tools" eyebrow="catalog" />
<div class="tool-grid">
{#each tools as tool, i (tool.title)}
<ToolCard
title={tool.title}
href={tool.href}
description={tool.description}
index={i + 1}
icon={Palette}
/>
{/each}
</div>
</Panel>
<Panel>
<PanelHeading title="Preview" eyebrow="canvas" />
<div class="preview-grid">
<ImageCard label="result.png · 512×512">
<CheckerCanvas size="sm" />
</ImageCard>
<PreviewTile label="tile-01">
<CheckerCanvas size="sm" />
</PreviewTile>
<PreviewTile label="tile-02">
<CheckerCanvas size="sm" />
</PreviewTile>
</div>
<SettingsFooter>
<MetaList
items={[
{ caption: "dimensions", value: "512 × 512" },
{ caption: "format", value: "PNG" },
{ caption: "size", value: "48.2 KB" },
]}
/>
</SettingsFooter>
</Panel>
<Panel>
<PanelHeading title="States & code" eyebrow="misc" />
<div class="stack">
<EmptyState
icon={ImageOff}
title="No image loaded"
description="Drop a PNG to start processing"
>
<Button onclick={() => {}}>Open file</Button>
</EmptyState>
<CodeBlock
code={'export const config = {\n radius: 8,\n tint: "#3b82f6",\n};'}
lang="ts"
copyable
oncopy={() => {}}
/>
</div>
</Panel>
</div>
<style>
.showcase {
max-width: 880px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.showcase-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.showcase-eyebrow {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.18em;
color: var(--muted);
}
.stack {
display: flex;
flex-direction: column;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
padding: 0.75rem 1rem;
}
</style>
@@ -0,0 +1,89 @@
<script lang="ts">
import { TOOLS } from "$lib/registry";
import { CATEGORIES, type CategoryId } from "$lib/categories";
import Panel from "$lib/components/kit/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import ToolCard from "$lib/components/kit/ToolCard.svelte";
const TITLES: Record<CategoryId, string> = {
convert: "Convert",
alpha: "Alpha & transparency",
color: "Color",
geometry: "Geometry",
filters: "Filters",
text: "Text",
analyze: "Analyze",
generate: "Generate",
};
const groups = CATEGORIES.map((cat) => ({
id: cat,
title: TITLES[cat],
tools: TOOLS.filter((t) => t.category === cat),
}));
</script>
<section class="catalog">
<header class="cat-head">
<span class="cat-eyebrow">CATALOG</span>
<h1 class="cat-title">All tools</h1>
<p class="cat-lede">
Каталог всех PNG-инструментов, сгруппированный по назначению.
</p>
</header>
{#each groups as group (group.id)}
<Panel>
<PanelHeading title={group.title} eyebrow={group.id} />
<div class="cat-grid">
{#each group.tools as tool (tool.id)}
<ToolCard
title={tool.title}
href="#"
description={tool.description}
/>
{/each}
</div>
</Panel>
{/each}
</section>
<style>
.catalog {
max-width: 880px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.cat-head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cat-eyebrow {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.18em;
color: var(--muted);
}
.cat-title {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: var(--foreground);
}
.cat-lede {
margin: 0;
max-width: 56ch;
color: var(--muted);
font-size: 0.95rem;
}
.cat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
padding: 0.75rem 1rem 1rem;
}
</style>