Compare commits

...
17 Commits
Author SHA1 Message Date
Ku6epXBOCTuK d1a386f92f fix: update tools list - show all tools 2026-09-03 16:29:35 +05:00
Ku6epXBOCTuK 1813383724 docs: update plan 2026-09-03 16:29:05 +05:00
Ku6epXBOCTuK 1ae4b36c9c feat: add new registry schema for new ui 2026-09-03 15:58:20 +05:00
Ku6epXBOCTuK a57acd1ff2 docs: add plan for composite params 2026-09-03 05:13:43 +05:00
Ku6epXBOCTuK 6ec3e75df4 refactor: move form fields to separate folder 2026-09-02 21:11:43 +05:00
Ku6epXBOCTuK a24a182d18 fix: update segmented component styles 2026-09-02 20:48:09 +05:00
Ku6epXBOCTuK 1e63eaa03b fix: increase font size 2026-09-02 20:19:40 +05:00
Ku6epXBOCTuK e9de7a4bf8 refactor: move components 2026-09-02 20:08:05 +05:00
Ku6epXBOCTuK 7e94205866 refactor: update panel component 2026-09-02 19:55:52 +05:00
Ku6epXBOCTuK 2f758a88af refactor: update panel component 2026-09-02 19:33:34 +05:00
Ku6epXBOCTuK 991fe69381 refactor: separate top bar component 2026-09-02 19:31:04 +05:00
Ku6epXBOCTuK 11c4ccece4 refactor: update footer component 2026-09-02 18:49:55 +05:00
Ku6epXBOCTuK 8a6f87938d refactor: icon and download button use button component 2026-09-02 18:37:43 +05:00
Ku6epXBOCTuK 3d0f8ebb10 fix: update field component 2026-09-02 17:54:31 +05:00
Ku6epXBOCTuK 28ff9d3ee1 chore: update lint rules - css property shorthand fix 2026-09-02 17:47:13 +05:00
Ku6epXBOCTuK ef74cabf1e fix: update download button style 2026-09-02 17:37:45 +05:00
Ku6epXBOCTuK c590bdf91a fix: update button component styles 2026-09-02 17:22:47 +05:00
52 changed files with 1184 additions and 765 deletions
+1
View File
@@ -9,3 +9,4 @@ refs/public/placeholder-logo.svg
refs/public/placeholder-logo.png refs/public/placeholder-logo.png
refs/public/placeholder.svg refs/public/placeholder.svg
web/audit/cdp-audit*.txt web/audit/cdp-audit*.txt
css-lint.txt
+391
View File
@@ -0,0 +1,391 @@
# План: составные типы параметров и полная типизация pipeline
> Статус: **в реализации.** Фаза 0 (фундамент) — сделана. Следующее: Фаза 1 —
> один полноценный рабочий инструмент в preview, затем масштабирование.
>
> Файлы: `web/src/lib/registry.ts` (3918 строк), `web/src/lib/preview/tool-views.ts` (193 строки),
> `web/src/lib/registry-schema.ts`.
## Ключевая стратегия: параллельная сборка, старый UI не трогаем
Старый UI (группа `(old)/`, `ParamForm.svelte` + старый pipeline) **продолжает
работать как сейчас, без рефакторинга**. Новый `ToolSchema<P>` строится
рядом и служит источником для **нового UI**. Инструменты переписываются под
новый registry по мере нужды — для нового UI.
Это та же логика, что в `plan-redesign.md` применена к дизайну: параллельная
сборка, изоляция от старого, затем новый становится основным и старый
удаляется вместе со старым дизайном.
**Разделение схем: две независимые схемы.**
| Схема | Источник | Использование |
| --------------------- | ------------------------------------------------- | ---------------------------------------------------------------- |
| Старый `ParamDef[]` | В `ToolEntry.params`**остаётся без изменений** | Старый UI, старый pipeline (`ParamForm.svelte`, executor, chain) |
| Новый `ToolSchema<P>` | Новое поле `ToolEntry.schema` — добавляется рядом | Новый UI (kit/`ParamControl`, новый pipeline) |
- Старая и новая схемы **не выводятся друг из друга** — это два независимых
описания инструмента.
- Пока новый UI не готов — `schema` просто отсутствует у большинства
инструментов, старый UI ничего не замечает.
- Удаление старого UI (после перехода) попутно удаляет и старый `ParamDef[]`
## Проблема (текущая)
- `ParamDef` — плоский union из 6 примитивов (`number | slider | select |
checkbox | color | text`). Параметры инструмента — отдельные записи в плоском
массиве.
- `run()`/`generate()` получают `Record<string, unknown>`; значения достаются
через `num(p, "fromColor")` — runtime string lookup **без type safety**,
легко напутать id.
- Поля инструмента **слабо связаны** (например «ширина» и «цвет» рамки — два
независимых поля без общего контракта) → общий генерализованный рендер, нет
возможности сделать идеальный пер-инструмент UI.
- Preview-система (`tool-views.ts`) **дублирует** registry (FieldDef vs
ParamDef) — одни и те же инструменты описаны дважды.
## Решения (принятые в обсуждении)
1. **Полная типизация.** `interface Params` (runtime-тип) объявляется явно в
каждом инструменте. `run`/`generate` типизируются прямо на него, а не на
`Record<string, unknown>`.
2. **Отдельная типизированная схема для UI/дефолтов/валидации.** Два понятия
на инструмент: runtime-тип + схема. Схема проверяется компилятором на
соответствие `Params` (чтобы не рассинхронизировались).
3. **Общий рендер,** но с пер-инструмент layout: общие компоненты рисуют поля
по схеме; схема инструмента задаёт layout и группировку. Без отдельного
Svelte-компонента на каждый инструмент.
4. **Составные типы** (`gradient`, `color-pair`, `dimension`, `offset`,
`position9`, `font-style`, `plate`) остаются в плане — это способ выразить
в схеме связанную группу полей и переиспользовать её на нескольких
инструментах и в UI.
5. Рано или поздно `tool-views.ts` поглощается registry (preview/lede/layout
переезжают в meta инструмента) — но это отдельный шаг, см. ниже.
6. **Старый UI не рефакторим.** Две независимые схемы: старый `ParamDef[]`
остаётся источником для старого UI без изменений; новый `ToolSchema<P>`
строится рядом для нового UI (см. «Ключевая стратегия» выше).
## Целевая архитектура (на примере «добавить рамку»)
Цель: «добавить рамку: ширина и цвет» — **один интерфейс**, а не два
слабо связанных поля.
```ts
// 1. Runtime-тип — что получает run/generate
interface AddBorderParams {
thickness: number; // рамка: ширина
color: string; // рамка: цвет
}
// 2. Типизированная UI-схема, проверяемая компилятором против Params
// Ключи схемы обязаны совпадать с полями Params; default совместим с типом.
const addBorderSchema = toolSchema<AddBorderParams>({
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
layout: { group: "border", cols: 2 }, // пер-инструмент раскладка
});
// 3. Инструмент в registry
const addBorder: ToolEntry<AddBorderParams> = {
id: "add-border-png",
title: "Add border to PNG",
category: "geometry",
schema: addBorderSchema,
run: (img, p) => expandCanvas(img, 4x..., p.color), // p.thickness — type safe
};
```
### Что даёт
- `p.thickness`, `p.color` — IDE autocomplete + compile-time ошибка при ошибке.
- `run()` нельзя передать лишний/чужой ключ.
- Схема — единый источник для дефолтов, валидации и рендера.
- Схема одного инструмента может задавать layout, которого нет в общем рендере.
## Типовая система (набросок)
```ts
// ─── Поле схемы ───
interface NumberField { kind: "number"; default: number; min?: number; max?: number; step?: number }
interface SliderField { kind: "slider"; default: number; min: number; max: number; step?: number }
interface ColorField { kind: "color"; default: string }
interface SelectField { kind: "select"; default: string; options: { value: string; label: string }[] }
interface TextField { kind: "text"; default: string; placeholder?: string }
interface CheckboxField { kind: "checkbox"; default: boolean }
type FieldDef =
| NumberField | SliderField | ColorField | SelectField | TextField | CheckboxField;
// ─── «поле» описывает тип значения + схему вместе, чтобы не рассинхронизировать ───
interface Field<T> {
schema: FieldDef;
// T — ожидаемый runtime-тип этого поля (number|string|boolean)
}
// ─── Builder: field.slider({...}) возвращает Field<number> с типизированной схемой ───
const field = {
slider: (s: SliderField): Field<number> => ({ schema: s }),
number: (s: NumberField): Field<number> => ({ schema: s }),
color: (s: ColorField): Field<string> => ({ schema: s }),
select: <V extends string>(s: SelectField & { options: {value: V}[] }): Field<V> => ({ schema: s }),
text: (s: TextField): Field<string> => ({ schema: s }),
checkbox: (s: CheckboxField): Field<boolean> => ({ schema: s }),
};
// ─── toolSchema<P>: проверяет, что ключи схемы === полям P и типы сходятся ───
type FieldsOf<T> = { [K in keyof T]: Field<T[K]> } & {
layout?: { group?: string; cols?: number };
};
function toolSchema<P>(fields: FieldsOf<P>): ToolSchema<P> { ... }
// TS выдаст ошибку, если schema содержит ключ, отсутствующий в Params, или
// если type поля не совпадает с типом Params[K].
```
Ключевая идея связки: **builder `field.x<T>()` связывает runtime-тип поля с его
схемой на этапе компиляции**, а `toolSchema<P>` заставляет ключи схемы
совпадать с ключами `P`. Это и есть защита от рассинхрона.
## Составные типы (для переиспользования и группировки)
| Тип | Под-поля | Инструментов |
| ------------ | -------------------------- | ------------ |
| `dimension` | width + height | 12 |
| `color-pair` | from + to | 4 |
| `offset` | x + y | 4 |
| `gradient` | from + to + dir (+ type?) | 1 |
| `position9` | position (3×3 grid) | 3 |
| `font-style` | font + size + bold + color | 3-4 |
| `plate` | enabled + color + opacity | 2-3 |
Составной тип = группы полей, которые в интерфейсе `Params` могут разворачиваться
во вложенный объект или плоские ключи — решается на этапе реализации.
Пример для `gradient`:
```ts
interface GradientParams {
from: string;
to: string;
dir: string;
}
// схема: field.color(from), field.color(to), field.select(dir)
// плюс annotation в схеме: { group: "gradient" } → UI рисует как цветовой переход
```
## Инвентарь: какие инструменты мигрировать
### dimension (12)
resize-png*, crop-png*, fit-on-background-png, create-empty-png,
single-color-png, random-noise-png, linear-gradient-png, color-spectrum-png,
random-colors-png, draw-grid-png, placeholder-png, change-canvas-size-png
(\* у resize/crop — не просто dimension, есть специфика: keepAspect, x/y и т.д.)
### color-pair (4)
blend-two-png, step-colors-png, linear-gradient-png, two-colors-png
### offset (4)
circle-mask-png, square-mask-png, star-mask-png, wavy-mask-png
### position9 (3)
add-text-png, date-stamp-png, watermark-image-png
### font-style (3-4)
text-to-png, add-text-png, date-stamp-png
### plate (2-3)
add-text-png, date-stamp-png
## Пошаговый план перевода инструментов
> Каждый шаг — **атомарное, маленькое изменение** (в идеале 1 инструмент =
> 1 diff, укладывается в правило «коммиты < ~500 строк»). После каждого шага —
> ревью; параллельно можно делать UI нового инструмента.
>
> **Принцип малых шагов:** перевод инструмента не меняет поведение старой
> схемы (`params` остаётся), не ломает старый UI. Инструмент получает ДОПОЛНИТЕЛЬНО
> новое поле `schema` + `interface Params`, старое `run`/`params` не трогаем.
> Это гарантирует, что старый UI продолжает работать без регресса.
>
> **Новый подход (решение пользователя):** сначала делаем **один полноценный
> рабочий инструмент в preview** — не эталон «на бумаге», а реально юзабельный,
> чтобы руками оценить, насколько это рабочее решение и какие возникнут сложности.
> Оцениваем, при необходимости правим инфраструктуру, и только потом переводим
> остальные инструменты тем же проверенным паттерном.
### Фаза 0 — фундамент ✔ (сделано)
Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
в `web/src/lib/registry-schema.ts`. `ToolEntry<P>` generic + опциональное
поле `schema`. Новые `defaultSchemaParams`/`sanitizeSchemaParams`.
Старый `ParamDef[]`/pipeline не тронуты. Проверено: compile-time-защита
(лишний/неверный/отсутствующий ключ ловит TS), тесты, svelte-check без новых
ошибок.
### Фаза 1 — ОДИН полноценный рабочий инструмент в preview (срез)
5. **Инструмент «добавить рамку» (`add-border-png`)** — сделать полностью
рабочим в preview:
- `interface AddBorderParams { thickness: number; color: string }`
- `addBorderSchema = toolSchema<AddBorderParams>(...)` + `schema: ...` в entry
(старый `params`/`run` остаются — старый UI не регрессит).
- **Новый рендер в preview** (`kit/ToolView` → `ParamControl` или новый
компонент схемы): читает `ToolSchema<P>`, рисует поля рамки, применение
реально работает (end-to-end до картинки).
- Дефолты/валидация/значения — из схемы (`defaultSchemaParams`,
`sanitizeSchemaParams`).
- **Ревью-оценка:** удобно ли это, что сломалось/усложнилось, что поправить
в инфраструктуре до масштабирования. Принимается макет/решение.
Результат: один реально рабочий инструмент на новой системе = доказываем паттерн,
прежде чем переводить остальных.
### Фаза 2 — простые инструменты без составных типов (1 инструмент = 1 шаг)
Одиночные/небольшие инструменты, где `interface Params` + `schema` не требуют
составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым
проверенным в Фазе 1 паттерном:
6. add-stroke-png (color + slider)
7. find-contour-png (color + slider)
8. convert-png-to-jpg (color + slider)
9. convert-png-to-webp (slider)
10. remove-color-from-png (color + slider)
11. extract-color-from-png (color + slider)
12. add-noise-png (slider + select + number)
13. randomize-pixels-png (slider + number)
14. pixelate-png (slider)
15. vignette-png (slider)
16. gamma-png (slider)
17. temperature-png (slider)
18. tint-png (color + slider)
19. quantize-png (slider)
20. custom-palette-png (text)
21. dithering-png (slider + select)
22. jpeg-artifacts-png (slider)
(и т.п. — все одиночные инструменты в этом же ключе)
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
24. `dimension` — ввести составной тип + перевести **один** пилот
(create-empty-png), ревью, затем остальные 11 по 1:
create-empty → single-color → random-noise → fit-on-background →
change-canvas-size → placeholder → draw-grid → random-colors →
color-spectrum → linear-gradient → resize → crop
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors →
linear-gradient (уже переведён в dimension, здесь добавляется к нему)
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
wavy-mask
27. `position9` — перевести add-text → date-stamp → watermark-image
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
date-stamp
29. `plate` — перевести add-text → date-stamp
30. `gradient` — собрать из dimension + color-pair + direction на
linear-gradient (зависит от решения по gradient, см. план)
### Фаза 4 — масштаб UI на остальные инструменты
31. По мере перевода инструментов в Фазе 2-3 — рендер схемы (из Фазы 1)
покрывает их автоматически; составные виджеты (dimension, color-pair,
offset, position9, font-style, plate, gradient) — по одному, каждый с ревью.
32. Пер-инструмент layout (`schema.layout`): группировка полей рамки и т.п.
33. Для каждого переведённого инструмента — UI-макет, принимается отдельно.
### Фаза 5 — зачистка
34. Поглощение `tool-views.ts` (preview/lede/layout → meta инструмента).
35. После перехода на новый дизайн — удаление старого UI и старого `ParamDef[]`.
### Как ревьюить каждый шаг
- Diff **только один инструмент** (или фундамент без инструментов).
- Старый `params`/`run`/`defaultParams`/`sanitizeParams` — **не изменены** (кроме
добаление `schema`, если это пилот).
- Старый UI и старые тесты продолжают работать — запустить
`pnpm --dir web test` и `pnpm --dir web exec svelte-check`.
- Порядок полей в `interface Params` и в `schema` совпадает; дефолты в сeme
равны старым `default` из `params`.
- Пилот из Фазы 1 проверяется **руками в preview**: применяется рамка,
дефолты/валидация/смена значений работают end-to-end.
## Поток данных (runtime)
### Старый pipeline (не трогаем)
Всё как сейчас: старое `defaultParams`/`sanitizeParams`/executor/chain работают
на `ParamDef[]` и `Record<string, unknown>`. Никаких изменений.
### Новый pipeline (строим рядом)
- **Хранилище значений** — типизировано на схему; у нового UI значения полей
собираются в `Params` по `ToolSchema<P>`.
- **defaultParams / sanitizeParams (новые)** — выводятся из `ToolSchema<P>`,
дефолты лежат в схеме. Старые функции не трогаем, добавляем новые рядом.
- **Executor/worker (новые)** — получает `Record<string, unknown>`, собирает
`Params` по схеме, вызывает `run`. Остаётся serializable.
- **Pipeline/chain (новый)** — шаги хранят значения, типизируются по схеме.
## Изменения в UI-компонентах
- **Старый UI** (`ParamForm.svelte`, группа `(old)/`) — **не трогаем.** Работает
на `ParamDef[]` как раньше.
- **Новый UI** (kit/`ParamControl.svelte`, новый pipeline) — читает
`ToolSchema<P>`; поддержка составных типов и пер-инструмент layout
(`schema.layout`).
- `tool-views.ts` — со временем поглощается registry (preview/lede/layout →
meta инструмента). Отдельный шаг, НЕ блокирует типизацию params.
## Оценка трудозатрат
| Часть | Сложность | Время |
| ----------------------------------------------------- | ------------------------ | ---------------------- |
| Typed field builders + toolSchema\<P\> | Средняя | ~2ч |
| Новые defaultParams/sanitizeParams под схему | Средняя | ~2-3ч |
| ToolEntry\<P\> generic + поле schema (рядом с params) | Низкая-Средняя | ~1-2ч |
| Новый executor/worker/pipeline под схему | Средняя | ~2-3ч |
| Миграция инструментов на interface Params + схемы | Механическая, но крупная | ~8-12ч |
| Новый рендер (kit/ParamControl) + составные виджеты | Средняя | ~4-5ч |
| Поглощение tool-views.ts | Средняя | ~1-2ч |
| **Итого** | | **~20-29ч** (поэтапно) |
> Оценка выросла по сравнению с ранней версией плана, потому что принят путь
> «явный interface Params + схема + общий рендер с пер-инструмент layout» —
> это полный рефакторинг pipeline, а не только добавление составных типов.
>
> Старый UI/`ParamDef[]`/старый pipeline в смету **не входят** — они не
> рефакторятся, а продолжают работать до перехода (затем удаляются вместе со
> старым дизайном).
## Порядок реализации (кратко)
Детальный пошаговый план с атомарными изменениями и порядком ревью — в разделе
«Пошаговый план перевода инструментов» выше. Здесь краткая сводка фаз:
1. **Фаза 0** — фундамент: builders + `toolSchema<P>` + `ToolEntry<P>.schema` +
новые default/sanitize. Ни одного инструмента не изменено. ✔
2. **Фаза 1** — **один полноценный рабочий инструмент в preview**
(«добавить рамку»): schema + рендер + end-to-end применение. Ручная оценка
работоспособности решения/сложностей.
3. **Фаза 2** — простые/одиночные инструменты (проверенный паттерн Фазы 1),
1 инструмент = 1 диф.
4. **Фаза 3** — инструменты с составными типами (dimension → color-pair →
offset → position9 → font-style → plate → gradient), пилот каждого типа
отдельно.
5. **Фаза 4** — масштаб UI на остальные: составные виджеты, пер-инструмент
layout (для каждого инструмента — UI-макет).
6. **Фаза 5** — поглощение `tool-views.ts`, затем удаление старого UI/`ParamDef[]`
## Зависимости
- **UI сперва доделать** — текущая активная работа; типизация params ожидает
готовности нового дизайна.
- Ветка redesign (`plan-redesign.md`) — параллельная, учесть при миграции
kit/`ParamControl.svelte` и `overlay-store`.
- Старый pipeline остаётся рабочим и шippable на всём протяжении перехода
(аналогично стратегии `plan-redesign.md`).
+14
View File
@@ -15,6 +15,20 @@ export const COLOR_PROPS =
export const SIZE_PROPS = export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|border-spacing)$/; /^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|border-spacing)$/;
// =====================================================================
// SHORTHAND properties that accept BOTH a color and a size. The browser
// assigns their sub-properties by value type at runtime (length -> width,
// color -> ...-color), so they cannot be category-checked positionally.
// border -> border-width + border-color
// outline -> outline-width + outline-color
// text-decoration -> text-decoration-line/-color/...
// column-rule -> column-rule-width + column-rule-color
// The longhands they expand to (border-width, border-color, ...) are already
// covered individually by SIZE_PROPS / COLOR_PROPS.
// =====================================================================
export const MIXED_PROPS =
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
// ===================================================================== // =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations. // Properties that carry a DURATION (ms/s) — transitions/animations.
// ===================================================================== // =====================================================================
@@ -5,7 +5,13 @@
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x)) // (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused. // is a sign the wrong token is being reused.
import { COLOR_PROPS, COLOR_TOKEN, SIZE_PROPS, SIZE_TOKEN } from "./lists.js"; import {
COLOR_PROPS,
COLOR_TOKEN,
MIXED_PROPS,
SIZE_PROPS,
SIZE_TOKEN,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js"; import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Collect the token names referenced by var() in a value. // Collect the token names referenced by var() in a value.
@@ -50,6 +56,14 @@ export default {
const prop = decl.prop ?? ""; const prop = decl.prop ?? "";
const value = decl.value ?? ""; const value = decl.value ?? "";
// Mixed shorthands (border, outline, text-decoration,
// column-rule) legitimately take BOTH a size and a color.
// The browser assigns sub-properties by value type at runtime,
// not by position, so skip category checks for them.
// Their longhands (border-width, border-color, ...) are still
// covered individually.
if (MIXED_PROPS.test(prop)) return;
const isSizeProp = SIZE_PROPS.test(prop); const isSizeProp = SIZE_PROPS.test(prop);
const isColorProp = COLOR_PROPS.test(prop); const isColorProp = COLOR_PROPS.test(prop);
if (!isSizeProp && !isColorProp) return; if (!isSizeProp && !isColorProp) return;
-69
View File
@@ -1,69 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
children: Snippet;
onclick?: () => void;
type?: "button" | "submit";
variant?: "primary" | "ghost" | "accent" | "danger";
icon?: Component<{ size?: number; class?: string }>;
disabled?: boolean;
}
let {
children,
onclick,
type = "button",
variant = "primary",
icon,
disabled = false,
}: Props = $props();
</script>
<button
{type}
class="btn btn--{variant}"
{disabled}
onclick={() => onclick?.()}
>
{#if icon}<Icon {icon} size={14} />{/if}
<span class="btn-label">{@render children()}</span>
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--foreground);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.btn--primary {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.btn--ghost:hover:not(:disabled) {
border-color: var(--blue);
}
.btn--accent {
background: var(--cyan);
color: #042;
border-color: var(--cyan);
}
.btn--danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -14,7 +14,9 @@
{ value: "color", label: "COLOR" }, { value: "color", label: "COLOR" },
{ value: "geometry", label: "GEOMETRY" }, { value: "geometry", label: "GEOMETRY" },
{ value: "filters", label: "FILTERS" }, { value: "filters", label: "FILTERS" },
{ value: "text", label: "TEXT" },
{ value: "analyze", label: "ANALYZE" }, { value: "analyze", label: "ANALYZE" },
{ value: "generate", label: "GENERATE" },
]; ];
</script> </script>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Copy } from "@lucide/svelte"; import { Copy } from "@lucide/svelte";
import IconButton from "./IconButton.svelte"; import IconButton from "./ui/IconButton.svelte";
interface Props { interface Props {
code: string; code: string;
@@ -1,35 +0,0 @@
<script lang="ts">
import { ArrowDownToLine, Download } from "@lucide/svelte";
interface Props {
label: string;
onclick?: () => void;
}
let { label, onclick }: Props = $props();
</script>
<button class="download-btn" type="button" onclick={() => onclick?.()}>
<Download size={16} />
{label}
<ArrowDownToLine size={14} />
</button>
<style>
.download-btn {
background: var(--blue);
color: #fff;
width: 100%;
height: 42px;
font: 11px var(--font-mono);
border: 0;
justify-content: center;
align-items: center;
gap: 9px;
margin-top: 18px;
display: flex;
white-space: nowrap;
}
.download-btn:hover {
background: color-mix(in srgb, var(--blue) 88%, #000);
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Component, Snippet } from "svelte"; import type { Component, Snippet } from "svelte";
import Icon from "./Icon.svelte"; import Icon from "./ui/Icon.svelte";
interface Props { interface Props {
title: string; title: string;
-25
View File
@@ -1,25 +0,0 @@
<footer class="preview-footer">
<span>easy-png-tools <b class="version">v2.4.0</b></span>
<span>© 2024</span>
</footer>
<style>
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px clamp(20px, 4vw, 72px);
border-top: 1px solid var(--line);
font: 10px var(--font-mono);
color: var(--muted);
& span {
display: flex;
align-items: center;
gap: 7px;
}
}
.preview-footer .version {
font-weight: 400;
color: var(--blue);
}
</style>
@@ -1,87 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
icon: Component<{ size?: number; class?: string }>;
label: string;
onclick?: () => void;
variant?: "ghost" | "solid" | "accent" | "bare";
size?: number;
disabled?: boolean;
}
let {
icon,
label,
onclick,
variant = "ghost",
size = 16,
disabled = false,
}: Props = $props();
</script>
<button
type="button"
class="icon-btn icon-btn--{variant}"
aria-label={label}
{disabled}
onclick={() => onclick?.()}
>
<Icon {icon} {size} />
</button>
<style>
.icon-btn {
display: grid;
align-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: inherit;
line-height: inherit;
border: 0;
background: 0 0;
border: 0;
place-items: center;
padding: 6px;
display: grid;
}
.icon-btn:hover:not(:disabled) {
color: var(--foreground);
border-color: var(--blue);
}
.icon-btn--solid {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.icon-btn--accent {
background: var(--cyan);
color: #042;
border-color: var(--cyan);
}
.icon-btn--bare {
padding: 6px;
border: 0;
border-radius: 0;
color: var(--muted);
}
.icon-btn--bare:hover:not(:disabled) {
color: var(--foreground);
border-color: transparent;
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn--ghost {
padding: 6px;
border: 0;
border-radius: 0;
color: var(--muted);
}
</style>
+4 -6
View File
@@ -17,15 +17,13 @@
<style> <style>
.mono-label { .mono-label {
font-family: var(--font-mono); font: normal var(--font-size-s) var(--font-mono);
font-size: 10px; letter-spacing: var(--space-text-l);
font-weight: 400; color: var(--color-text-muted);
letter-spacing: 0.12em;
color: var(--muted);
line-height: normal; line-height: normal;
} }
.accent { .accent {
color: var(--blue); color: var(--color-accent);
} }
</style> </style>
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<section class="panel">
{@render children()}
</section>
<style>
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
</style>
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import ColorField from "$lib/components/kit/fields/ColorField.svelte";
import NumberField from "$lib/components/kit/fields/NumberField.svelte";
import SelectField from "$lib/components/kit/fields/SelectField.svelte";
import SliderField from "$lib/components/kit/fields/SliderField.svelte";
import TextField from "$lib/components/kit/fields/TextField.svelte";
import ToggleRow from "$lib/components/kit/fields/ToggleRow.svelte";
import type { ParamDef } from "$lib/registry"; import type { ParamDef } from "$lib/registry";
import ColorField from "$lib/components/kit/ColorField.svelte";
import SliderField from "$lib/components/kit/SliderField.svelte";
import SelectField from "$lib/components/kit/SelectField.svelte";
import ToggleRow from "$lib/components/kit/ToggleRow.svelte";
import TextField from "$lib/components/kit/TextField.svelte";
import NumberField from "$lib/components/kit/NumberField.svelte";
type ParamValue = string | number | boolean; type ParamValue = string | number | boolean;
@@ -16,6 +16,8 @@
} }
let { param, value, onInput }: Props = $props(); let { param, value, onInput }: Props = $props();
// TODO: investigate - may be better move param fields switch to field component?
</script> </script>
{#if param.type === "color"} {#if param.type === "color"}
@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/kit/Button.svelte";
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte"; import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
import Dropzone from "$lib/components/kit/Dropzone.svelte"; import Dropzone from "$lib/components/kit/Dropzone.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte"; import EmptyState from "$lib/components/kit/EmptyState.svelte";
import Panel from "$lib/components/kit/layout/Panel.svelte";
import MetaList from "$lib/components/kit/MetaList.svelte"; import MetaList from "$lib/components/kit/MetaList.svelte";
import Panel from "$lib/components/kit/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import PreviewStack from "$lib/components/kit/PreviewStack.svelte"; import PreviewStack from "$lib/components/kit/PreviewStack.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte"; import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import StatusLine from "$lib/components/kit/StatusLine.svelte"; import StatusLine from "$lib/components/kit/StatusLine.svelte";
import Button from "$lib/components/kit/ui/Button.svelte";
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
interface PreviewItem { interface PreviewItem {
@@ -52,14 +51,11 @@
}: Props = $props(); }: Props = $props();
</script> </script>
<Panel> <Panel title="Pipeline output" eyebrow="RESULT">
<PanelHeading title="Pipeline output" eyebrow="RESULT"> {#if resultUrl}
{#snippet actions()} <DownloadButton label="Download result" onclick={onDownload} />
{#if resultUrl} {/if}
<DownloadButton label="Download result" onclick={onDownload} />
{/if}
{/snippet}
</PanelHeading>
<div class="preview-body"> <div class="preview-body">
{#if chainError} {#if chainError}
<EmptyState title="Pipeline failed" description={chainError} /> <EmptyState title="Pipeline failed" description={chainError} />
@@ -67,18 +63,21 @@
{#if needsSource && !sourceImg} {#if needsSource && !sourceImg}
<Dropzone onfile={onFile} /> <Dropzone onfile={onFile} />
{:else if needsSource && sourceImg} {:else if needsSource && sourceImg}
<Button variant="ghost" onclick={onClearSource}>Change image</Button> <Button variant="outline" onclick={onClearSource} label="Change image" />
{/if} {/if}
<PreviewStack> <PreviewStack>
{#if needsSource && sourceImg} {#if needsSource && sourceImg}
<PreviewTile label="SOURCE"> <PreviewTile label="SOURCE" caption="">
<CheckerCanvas size="sm"> <CheckerCanvas size="sm">
<img class="tile-img" src={sourceUrl} alt="source" /> <img class="tile-img" src={sourceUrl} alt="source" />
</CheckerCanvas> </CheckerCanvas>
</PreviewTile> </PreviewTile>
{/if} {/if}
{#each stepOutputs as out, i (out.toolId + i)} {#each stepOutputs as out, i (out.toolId + i)}
<PreviewTile label={`STEP ${String(i + 1).padStart(2, "0")}`}> <PreviewTile
label={`STEP ${String(i + 1).padStart(2, "0")}`}
caption=""
>
<CheckerCanvas size="sm"> <CheckerCanvas size="sm">
<img class="tile-img" src={out.url} alt={out.title} /> <img class="tile-img" src={out.url} alt={out.title} />
</CheckerCanvas> </CheckerCanvas>
@@ -4,11 +4,11 @@
interface Props { interface Props {
label: string; label: string;
caption: string; caption: string;
active: boolean; active?: boolean;
children: Snippet; children: Snippet;
} }
let { label, caption, active, children }: Props = $props(); let { label, caption, active = false, children }: Props = $props();
</script> </script>
<div class="preview-tile" class:active> <div class="preview-tile" class:active>
@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/kit/Button.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte"; import EmptyState from "$lib/components/kit/EmptyState.svelte";
import Panel from "$lib/components/kit/Panel.svelte"; import SelectField from "$lib/components/kit/fields/SelectField.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte"; import Panel from "$lib/components/kit/layout/Panel.svelte";
import SelectField from "$lib/components/kit/SelectField.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte"; import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import StepCard from "$lib/components/kit/StepCard.svelte"; import StepCard from "$lib/components/kit/StepCard.svelte";
import Badge from "$lib/components/kit/ui/Badge.svelte"; import Button from "$lib/components/kit/ui/Button.svelte";
import { getTool } from "$lib/registry"; import { getTool } from "$lib/registry";
import ParamControl from "./ParamControl.svelte"; import ParamControl from "./ParamControl.svelte";
@@ -38,12 +36,7 @@
}: Props = $props(); }: Props = $props();
</script> </script>
<Panel> <Panel title="Pipeline" eyebrow="STEPS">
<PanelHeading title="Pipeline" eyebrow="STEPS">
{#snippet actions()}
<Badge tone="accent">AUTO</Badge>
{/snippet}
</PanelHeading>
<div class="pipeline-body"> <div class="pipeline-body">
{#if steps.length === 0} {#if steps.length === 0}
<EmptyState title="Pipeline empty" description="Add a tool step below." /> <EmptyState title="Pipeline empty" description="Add a tool step below." />
@@ -78,7 +71,7 @@
options={chainOptions} options={chainOptions}
onchange={(v) => onSetAddId(v)} onchange={(v) => onSetAddId(v)}
/> />
<Button variant="ghost" onclick={onAddStep}>Add step</Button> <Button variant="outline" onclick={onAddStep} label="Add step" />
</div> </div>
</SettingsFooter> </SettingsFooter>
</Panel> </Panel>
+1 -12
View File
@@ -2,7 +2,7 @@
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { ArrowUpRight } from "@lucide/svelte"; import { ArrowUpRight } from "@lucide/svelte";
import type { Component } from "svelte"; import type { Component } from "svelte";
import Icon from "./Icon.svelte"; import Icon from "./ui/Icon.svelte";
interface Props { interface Props {
title: string; title: string;
@@ -73,15 +73,4 @@
color: var(--muted); color: var(--muted);
align-self: start; align-self: start;
} }
.tool-arrow {
color: var(--blue);
opacity: 0;
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.tool-card:hover .tool-arrow {
opacity: 1;
transform: translate(2px, -2px);
}
</style> </style>
-146
View File
@@ -1,146 +0,0 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { CircleQuestionMark, Moon, Sun } from "@lucide/svelte";
import IconButton from "./IconButton.svelte";
import StatusDot from "./StatusDot.svelte";
interface Props {
theme: "light" | "dark";
crumb?: string;
status?: string;
ontoggle: () => void;
}
let { theme, crumb, status = "AUTO PIPELINE", ontoggle }: Props = $props();
let lang = $state("RU");
</script>
<header class="topbar">
<a class="brand" href={resolve("/")}>
<span class="brand-mark">EP</span>
<span>easy-png-tools</span>
{#if crumb}<span class="version">{crumb}</span>{/if}
</a>
<nav class="nav">
<a href={resolve("/preview/demo")}>Workspace</a>
<a href={resolve("/preview/list-tools")}>Catalog</a>
<a href={resolve("/preview/tools/linear-gradient-png")}>Gradient</a>
<a href={resolve("/preview/tools/remove-background-png")}
>Background remover</a
>
</nav>
<div class="top-actions">
<span class="status"><StatusDot /> {status}</span>
<IconButton
icon={CircleQuestionMark}
label="Help"
variant="bare"
onclick={() => {}}
/>
<IconButton
icon={theme === "light" ? Moon : Sun}
label="Toggle theme"
variant="bare"
onclick={ontoggle}
/>
<div class="lang" role="group" aria-label="Language">
<button
type="button"
class="lang-btn"
class:active={lang === "RU"}
onclick={() => (lang = "RU")}>RU</button
><span class="lang-div">/</span>
<button
type="button"
class="lang-btn"
class:active={lang === "EN"}
onclick={() => (lang = "EN")}>EN</button
>
</div>
</div>
</header>
<style>
.topbar {
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);
color: var(--foreground);
text-decoration: none;
}
.brand-mark {
background: var(--blue);
color: #fff;
width: 30px;
height: 30px;
font-size: 11px;
display: grid;
place-items: center;
}
.version {
font: 10px var(--font-mono);
letter-spacing: 0.12em;
color: var(--blue);
}
.nav {
display: flex;
align-items: center;
gap: 22px;
margin-right: 28px;
margin-left: auto;
}
.nav a {
color: var(--muted);
font: 10px var(--font-mono);
white-space: nowrap;
text-decoration: none;
}
.nav a:hover {
color: var(--foreground);
}
.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;
}
.lang {
display: flex;
align-items: center;
border: 1px solid var(--line);
}
.lang-btn {
border: 0;
background: transparent;
color: var(--muted);
font: 10px var(--font-mono);
padding: 6px 9px;
cursor: pointer;
}
.lang-div {
color: var(--foreground);
font-family: var(--font-sans);
}
.lang-btn.active {
background: var(--foreground);
color: var(--background);
}
</style>
+10
View File
@@ -0,0 +1,10 @@
export const ButtonVariantDefine = {
PRIMARY: "primary",
OUTLINE: "outline",
ACCENT: "accent",
DANGER: "danger",
CLEAR: "clear",
} as const;
export type ButtonVariant =
(typeof ButtonVariantDefine)[keyof typeof ButtonVariantDefine];
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte"; import MonoLabel from "../MonoLabel.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -20,16 +20,16 @@
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: var(--space-s);
} }
.field-control { .field-control {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: var(--space-m);
} }
.field-hint { .field-hint {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: var(--font-size-s);
color: var(--muted); color: var(--color-text-muted);
} }
</style> </style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import Toggle from "./Toggle.svelte"; import Toggle from "../ui/Toggle.svelte";
interface Props { interface Props {
label: string; label: string;
@@ -0,0 +1,29 @@
<script lang="ts">
// TODO: insert version from package.json
</script>
<footer class="preview-footer">
<span>easy-png-tools <b class="version">v0.1.0</b></span>
<span>© 2026</span>
</footer>
<style>
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xl);
border-top: var(--size-border) solid var(--color-border);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
& span {
display: flex;
align-items: center;
gap: var(--space-m);
}
}
.preview-footer .version {
font-weight: normal;
color: var(--color-text-accent);
}
</style>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from "svelte";
import PanelHeading from "../PanelHeading.svelte";
interface Props {
title: string;
eyebrow: string;
children: Snippet;
}
let { title, eyebrow, children }: Props = $props();
</script>
<section class="panel">
<PanelHeading {title} {eyebrow} />
{@render children()}
</section>
<style>
.panel {
background: var(--color-panel);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
padding: var(--space-l);
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import { resolve } from "$app/paths";
import LangToggle from "../ui/LangToggle.svelte";
import ThemeToggle from "../ui/ThemeToggle.svelte";
import TopNav from "../ui/TopNav.svelte";
interface Props {
theme: "light" | "dark";
crumb?: string;
ontoggle: () => void;
}
let { theme, crumb, ontoggle }: Props = $props();
</script>
<header class="topbar">
<a class="brand" href={resolve("/")}>
<span class="brand-mark">EP</span>
<span>easy-png-tools</span>
{#if crumb}
<span class="crumb">{crumb}</span>
{/if}
</a>
<TopNav />
<div class="top-actions">
<ThemeToggle {theme} {ontoggle} />
<LangToggle />
</div>
</header>
<style>
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-xl);
background: var(--color-background);
border-bottom: var(--size-border) solid var(--color-border);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-m);
font: bold var(--font-size-l) var(--font-mono);
color: var(--color-text);
text-decoration: none;
}
.brand-mark {
background: var(--color-main);
color: var(--color-background);
width: var(--space-brand);
height: var(--space-brand);
font-size: var(--font-size-m);
display: grid;
place-items: center;
}
.crumb {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
color: var(--color-main);
}
.top-actions {
display: flex;
align-items: center;
gap: var(--space-xl);
}
</style>
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
// TODO: сделать эти типы общими для кнопок \ бейджей \ етц???
const ToneVariantDefine = { const ToneVariantDefine = {
DEFAULT: "default", DEFAULT: "default",
ACCENT: "accent", ACCENT: "accent",
@@ -46,7 +47,7 @@
gap: var(--space-s); gap: var(--space-s);
line-height: normal; line-height: normal;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--font-text); font-size: var(--font-size-s);
font-weight: bold; font-weight: bold;
border-width: var(--size-border-thick); border-width: var(--size-border-thick);
padding: var(--space-s) var(--space-m); padding: var(--space-s) var(--space-m);
@@ -0,0 +1,76 @@
<script lang="ts">
import type { Component } from "svelte";
import { ButtonVariantDefine, type ButtonVariant } from "../define";
import Icon from "./Icon.svelte";
interface Props {
label: string;
ariaLabel?: string;
onclick?: () => void;
variant?: ButtonVariant;
icon?: Component<{ size?: number; class?: string }>;
disabled?: boolean;
}
let {
label,
ariaLabel = label,
onclick,
variant = ButtonVariantDefine.PRIMARY,
icon,
disabled = false,
}: Props = $props();
</script>
<button
class="btn btn--{variant}"
{disabled}
aria-label={ariaLabel}
onclick={() => onclick?.()}
>
{#if icon}
<Icon {icon} size={14} />
{/if}
{#if label}
<span class="btn-label">{label}</span>
{/if}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-xl);
border-color: var(--color-main);
border-width: var(--size-border-thick);
border-radius: var(--radius-s);
font-size: var(--font-size-l);
font-weight: bold;
cursor: pointer;
background: var(--color-main);
color: var(--color-background);
}
.btn--outline {
background: transparent;
color: var(--color-main-tint);
border-color: var(--color-main-tint);
}
.btn--accent {
background: var(--color-accent);
color: var(--color-background);
border-color: var(--color-accent);
}
.btn--danger {
background: var(--color-danger);
color: var(--color-background);
border-color: var(--color-danger);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,12 @@
<script lang="ts">
import { Download } from "@lucide/svelte";
import Button from "./Button.svelte";
interface Props {
label: string;
onclick?: () => void;
}
let { label, onclick }: Props = $props();
</script>
<Button icon={Download} {label} {onclick} />
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Component } from "svelte";
import { ButtonVariantDefine, type ButtonVariant } from "../define";
import Button from "./Button.svelte";
interface Props {
icon: Component<{ size?: number; class?: string }>;
label: string;
onclick?: () => void;
variant?: ButtonVariant;
disabled?: boolean;
}
let {
icon,
label,
onclick,
variant = ButtonVariantDefine.PRIMARY,
disabled = false,
}: Props = $props();
</script>
<Button label="" {onclick} {variant} {disabled} {icon} ariaLabel={label} />
@@ -0,0 +1,44 @@
<script lang="ts">
let lang = $state("RU");
// TODO: change to sectioned dual buttons
</script>
<div class="lang" role="group" aria-label="Language">
<button
type="button"
class="lang-btn"
class:active={lang === "RU"}
onclick={() => (lang = "RU")}>RU</button
><span class="lang-div">/</span>
<button
type="button"
class="lang-btn"
class:active={lang === "EN"}
onclick={() => (lang = "EN")}>EN</button
>
</div>
<style>
.lang {
display: flex;
align-items: center;
border: 1px solid var(--line);
}
.lang-btn {
border: 0;
background: transparent;
color: var(--muted);
font: 10px var(--font-mono);
padding: 6px 9px;
cursor: pointer;
}
.lang-div {
color: var(--foreground);
font-family: var(--font-sans);
}
.lang-btn.active {
background: var(--foreground);
color: var(--background);
}
</style>
@@ -7,9 +7,15 @@
interface Props { interface Props {
options: Option[]; options: Option[];
value?: string; value?: string;
wide?: boolean;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} }
let { options, value = $bindable(), onchange }: Props = $props(); let {
options,
wide = false,
value = $bindable(),
onchange,
}: Props = $props();
function select(v: string) { function select(v: string) {
value = v; value = v;
@@ -17,7 +23,7 @@
} }
</script> </script>
<div class="segmented" role="group"> <div class="segmented" class:wide role="group">
{#each options as opt (opt.value)} {#each options as opt (opt.value)}
<button <button
type="button" type="button"
@@ -33,23 +39,27 @@
<style> <style>
.segmented { .segmented {
display: flex; display: inline-flex;
height: 32px; border: var(--size-border) solid var(--color-border);
border: 1px solid var(--line); cursor: pointer;
&.wide {
width: 100%;
}
} }
.segment { .segment {
border: 0; border: 0;
border-right: 1px solid var(--line); border-right: var(--size-border) solid var(--color-border);
color: var(--muted); color: var(--color-text-muted);
font: 10px var(--font-mono); font: var(--font-size-s) var(--font-mono);
background: 0 0; background: 0 0;
padding: var(--space-m);
flex: 1; flex: 1;
} }
.segment:last-child { .segment:last-child {
border-right: 0px; border-right: 0px;
} }
.segment.selected { .segment.selected {
background: var(--blue); background: var(--color-main);
color: #fff; color: var(--color-background);
} }
</style> </style>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Moon, Sun } from "@lucide/svelte";
import IconButton from "../ui/IconButton.svelte";
interface Props {
theme: "light" | "dark";
ontoggle: () => void;
}
let { theme, ontoggle }: Props = $props();
// TODO: change icon animations - custom button instead of IconButton
</script>
<IconButton
icon={theme === "light" ? Moon : Sun}
label="Toggle theme"
variant="clear"
onclick={ontoggle}
/>
@@ -0,0 +1,32 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<nav class="nav">
<a href={resolve("/preview/demo")}>Workspace</a>
<a href={resolve("/preview/list-tools")}>Catalog</a>
<a href={resolve("/preview/tools/linear-gradient-png")}>Gradient</a>
<a href={resolve("/preview/tools/remove-background-png")}>
Background remover
</a>
<a href={resolve("/preview/kit")}> UI Kit </a>
</nav>
<style>
.nav {
display: flex;
align-items: center;
gap: var(--space-xxl);
margin-right: var(--space-xxxl);
margin-left: auto;
}
.nav a {
color: var(--color-text);
font: bold var(--font-size-s) var(--font-mono);
white-space: nowrap;
text-decoration: none;
}
.nav a:hover {
color: var(--color-main);
}
</style>
+21 -224
View File
@@ -1,3 +1,4 @@
import { CATEGORIES } from "../categories";
import { TOOLS, type ToolEntry } from "../registry"; import { TOOLS, type ToolEntry } from "../registry";
export type PreviewGroup = { export type PreviewGroup = {
@@ -6,230 +7,26 @@ export type PreviewGroup = {
tools: ToolEntry[]; tools: ToolEntry[];
}; };
/** Uppercase labels для всех категорий каталога (стиль нового дизайна). */
const GROUP_LABELS: Record<string, string> = {
convert: "CONVERT",
alpha: "TRANSPARENCY",
color: "COLOR",
geometry: "GEOMETRY",
filters: "FILTERS",
text: "TEXT",
analyze: "ANALYZE",
generate: "GENERATE",
};
/** /**
* Preview-only catalog: a trimmed view of the registry that mirrors * Preview-каталог: полный реестр инструментов, сгруппированный по категориям.
* refs-html/list-tools.html (28 tools in 6 groups). Used exclusively by * Больше не урезаем до референс-набора показываем все инструменты из TOOLS.
* /preview and /kit so the working site (old) and /ui keep the full registry.
*
* Titles/descriptions are overridden with the ref's exact copy where they
* diverge from the registry. The underlying ToolEntry (params, run, ) is
* reused so navigation and tool pages stay functional.
*/ */
const REF: { export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORIES.map((category) => ({
group: string; id: category,
label: string; label: GROUP_LABELS[category] ?? category.toUpperCase(),
tools: { id: string; title: string; description: string }[]; tools: TOOLS.filter((tool) => tool.category === category),
}[] = [ })).filter((group) => group.tools.length > 0);
{
group: "convert",
label: "CONVERT",
tools: [
{
id: "jpg-to-png",
title: "Convert JPG to PNG",
description:
"Re-encode JPEG files as lossless PNG while preserving transparency.",
},
{
id: "webp-to-png",
title: "Convert WebP to PNG",
description:
"Turn WebP images into a universal PNG format for any workflow.",
},
{
id: "png-to-base64",
title: "PNG to Base64",
description:
"Encode an image as a base64 string for embedding in code or styles.",
},
{
id: "png-to-data-uri",
title: "PNG to Data URI",
description: "Build a complete data URI ready for HTML and CSS.",
},
{
id: "convert-png-to-jpg",
title: "Convert PNG to JPG",
description:
"Composite transparency over a selected backdrop and export JPEG.",
},
],
},
{
group: "alpha",
label: "TRANSPARENCY",
tools: [
{
id: "remove-background-png",
title: "Remove background PNG",
description:
"Remove a solid background by color, tolerance, or edge-connected regions.",
},
{
id: "extract-alpha-mask-png",
title: "Extract alpha mask",
description:
"Turn the alpha channel into a clean black-and-white mask.",
},
{
id: "round-corners-png",
title: "Round corners PNG",
description: "Clip the image corners by a precise radius percentage.",
},
{
id: "add-stroke-png",
title: "Outline PNG",
description:
"Add a colored ring around opaque content with adjustable thickness.",
},
{
id: "change-png-opacity",
title: "Change PNG opacity",
description:
"Multiply the alpha channel while keeping the original colors unchanged.",
},
],
},
{
group: "color",
label: "COLOR",
tools: [
{
id: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generate a smooth transition between two colors with direction controls.",
},
{
id: "grayscale-png",
title: "Grayscale PNG",
description: "Convert the image to luminance-based shades of gray.",
},
{
id: "invert-colors-png",
title: "Invert colors PNG",
description:
"Invert every color channel while leaving alpha untouched.",
},
{
id: "adjust-brightness-contrast-png",
title: "Brightness & contrast",
description:
"Adjust brightness and contrast across a controlled range.",
},
{
id: "temperature-png",
title: "Temperature PNG",
description:
"Make an image warmer or cooler with a single precise control.",
},
],
},
{
group: "geometry",
label: "GEOMETRY",
tools: [
{
id: "resize-png",
title: "Resize PNG",
description:
"Scale an image with bilinear interpolation and optional aspect lock.",
},
{
id: "crop-png",
title: "Crop PNG",
description:
"Cut a rectangular area with exact coordinates and dimensions.",
},
{
id: "rotate-png",
title: "Rotate PNG",
description: "Rotate by 90, 180, or 270 degrees without quality loss.",
},
{
id: "flip-png",
title: "Flip PNG",
description: "Mirror the image horizontally or vertically.",
},
{
id: "add-padding-png",
title: "Add padding to PNG",
description:
"Expand the canvas on all sides by a chosen number of pixels.",
},
],
},
{
group: "filters",
label: "FILTERS",
tools: [
{
id: "blur-png",
title: "Blur PNG",
description:
"Apply a fast Gaussian-style blur with transparent edge handling.",
},
{
id: "sharpen-png",
title: "Sharpen PNG",
description: "Emphasize edges with an adjustable sharpening kernel.",
},
{
id: "vignette-png",
title: "Vignette PNG",
description:
"Smoothly darken the image edges while preserving the center.",
},
{
id: "jpeg-artifacts-png",
title: "JPEG artifacts",
description: "Simulate low-quality JPEG recompression for testing.",
},
],
},
{
group: "analyze",
label: "ANALYZE",
tools: [
{
id: "png-info",
title: "PNG info",
description:
"Inspect dimensions, alpha presence, and unique color count.",
},
{
id: "png-is-grayscale",
title: "Check grayscale",
description: "Report whether the image contains only shades of gray.",
},
{
id: "png-is-transparent",
title: "Check transparency",
description: "Detect transparent and semi-transparent pixels.",
},
{
id: "png-orientation",
title: "PNG orientation",
description: "Classify the image as portrait, landscape, or square.",
},
],
},
];
const TOOL_BY_ID = new Map(TOOLS.map((t) => [t.id, t])); export const PREVIEW_TOTAL = TOOLS.length;
export const PREVIEW_GROUPS: PreviewGroup[] = REF.map((g) => ({
id: g.group,
label: g.label,
tools: g.tools.map((spec) => {
const base = TOOL_BY_ID.get(spec.id);
if (!base) {
throw new Error(`preview catalog: missing tool ${spec.id}`);
}
return { ...base, title: spec.title, description: spec.description };
}),
}));
// The ref header statically reads 32 (its own count); the body lists 28 cards.
// Mirroring the ref exactly keeps the audit at 0/0.
export const PREVIEW_TOTAL = 32;
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
defaultSchemaParams,
field,
sanitizeSchemaParams,
toolSchema,
} from "./registry-schema";
interface FrameParams {
thickness: number;
color: string;
enabled: boolean;
count: string;
}
const frameSchema = toolSchema<FrameParams>(
{
thickness: field.slider({ min: 1, max: 500, default: 5 }),
color: field.color({ default: "#000000" }),
enabled: field.checkbox({ default: true }),
count: field.select({
default: "two",
options: [
{ value: "one", label: "One" },
{ value: "two", label: "Two" },
],
}),
},
{ layout: { group: "frame", cols: 2 }, label: "Frame" },
);
describe("registry-schema: дефолты из схемы", () => {
it("собирает дефолты всех полей", () => {
expect(defaultSchemaParams(frameSchema)).toEqual({
thickness: 5,
color: "#000000",
enabled: true,
count: "two",
});
});
});
describe("registry-schema: санитайз", () => {
it("пропускает валидные значения", () => {
const out = sanitizeSchemaParams(frameSchema, {
thickness: 40,
color: "#ff0000",
enabled: false,
count: "one",
});
expect(out).toEqual({
thickness: 40,
color: "#ff0000",
enabled: false,
count: "one",
});
});
it("заменяет мусор дефолтами", () => {
const out = sanitizeSchemaParams(frameSchema, {
thickness: "abc",
color: "not-a-color",
enabled: "yes",
count: "wat",
extra: 123,
});
expect(out).toEqual({
thickness: 5,
color: "#000000",
enabled: true,
count: "two",
});
});
it("clamp-ит числовые поля к min/max", () => {
const out = sanitizeSchemaParams(frameSchema, {
thickness: 9999,
color: "#000000",
enabled: true,
count: "two",
});
expect(out.thickness).toBe(500);
});
});
+162
View File
@@ -0,0 +1,162 @@
// Новая типизированная схема параметров (для нового UI, старый UI не трогаем).
//
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
// валидацию поверх `ToolSchema<P>`.
//
// Связка типов на этапе компиляции (защита от рассинхрона):
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
// runtime-спецификацию поля с ожидаемым типом значения (`number`|`string`|`boolean`).
// - `toolSchema<P>(fields)` требует, чтобы ключи `fields` в точности совпадали
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
export interface NumberSpec {
kind: "number";
default: number;
min?: number;
max?: number;
step?: number;
}
export interface SliderSpec {
kind: "slider";
default: number;
min: number;
max: number;
step?: number;
}
export interface ColorSpec {
kind: "color";
default: string;
}
export interface SelectSpec<V extends string = string> {
kind: "select";
default: V;
options: { value: V; label: string }[];
}
export interface TextSpec {
kind: "text";
default: string;
placeholder?: string;
}
export interface CheckboxSpec {
kind: "checkbox";
default: boolean;
}
export type FieldSpec =
NumberSpec | SliderSpec | ColorSpec | SelectSpec | TextSpec | CheckboxSpec;
/** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */
export interface Field<T> {
spec: FieldSpec;
/** Phantom, только для типобезопасности — не задаётся и не читается в рантайме. */
__fieldT?: T;
}
export interface ToolSchemaMeta {
/** Пер-инструмент раскладка полей (группировка/колонки). */
layout?: { group?: string; cols?: number };
/** Короткая подпись инструмента для нового UI (необязательно). */
label?: string;
}
export interface ToolSchema<P> {
fields: { [K in keyof P]: Field<P[K]> };
layout?: { group?: string; cols?: number };
label?: string;
}
export const field = {
number: (s: Omit<NumberSpec, "kind">): Field<number> => ({
spec: { kind: "number", ...s },
}),
slider: (s: Omit<SliderSpec, "kind">): Field<number> => ({
spec: { kind: "slider", ...s },
}),
color: (s: Omit<ColorSpec, "kind">): Field<string> => ({
spec: { kind: "color", ...s },
}),
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
spec: { kind: "select", ...s },
}),
text: (s: Omit<TextSpec, "kind">): Field<string> => ({
spec: { kind: "text", ...s },
}),
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
spec: { kind: "checkbox", ...s },
}),
};
/**
* Собирает `ToolSchema<P>` из объявленных полей.
* Компилятор проверяет: ключи `fields` === полям `P`, типы полей совместимы с `P[K]`.
*/
export function toolSchema<P>(
fields: { [K in keyof P]: Field<P[K]> },
meta?: ToolSchemaMeta,
): ToolSchema<P> {
return { fields, ...(meta ?? {}) };
}
function clamp(value: number, min?: number, max?: number): number {
if (min !== undefined && value < min) return min;
if (max !== undefined && value > max) return max;
return value;
}
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
export function defaultSchemaParams<P>(
schema: ToolSchema<P>,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
out[key] = schema.fields[key].spec.default;
}
return out;
}
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
export function sanitizeSchemaParams<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
const raw = values[key as string];
switch (spec.kind) {
case "number":
case "slider": {
const n =
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
out[key] = clamp(n, spec.min, spec.max);
break;
}
case "select":
out[key] =
typeof raw === "string" && spec.options.some((o) => o.value === raw)
? raw
: spec.default;
break;
case "checkbox":
out[key] = typeof raw === "boolean" ? raw : spec.default;
break;
case "color":
out[key] =
typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw)
? raw
: spec.default;
break;
case "text":
out[key] = typeof raw === "string" ? raw : spec.default;
break;
}
}
return out;
}
+20 -20
View File
@@ -1,4 +1,5 @@
import type { CategoryId } from "./categories"; import type { CategoryId } from "./categories";
import { field, toolSchema, type ToolSchema } from "./registry-schema";
import { ToolError } from "./core/errors"; import { ToolError } from "./core/errors";
import { import {
colorMask, colorMask,
@@ -189,34 +190,22 @@ export type OutputFormat = {
export type SourceMode = "file" | "none" | "text"; export type SourceMode = "file" | "none" | "text";
export type ToolEntry = { export type ToolEntry<P = Record<string, unknown>> = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
category: CategoryId; category: CategoryId;
sourceMode?: SourceMode; sourceMode?: SourceMode;
params: ParamDef[]; params: ParamDef[];
run?: ( /** Новая типизированная схема для нового UI. Опциональна — старый UI её не использует. */
img: PixelImage, schema?: ToolSchema<P>;
params: Record<string, unknown>, run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
) => Promise<PixelImage> | PixelImage; generate?: (params: P) => Promise<PixelImage> | PixelImage;
generate?: ( toText?: (img: PixelImage, params: P) => Promise<string> | string;
params: Record<string, unknown>, runFromText?: (text: string, params: P) => Promise<PixelImage> | PixelImage;
) => Promise<PixelImage> | PixelImage;
toText?: (
img: PixelImage,
params: Record<string, unknown>,
) => Promise<string> | string;
runFromText?: (
text: string,
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
/** Текстовый источник + текстовый результат без промежуточного изображения. */ /** Текстовый источник + текстовый результат без промежуточного изображения. */
textToText?: (text: string) => Promise<string> | string; textToText?: (text: string) => Promise<string> | string;
preview?: ( preview?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
img: PixelImage,
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
popularity?: number; popularity?: number;
icon?: string; icon?: string;
resultType?: "image" | "info" | "text"; resultType?: "image" | "info" | "text";
@@ -575,6 +564,16 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
]; ];
} }
interface AddBorderParams {
thickness: number;
color: string;
}
const addBorderSchema = toolSchema<AddBorderParams>({
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
color: field.color({ default: "#000000" }),
});
export const TOOLS: ToolEntry[] = [ export const TOOLS: ToolEntry[] = [
...channelEntries(), ...channelEntries(),
...maskEntries(), ...maskEntries(),
@@ -944,6 +943,7 @@ export const TOOLS: ToolEntry[] = [
description: description:
"Draws a colored frame of the chosen thickness around the image.", "Draws a colored frame of the chosen thickness around the image.",
category: "geometry", category: "geometry",
schema: addBorderSchema,
params: [ params: [
{ {
id: "thickness", id: "thickness",
+14 -2
View File
@@ -61,6 +61,7 @@
button, button,
input:where([type="button"], [type="reset"], [type="submit"]) { input:where([type="button"], [type="reset"], [type="submit"]) {
appearance: auto; appearance: auto;
cursor: pointer;
} }
a, a,
@@ -126,12 +127,23 @@
--space-s: 4px; --space-s: 4px;
--space-m: 8px; --space-m: 8px;
--space-l: 12px; --space-l: 12px;
--space-xl: 12px; --space-xl: 16px;
--space-xxl: 24px;
--space-xxxl: 32px;
--space-brand: 32px;
--space-text-s: 0;
--space-text-m: 0.04em;
--space-text-l: 0.08em;
--space-text-xl: 0.12em;
--size-border: 1px; --size-border: 1px;
--size-border-thick: 2px; --size-border-thick: 2px;
--font-text: 10px; --font-size-s: 12px;
--font-size-m: 14px;
--font-size-l: 16px;
--radius-s: 0px; --radius-s: 0px;
--radius-m: 4px; --radius-m: 4px;
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import Footer from "$lib/components/kit/Footer.svelte"; import Footer from "$lib/components/kit/layout/Footer.svelte";
import TopBar from "$lib/components/kit/TopBar.svelte"; import TopBar from "$lib/components/kit/layout/TopBar.svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import "../../preview.css"; import "../../preview.css";
@@ -63,7 +63,7 @@
</script> </script>
<main class="preview-root" data-theme={theme}> <main class="preview-root" data-theme={theme}>
<TopBar {theme} {crumb} {status} ontoggle={toggle} /> <TopBar {theme} {crumb} ontoggle={toggle} />
{@render children()} {@render children()}
<Footer /> <Footer />
</main> </main>
+12 -9
View File
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { TOOLS } from "$lib/registry"; import TextField from "$lib/components/kit/fields/TextField.svelte";
import Panel from "$lib/components/kit/Panel.svelte"; import Panel from "$lib/components/kit/layout/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.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 ToolCard from "$lib/components/kit/ToolCard.svelte";
import Button from "$lib/components/kit/Button.svelte"; import Button from "$lib/components/kit/ui/Button.svelte";
import { TOOLS } from "$lib/registry";
import { Search } from "@lucide/svelte"; import { Search } from "@lucide/svelte";
let query = $state(""); let query = $state("");
@@ -28,8 +27,7 @@
</p> </p>
</header> </header>
<Panel> <Panel title="Search" eyebrow="tools">
<PanelHeading title="Search" eyebrow="tools" />
<div class="ws-search"> <div class="ws-search">
<div class="ws-field"> <div class="ws-field">
<TextField <TextField
@@ -38,7 +36,12 @@
placeholder="Search tools…" placeholder="Search tools…"
/> />
</div> </div>
<Button icon={Search} variant="primary" onclick={() => {}}>Search</Button> <Button
icon={Search}
variant="primary"
onclick={() => {}}
label="Search"
/>
</div> </div>
<div class="ws-results"> <div class="ws-results">
{#each results as tool (tool.id)} {#each results as tool (tool.id)}
@@ -50,7 +53,7 @@
{/each} {/each}
</div> </div>
<SettingsFooter> <SettingsFooter>
<Button onclick={() => {}}>Open last project</Button> <Button onclick={() => {}} label="Open last project" />
</SettingsFooter> </SettingsFooter>
</Panel> </Panel>
</section> </section>
+12 -15
View File
@@ -1,26 +1,26 @@
<script lang="ts"> <script lang="ts">
import ColorField from "$lib/components/kit/ColorField.svelte"; import ColorField from "$lib/components/kit/fields/ColorField.svelte";
import DownloadButton from "$lib/components/kit/DownloadButton.svelte"; import FieldGrid from "$lib/components/kit/fields/FieldGrid.svelte";
import FieldGrid from "$lib/components/kit/FieldGrid.svelte"; import SliderField from "$lib/components/kit/fields/SliderField.svelte";
import IconButton from "$lib/components/kit/IconButton.svelte"; import ToggleRow from "$lib/components/kit/fields/ToggleRow.svelte";
import PageGrid from "$lib/components/kit/layout/PageGrid.svelte"; import PageGrid from "$lib/components/kit/layout/PageGrid.svelte";
import MetaList from "$lib/components/kit/MetaList.svelte"; import MetaList from "$lib/components/kit/MetaList.svelte";
import PipelineFooter from "$lib/components/kit/PipelineFooter.svelte"; import PipelineFooter from "$lib/components/kit/PipelineFooter.svelte";
import PreviewStack from "$lib/components/kit/PreviewStack.svelte"; import PreviewStack from "$lib/components/kit/PreviewStack.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte"; import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import SectionLabel from "$lib/components/kit/SectionLabel.svelte"; import SectionLabel from "$lib/components/kit/SectionLabel.svelte";
import Segmented from "$lib/components/kit/Segmented.svelte";
import SliderField from "$lib/components/kit/SliderField.svelte";
import StepCard from "$lib/components/kit/StepCard.svelte"; import StepCard from "$lib/components/kit/StepCard.svelte";
import ToggleRow from "$lib/components/kit/ToggleRow.svelte";
import Badge from "$lib/components/kit/ui/Badge.svelte"; import Badge from "$lib/components/kit/ui/Badge.svelte";
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
import IconButton from "$lib/components/kit/ui/IconButton.svelte";
import Segmented from "$lib/components/kit/ui/Segmented.svelte";
import WorkspaceHeader from "$lib/components/kit/WorkspaceHeader.svelte";
import { import {
ChevronDown, ChevronDown,
Plus, Plus,
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
} from "@lucide/svelte"; } from "@lucide/svelte";
import WorkspaceHeader from "./WorkspaceHeader.svelte";
let gradColor = $state("#DCEBFF"); let gradColor = $state("#DCEBFF");
let direction = $state(135); let direction = $state(135);
@@ -95,7 +95,7 @@
onremove={() => {}} onremove={() => {}}
> >
{#snippet tools()} {#snippet tools()}
<Badge tone="success" check>AUTO</Badge> <Badge tone="success" check label="AUTO" />
{/snippet} {/snippet}
<FieldGrid> <FieldGrid>
<ColorField label="COLOR" chevron bind:value={gradColor} /> <ColorField label="COLOR" chevron bind:value={gradColor} />
@@ -128,7 +128,7 @@
onremove={() => {}} onremove={() => {}}
> >
{#snippet tools()} {#snippet tools()}
<Badge tone="success" check>AUTO</Badge> <Badge tone="success" check label="AUTO" />
{/snippet} {/snippet}
<div class="transform-note"> <div class="transform-note">
<SlidersHorizontal size={15} /> Automatic subject detection enabled <SlidersHorizontal size={15} /> Automatic subject detection enabled
@@ -137,7 +137,7 @@
<StepCard index={3} type="STYLE" title="Add outline" onremove={() => {}}> <StepCard index={3} type="STYLE" title="Add outline" onremove={() => {}}>
{#snippet tools()} {#snippet tools()}
<Badge tone="success" check>AUTO</Badge> <Badge tone="success" check label="AUTO" />
{/snippet} {/snippet}
<FieldGrid compact> <FieldGrid compact>
<SliderField <SliderField
@@ -158,7 +158,7 @@
onremove={() => {}} onremove={() => {}}
> >
{#snippet tools()} {#snippet tools()}
<Badge tone="success" check>AUTO</Badge> <Badge tone="success" check label="AUTO" />
{/snippet} {/snippet}
<FieldGrid compact> <FieldGrid compact>
<SliderField <SliderField
@@ -296,9 +296,6 @@
gap: 5px; gap: 5px;
display: flex; display: flex;
} }
.image-preview.active {
border-color: var(--blue);
}
.sample-icon { .sample-icon {
border: 1px solid; border: 1px solid;
padding: 3px 5px; padding: 3px 5px;
+31 -37
View File
@@ -1,28 +1,27 @@
<script lang="ts"> <script lang="ts">
import Button from "$lib/components/kit/Button.svelte";
import CheckboxField from "$lib/components/kit/CheckboxField.svelte";
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte"; import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
import CodeBlock from "$lib/components/kit/CodeBlock.svelte"; import CodeBlock from "$lib/components/kit/CodeBlock.svelte";
import ColorField from "$lib/components/kit/ColorField.svelte";
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte"; import EmptyState from "$lib/components/kit/EmptyState.svelte";
import IconButton from "$lib/components/kit/IconButton.svelte"; import CheckboxField from "$lib/components/kit/fields/CheckboxField.svelte";
import ColorField from "$lib/components/kit/fields/ColorField.svelte";
import SelectField from "$lib/components/kit/fields/SelectField.svelte";
import SliderField from "$lib/components/kit/fields/SliderField.svelte";
import TextField from "$lib/components/kit/fields/TextField.svelte";
import ImageCard from "$lib/components/kit/ImageCard.svelte"; import ImageCard from "$lib/components/kit/ImageCard.svelte";
import Panel from "$lib/components/kit/layout/Panel.svelte";
import MetaList from "$lib/components/kit/MetaList.svelte"; import MetaList from "$lib/components/kit/MetaList.svelte";
import Panel from "$lib/components/kit/Panel.svelte";
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
import PreviewTile from "$lib/components/kit/PreviewTile.svelte"; import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
import Segmented from "$lib/components/kit/Segmented.svelte";
import SelectField from "$lib/components/kit/SelectField.svelte";
import SettingGroup from "$lib/components/kit/SettingGroup.svelte"; import SettingGroup from "$lib/components/kit/SettingGroup.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte"; import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import SliderField from "$lib/components/kit/SliderField.svelte";
import StatusLine from "$lib/components/kit/StatusLine.svelte"; import StatusLine from "$lib/components/kit/StatusLine.svelte";
import StepCard from "$lib/components/kit/StepCard.svelte"; import StepCard from "$lib/components/kit/StepCard.svelte";
import TextField from "$lib/components/kit/TextField.svelte";
import Toggle from "$lib/components/kit/Toggle.svelte";
import ToolCard from "$lib/components/kit/ToolCard.svelte"; import ToolCard from "$lib/components/kit/ToolCard.svelte";
import Badge from "$lib/components/kit/ui/Badge.svelte"; import Badge from "$lib/components/kit/ui/Badge.svelte";
import Button from "$lib/components/kit/ui/Button.svelte";
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
import IconButton from "$lib/components/kit/ui/IconButton.svelte";
import Segmented from "$lib/components/kit/ui/Segmented.svelte";
import Toggle from "$lib/components/kit/ui/Toggle.svelte";
import { Download, ImageOff, Palette, Plus, Trash2 } from "@lucide/svelte"; import { Download, ImageOff, Palette, Plus, Trash2 } from "@lucide/svelte";
let mode = $state("preview"); let mode = $state("preview");
@@ -58,22 +57,21 @@
<StatusLine label="rendering ok" /> <StatusLine label="rendering ok" />
</header> </header>
<Panel> <Panel title="Buttons" eyebrow="controls">
<PanelHeading title="Buttons" eyebrow="controls" />
<div class="stack"> <div class="stack">
<SettingGroup label="variants"> <SettingGroup label="variants">
<Button onclick={() => {}}>Primary</Button> <Button onclick={() => {}} label="Primary" />
<Button variant="ghost" onclick={() => {}}>Ghost</Button> <Button variant="outline" onclick={() => {}} label="Outline" />
<Button variant="accent" onclick={() => {}}>Accent</Button> <Button variant="accent" onclick={() => {}} label="Accent" />
<Button variant="danger" onclick={() => {}}>Danger</Button> <Button variant="danger" onclick={() => {}} label="Danger" />
</SettingGroup> </SettingGroup>
<SettingGroup label="states"> <SettingGroup label="states">
<Button disabled onclick={() => {}}>Disabled</Button> <Button disabled onclick={() => {}} label="Disabled" />
<IconButton icon={Plus} label="Add" onclick={() => {}} /> <IconButton icon={Plus} label="Add" onclick={() => {}} />
<IconButton <IconButton
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
variant="solid" variant="primary"
onclick={() => {}} onclick={() => {}}
/> />
<IconButton <IconButton
@@ -89,8 +87,7 @@
</SettingsFooter> </SettingsFooter>
</Panel> </Panel>
<Panel> <Panel title="Inputs" eyebrow="controls">
<PanelHeading title="Inputs" eyebrow="controls" />
<div class="stack"> <div class="stack">
<SliderField <SliderField
label="corner radius" label="corner radius"
@@ -121,8 +118,7 @@
</div> </div>
</Panel> </Panel>
<Panel> <Panel title="Segmented & badges" eyebrow="controls">
<PanelHeading title="Segmented & badges" eyebrow="controls" />
<div class="stack"> <div class="stack">
<Segmented <Segmented
bind:value={mode} bind:value={mode}
@@ -133,16 +129,17 @@
]} ]}
/> />
<SettingGroup label="tones"> <SettingGroup label="tones">
<Badge>default</Badge> <Badge label="default" />
<Badge tone="accent">accent</Badge> <Badge tone="accent" label="accent" />
<Badge tone="success">success</Badge> <Badge tone="success" label="success" />
<Badge tone="amber">amber</Badge> <Badge tone="danger" label="default" />
<Badge tone="warning" label="warning" />
<Badge tone="info" label="info" />
</SettingGroup> </SettingGroup>
</div> </div>
</Panel> </Panel>
<Panel> <Panel title="Steps" eyebrow="pipeline">
<PanelHeading title="Steps" eyebrow="pipeline" />
<div class="stack"> <div class="stack">
<StepCard index={1} type="BACKGROUND" title="Gradient fill"> <StepCard index={1} type="BACKGROUND" title="Gradient fill">
<ColorField label="color" bind:value={tint} /> <ColorField label="color" bind:value={tint} />
@@ -153,8 +150,7 @@
</div> </div>
</Panel> </Panel>
<Panel> <Panel title="Tools" eyebrow="catalog">
<PanelHeading title="Tools" eyebrow="catalog" />
<div class="tool-grid"> <div class="tool-grid">
{#each tools as tool, i (tool.title)} {#each tools as tool, i (tool.title)}
<ToolCard <ToolCard
@@ -168,8 +164,7 @@
</div> </div>
</Panel> </Panel>
<Panel> <Panel title="Preview" eyebrow="canvas">
<PanelHeading title="Preview" eyebrow="canvas" />
<div class="preview-grid"> <div class="preview-grid">
<ImageCard label="result.png · 512×512"> <ImageCard label="result.png · 512×512">
<CheckerCanvas size="sm" /> <CheckerCanvas size="sm" />
@@ -192,15 +187,14 @@
</SettingsFooter> </SettingsFooter>
</Panel> </Panel>
<Panel> <Panel title="States & code" eyebrow="misc">
<PanelHeading title="States & code" eyebrow="misc" />
<div class="stack"> <div class="stack">
<EmptyState <EmptyState
icon={ImageOff} icon={ImageOff}
title="No image loaded" title="No image loaded"
description="Drop a PNG to start processing" description="Drop a PNG to start processing"
> >
<Button onclick={() => {}}>Open file</Button> <Button onclick={() => {}} label="Open file" />
</EmptyState> </EmptyState>
<CodeBlock <CodeBlock
code={'export const config = {\n radius: 8,\n tint: "#3b82f6",\n};'} code={'export const config = {\n radius: 8,\n tint: "#3b82f6",\n};'}