Compare commits
14
Commits
83d3ce1198
...
f4216e6868
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4216e6868 | ||
|
|
bcdfc4cf12 | ||
|
|
adcc255107 | ||
|
|
73118ea393 | ||
|
|
e9578fc15e | ||
|
|
06d185add6 | ||
|
|
8437c95a28 | ||
|
|
ac75665deb | ||
|
|
bc5a5d13c4 | ||
|
|
0acfa3aeb9 | ||
|
|
418a091505 | ||
|
|
f08af41568 | ||
|
|
dfb2311e32 | ||
|
|
84534b2f58 |
@@ -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 строк), атомарные
|
||||
@@ -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. Переснять скриншоты и перепроверить пиксель-дифф.
|
||||
@@ -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`, 16–17px, muted).
|
||||
|
||||
## 4. Минимальный UI-kit (`src/lib/components/kit/`)
|
||||
|
||||
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн
|
||||
(обратной совместимости со старым `ui/` не нужно — старые страницы его
|
||||
продолжают использовать).
|
||||
|
||||
| Компонент | Назначение |
|
||||
| ------------------------- | ----------------------------------------------------------------- |
|
||||
| `Button.svelte` | `primary` (solid blue) / `secondary` (outline) / `ghost` + `icon` |
|
||||
| `Field.svelte` | обёртка: `MonoLabel` + слот значения/контрола |
|
||||
| `SliderField.svelte` | range + `<output>`, опц. строка подсказок |
|
||||
| `ColorField.svelte` | swatch + hex-input + нативный picker |
|
||||
| `TextField.svelte` | текстовый ввод в стиле дизайна |
|
||||
| `SelectField.svelte` | селект в стиле дизайна |
|
||||
| `CheckboxField.svelte` | чекбокс → switch-toggle 32×18 |
|
||||
| `Toggle.svelte` | переключатель (для toggle-group) |
|
||||
| `SegmentedControl.svelte` | на базе `Segmented` — RU/EN, Linear/Radial, пресеты |
|
||||
| `Badge.svelte` | на базе `Badge` |
|
||||
| `MetaList.svelte` | на базе `MetaRow` |
|
||||
| `EmptyState.svelte` | пустое состояние под новый стиль |
|
||||
| `CodeBlock.svelte` | блок кода (gradient-art / direction-grid) |
|
||||
| `DownloadButton.svelte` | кнопка скачивания (можно переиспользовать идею старой) |
|
||||
|
||||
Иконки: `lucide-svelte` (те же пути, MIT), 16–17px, stroke, muted.
|
||||
|
||||
## 5. Изоляция старого и нового (критично)
|
||||
|
||||
Без префиксов и обёрток — только за счёт того, какие layout грузят какой CSS.
|
||||
|
||||
- **Корневой layout `src/routes/+layout.svelte`** перестаёт импортировать
|
||||
`../app.css`. В нём остаётся только общая логика (init темы/locale) и
|
||||
нейтральный `<div>{children}</div>` без глобальных стилей дизайна.
|
||||
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css`
|
||||
и содержит текущую шапку/подвал (разметку из нынешнего корневого layout).
|
||||
Все старые страницы переносятся внутрь `(old)/` (группа в скобках не
|
||||
меняет URL): `(old)/+page.svelte`, `(old)/demo/...`,
|
||||
`(old)/list-tools/...`, `(old)/tools/[id]/...`.
|
||||
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует
|
||||
`design2.css` и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
|
||||
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится
|
||||
только `app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
|
||||
Svelte-компонентов добавляет второй уровень изоляции. Никаких `.ds2`.
|
||||
- Переключатель для ручной проверки: временный флаг `previewDesign` в
|
||||
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на
|
||||
`/preview`. На фазе 4 флаг убираем.
|
||||
|
||||
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы
|
||||
> не могут одновременно определять один и тот же URL (`(old)/+page.svelte`
|
||||
> и `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая
|
||||
> ветка живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на
|
||||
> реальные пути и группа `(old)` удаляется.
|
||||
>
|
||||
> Это совпадает с рекомендованным в документации SvelteKit паттерном:
|
||||
> «put your entire app inside one or more groups except the routes that
|
||||
> should not inherit the common layouts» — старое целиком в `(old)`, новое
|
||||
> снаружи. Синтаксис `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь
|
||||
> не требуется: «выломаться» можно лишь до корня, а нам нужно, чтобы именно
|
||||
> у корня НЕ было `app.css`; если позже захотим держать новые маршруты внутри
|
||||
> группы, `@` пригодится для сброса родительского layout.
|
||||
|
||||
## 6. Маршрутизация параллельных страниц
|
||||
|
||||
Новые страницы живут в **отдельной ветке `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
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from '../../page'
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+7
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3931
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
msw: set this to true or false
|
||||
@@ -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 |
@@ -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 |
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
static
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
@@ -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`
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1014
-20
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user