Compare commits

...
6 Commits
11 changed files with 476 additions and 186 deletions
+22 -7
View File
@@ -16,7 +16,17 @@
## Мелочи всякие
- [ ] сгенерировать или найти файлы для ручной проверки `A. Краевые PNG-файлы`
- [x] сгенерировать или найти файлы для ручной проверки `A. Краевые PNG-файлы`
- [x] при загрузке нового изображения, результат не обновлятеся, только при
изменении параметров инструмента
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть
@@ -31,14 +41,14 @@
паспортом, семейства с кириллицей)
- [ ] Посмотреть темную тему - слишком темная и слишком много оттенка (убавить c
в hct?)
- [ ] на картинке результате при работе дергается высота надписи (высота иконки)
- [x] на картинке результате при работе дергается высота надписи (высота иконки)
- [x] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно)
[баг в хроме](https://issues.chromium.org/issues/556160936)
`Uncaught TypeError: Cannot read properties of undefined (reading 'startTime')`
`at et.reportAllChanges (anonymous:2:19429)`
- [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [x] не работают генераторы картинок (create-empty-png)
- [ ] генераторы работают не автоматически - требуется нажать кнопку generate
- [x] генераторы работают не автоматически - требуется нажать кнопку generate
(create-empty-png)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
@@ -56,9 +66,9 @@
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
удаление, добавление инструментов. переход один инструмент - pipeline без
перезагрузки
- [ ] Слаги инструментов (`*-png`) — надо обдумать, возможно что ссылки\маршруты
должны быть с png, а названия инструментов - без. Это упростит названия
инструментов и сделает их более понятными
- [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми
изображениями. Надо сделать отдельный интерфейс маппинга инструмент ->
ссылка
## Переезд
@@ -184,7 +194,12 @@
9. **`png-info` (детальная информация о PNG)** — оценка M/L. Отложен из
миграции. Отдельный райз: exif-теги, редактирование, структурированный вывод;
нужен свой отдельный случай в UI, не «ещё один text/verdict».
нужен свой отдельный случай в UI, не «ещё один text/verdict». возможно нужна
вообще отдельная страница, несовместимая с другими инструментами: 1) она
работает не с изображением, а с мета данными из файла, 2) она не может быть
частью цепочки, т.к. результат не является изображением, 3) если надо
изменить теги, никакого форматирования и изменения в изображение вносить не
надо, только в мета информацияю
10. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
reverse-area) — ждут UI выделения области на превью. Из
+291
View File
@@ -0,0 +1,291 @@
# План: i18n в preview (новый UI)
> Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче
> «добавить i18n в preview» (`docs/backlog.md`). Реализация не начата.
## 1. Что получается
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
`v1`) переходит на существующий модуль `lib/i18n`: переключатель RU/EN в шапке
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
подключить к ней preview и закрыть пробелы словарей.
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
копия `lib/i18n``lib/v1/i18n`, у нового UI — своя независимая ветка
`lib/i18n`, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
между страницами.
## 2. Текущее состояние (исследование)
### Уже есть
- `lib/i18n/locale.svelte.ts` — состояние локали (runes), персист в
localStorage, обновление `document.documentElement.lang`.
- `lib/i18n/t.ts` — перевод по точечному ключу с фолбэком на базовую локаль и
интерполяцией `{var}`.
- `lib/i18n/dict.ts` — тип `Dict`, `BASE_LOCALE = "en"` (база — английский, ru —
перевод), `LOCALES = ["ru", "en"]`.
- `lib/i18n/ru.ts` (~1351 строка) и `en.ts` (188 строк) — полные переводы
инструментов v1 по id (title/description/params/options/results).
- `lib/i18n/tool-strings.ts` — хелперы для v1
(`toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc`).
- `lib/i18n/matching.ts` — кросс-языковой поиск (ё→е, диакритика, скоринг).
- Тесты: `i18n.test.ts`, `tools-i18n.test.ts`, `matching.test.ts`,
`smoke-i18n.test.ts`, `search-coverage.test.ts`.
- `+layout.svelte` уже вызывает `initLocale()` на клиенте.
### Пробелы в новом UI
- `LangToggle.svelte` — заглушка с локальным `let lang = $state("RU")`, не
связана с `locale.svelte.ts`.
- Новый UI почти целиком на хардкод-английском:
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria,
плейсхолдеры, подписи секций);
- 122 инструмента с английскими `title`/`description` в `lib/registry/*.ts`;
- ~60+ подписей опций select;
- ~30 заголовков layout-групп (`title: "Canvas"`, `"Background"`, …);
- вердикты анализаторов — raw English строки (`"Yes — valid PNG signature."`),
при том что v1 возвращает ключи словаря (`"verifyYes"`) и рендерит их через
`tools[id].results`;
- `labelOf(id)` в `SchemaFields.svelte` генерирует подписи полей из field ID
(keepAspect → "Keep Aspect");
- категории в `catalog.ts` (`GROUP_LABELS`) — English uppercase, хотя в `dict`
уже есть ключи `categories.X`.
- Из всех новых компонентов `t()` вызывает только `SchemaToolView.svelte`, и
только для ошибок (`ToolError.key`).
### Совпадение регистров
- Новый registry и старый v1 registry используют **одинаковые id** инструментов
(resize-png, flip-png, …) — переводы title/description из v1 `ru.ts` служат
исходником для новой ветки `lib/i18n/ru.ts` (копирование данных при наполнении
Фазы 2, не общий модуль).
- **Расхождение**: param ids в ru.ts (`width`/`height`/`keepAspect`) не равны
field ids новоно schema (`size` dimension, `anchor`). Нужны новые переводы
полей.
- `ru.ts` tools[id].results для анализаторов уже содержит ключи (`verifyYes`,
`grayscaleNo`, `orientationPortrait` и т.д.).
### Архитектурные ограничения
- Изоляция old↔new (`isolation/no-mixed-imports`): сейчас `lib/i18n/` — shared.
По решению задачи (Фаза 0) i18n **форкается**: v1 получает полную копию
`lib/v1/i18n`, а `lib/i18n` переходит в ветку «new» (правило изоляции запретит
старому коду импортить новый i18n). До форка в общем модуле два скрытых
v1-импорта: `dict.ts` тянет `CategoryId` из `../v1/categories`,
`tool-strings.ts``$lib/v1/registry`; после форка оба живут только в копии.
Новому UI нужен параллельный `schema-tool-strings.ts` поверх `$lib/registry`.
- Tone detection в `SchemaTextResult.svelte`: `/^(yes|true)/i` по value —
сломается при русских вердиктах («Да», «Нет»).
- `tools-i18n.test.ts` покрывает только v1 TOOLS; нужен аналог для нового
registry.
- `known-issues.spec.ts`:
`test.fixme("error message is localized, not a raw i18n key")`.
## 3. Как делаем
Принципы:
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит
разработчик после ревью.
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
- Каждый этап заканчивается зелёными `pnpm --dir web lint` +
`pnpm --dir web check` + тесты.
- Решения по §5 зафиксированы и учитываются с первых правок словарей.
## 4. Фазы
### Фаза 0: Форк i18n для v1 + подготовка общего слоя
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
которую дальше меняем свободно. На выходе фазы — зелёные `lint`, `check`,
`test`, `test:rules`.
- **0.1** Форк: скопировать `web/src/lib/i18n/``web/src/lib/v1/i18n/` (полный
снимок: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `tool-strings.ts` + все 5 тестов). v1 работает дальше только с
копией — поздние правки нового i18n её не затрагивают.
- **0.2** Привести копию к v1-канону (относительные пути от нового места):
- копия `dict.ts`: импорт `CategoryId` `../v1/categories``../categories` (=
`lib/v1/categories`);
- копии тестов (`tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts`): `../v1/registry``../registry` (=
`lib/v1/registry`);
- импорт `$lib/v1/registry` в копии `tool-strings.ts` остаётся валидным;
- перепроверить остальные относительные и `$lib`-пути в копии.
- **0.3** Переключить импорты старого UI: все `$lib/i18n/*` в `routes/v1/**` и
`lib/v1/components/**` (39 импортов в 23 файлах) → `$lib/v1/i18n/*`.
- **0.4** Очистить новый `lib/i18n/` от v1-специфики, иначе isolation поймает
new→old:
- удалить `tool-strings.ts` (завязан на v1-типы `ParamDef`/`ToolEntry`; для
нового UI вместо него `schema-tool-strings.ts`, см. 0.8);
- удалить v1-центричные тесты `tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts` (они импортируют `../v1/registry`; живут только в
копии);
- остаются: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `i18n.test.ts`, `matching.test.ts`.
- **0.5** Обновить правило изоляции и конфиги:
- `isolation/no-mixed-imports.js`: добавить `lib/i18n/**` в `DEFAULT_NEW`
(копия по-прежнему old через `lib/v1/**`);
- `eslint-plugins/__tests__/no-mixed-imports.test.ts`: кейсы «old → i18n
чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под
новую классификацию: old→новый i18n = нарушение; добавить фикстуру
`__fixtures__/src/lib/v1/i18n/t.ts` для кейса old-копия;
- `eslint.config.js`: в `allowDefaultProject` добавить фикстуру
`eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts`; поправить комментарий
изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;
- `eslint-plugins/README.md`: убрать i18n из списка shared (§ Isolation).
- **0.6** Новый `lib/i18n/dict.ts`: `CategoryId` переключить с
`../v1/categories` на `../categories` (новый структурно-идентичный тип) —
скрытых v1-зависимостей в новом i18n не остаётся.
- **0.7** Проверка форка: `pnpm --dir web lint`, `check`, `test` (v1-копия
тестов подхватывается `vitest include: src/**/*.test.ts`), `test:rules`;
смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих
версий согласованы.
- **0.8** Добавить `lib/i18n/schema-tool-strings.ts` поверх `$lib/registry`
(новый тип `ToolEntry`):
- `toolTitle(tool)`, `toolDescription(tool)` с фолбэком на registry English;
- `fieldLabel(field, id)` — перевод по ключу из `fields`-секции словаря (см.
решение C), фолбэк на `labelOf(id)`;
- `groupLabel(title)` — перевод по ключу из `groups`-секции словаря (см.
решение A);
- `verdictText(toolId, key)` — рендер вердикта по ключу;
- `verdictTone(key)``success`|`danger`|`info` по имени ключа
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
- новая секция `preview` для UI-строк нового дизайна (только те, что остаются
после чистки mono-лейблов, см. решение B);
- новая глобальная секция `fields: Record<string, string>` — переводы подписей
полей по ключу из схемы (решение C);
- новая глобальная секция `groups: Record<string, string>` — переводы
заголовков групп полей (решение A).
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `preview`, `fields`, `groups`.
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
title/description в ru.ts новой ветки (по категориям — несколько коммитов).
- **1.3** Добавить в `ru.ts` ключи `fields`/`groups` для используемых в схемах
ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза
5).
- **1.4** Добавить `tools[id].options[fieldId][value]` для select-полей, чьи
опции нужно локализовать.
- **1.5** Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть
перевод в обоих словарях.
### Фаза 2: LangToggle → реальный locale
- **2.1** `LangToggle.svelte`: убрать локальный `lang`, подключить
`getLocale()`, `setLocale()`, `LOCALES`. Модель — language switch из
`routes/v1/+layout.svelte`.
- **2.2** Проверить e2e `navigation.spec.ts` «language toggle marks the active
button» — должен проходить без изменений.
### Фаза 3: Routes + layout
- **3.1** `+layout.svelte`: breadcrumbs через `t()`, динамический `lang`.
- **3.2** `+page.svelte`, `list-tools/+page.svelte`: footer-строка →
`t("header.footerNote")`.
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
### Фаза 4: Компоненты — основной блок (по одному коммиту на компонент)
Каждый коммит: удаление декоративных mono-лейблов (решение B) + замена
оставшихся строк на `t`/хелперы + lint/check. Список mono-лейблов, которые
**удаляем**: eyebrow'ы (`PNG PROCESSING / SINGLE TOOL`, `KIT SHOWCASE`,
`EASY-PNG-TOOLS / CATALOG`), статусы (`LIVE PREVIEW`, `rendering ok`), панельные
подписи (`TOOL SETTINGS`), дублирующие капшены (`SOURCE / RESULT`,
`GENERATOR / RESULT`). Оставляем и локализуем только информативные: категории
каталога, счётчики инструментов, подписи данных (`FORMAT`, `parts`, `px`, «ZIP
(PNG)»), alt-тексты, а также содержательные заголовки («Configure output»).
- `SchemaToolView.svelte`: убрать eyebrow/`LIVE PREVIEW`/`TOOL SETTINGS`,
перевести «Configure output» и «This tool has no schema yet.».
- `SchemaSourceTile.svelte`: убрать figcaption `SOURCE`, перевести «choose an
image»/«no source…» и alt.
- `SchemaResultTile.svelte`: убрать figcaption `RESULT`, перевести «no result
yet»/«parts» и alt.
- `SchemaPreview.svelte`: убрать панельные
`SOURCE / RESULT`/`GENERATOR / RESULT`, перевести meta-капшены (`FORMAT` и
т.п.).
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)`
`fieldLabel(field)`.
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
- `SchemaTextResult.svelte`: убрать лейбл `RESULT` у вердикта, перевести Copy /
Download .txt; tone detection → `verdictTone()`.
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
`t("categories.X")`, поисковый placeholder.
- `CatalogHeader.svelte`: убрать eyebrow, перевести heading/lead и счётчик
«TOOLS AVAILABLE».
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
- Schema field controls (по одному или группой): Width/Height, From/To, Backing
plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color»,
«Remove color».
- `TopNav.svelte`, `ThemeToggle.svelte`, `CodeBlock.svelte`, `StepCard.svelte`,
`Footer.svelte`.
### Фаза 5: Вердикты + ключи в schema
- **5.1** Анализаторы в `registry/analyze.ts`: возвращают ключи (`"verifyYes"`
вместо raw English).
- **5.2** `SchemaPreview.svelte`: рендер вердикта через
`verdictText(tool.id, key)`.
- **5.3** Layout groups в `registry/*.ts` (решение A): `title` → ключ
(`title: "groups.canvas"`); в `SchemaFields.svelte``groupLabel(title)`. В
`Dict` — глобальная секция `groups`.
- **5.4** Лейблы полей в `registry/*.ts` (решение C): на поле в схеме
проставляется `label`-ключ (`field.number({ label: "fields.thickness", … })`),
вывод в UI — `fieldLabel(field)` = `t(label)`. В `Dict` — глобальная секция
`fields`.
- **5.5** Категории в `catalog.ts`: `GROUP_LABELS``t("categories.X")` (ключи
уже есть в dict).
### Фаза 6: Тесты
- **6.1** `new-tools-i18n.test.ts` — полнота словарей для нового registry:
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
- **6.3** В новой ветке `lib/i18n` появится собственный `smoke-i18n.test.ts`
(аналог v1: переключение локали, `html lang`, контент без смеси языков) —
v1-копия теста не трогается.
### Фаза 7: Кросс-языковой поиск
Переключить поиск в новом каталоге с `toLowerCase()` на
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
решение D). Последний шаг, аккуратный отдельный коммит.
## 5. Решения
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
`t(title)` с фолбэком на сырой ключ.
- **B. Mono-лейблы**: декоративные лейблы без полезной информации **удаляем**
(eyebrow'ы, статусы, панельные капшены, дублирующие подписи секций) — они
занимают место. Оставляем только там, где реально нужно, и локализуем:
счётчики инструментов, категории каталога, подписи данных (FORMAT, parts, px,
«ZIP (PNG)»), alt-тексты, содержательные headings.
- **C. Лейблы полей**: в схеме задаём i18n-ключ
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
фолбэк на текущий `labelOf(id)` для несопоставленных.
- **D. Объём**: переводим всё сразу — и каркас, и контент реестра, и вердикты, и
кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие
атомарные шаги/коммиты.
## 6. Проверки
- `pnpm --dir web lint`
- `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`
- `pnpm --dir web test`
- `pnpm --dir web test:rules` (если менялся eslint/isolation-конфиг)
- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
вердикт — без смеси языков; `html lang` следует за локалью.
+11 -11
View File
@@ -101,11 +101,11 @@
--brand-main: #86efac;
--brand-alt: hct(from var(--brand-main) calc(h + 90) c t);
--color-background: hct(from var(--brand-main) h 8 95);
--color-background-muted: hct(from var(--brand-main) h 12 85);
--color-panel: hct(from var(--brand-main) h 6.5 98);
--color-checker-main: hct(from var(--brand-main) h 10.7 85.7);
--color-checker-alt: hct(from var(--brand-main) h 13.2 76);
--color-background: hct(from var(--brand-main) h 2 95);
--color-background-muted: hct(from var(--brand-main) h 4 85);
--color-panel: hct(from var(--brand-main) h 3 98);
--color-checker-main: hct(from var(--brand-main) h 5 85.7);
--color-checker-alt: hct(from var(--brand-main) h 7 76);
--color-border: hct(from var(--brand-main) h 13.1 69.4);
@@ -186,13 +186,13 @@
--brand-main: #86efac;
--brand-alt: hct(from var(--brand-main) calc(h + 90) c t);
--color-background: hct(from var(--brand-main) h 15 6);
--color-background-muted: hct(from var(--brand-main) h 15 10);
--color-panel: hct(from var(--brand-main) h 12 8);
--color-checker-main: hct(from var(--brand-main) h 12 10);
--color-checker-alt: hct(from var(--brand-main) h 14 14);
--color-background: hct(from var(--brand-main) h 2 16);
--color-background-muted: hct(from var(--brand-main) h 4 24);
--color-panel: hct(from var(--brand-main) h 3 26);
--color-checker-main: hct(from var(--brand-main) h 6 16);
--color-checker-alt: hct(from var(--brand-main) h 8 24);
--color-border: hct(from var(--brand-main) h 15 25);
--color-border: hct(from var(--brand-main) h 8 40);
--color-text: hct(from var(--brand-main) h 25 90);
--color-text-muted: hct(from var(--brand-main) h 22 70);
@@ -7,7 +7,6 @@
<div class="catalog-head">
<div>
<div class="eyebrow">EASY-PNG-TOOLS / CATALOG</div>
<h1>Tool catalog</h1>
<p>
Focused utilities for working with PNG. Inspect, transform, and export —
@@ -28,12 +27,6 @@
gap: var(--space-xxl);
flex-wrap: wrap;
}
.eyebrow {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-xl);
color: var(--color-text-muted);
margin-bottom: var(--space-m);
}
h1 {
margin: 0;
font-size: var(--font-size-2xl);
+9 -26
View File
@@ -1,32 +1,20 @@
<script lang="ts">
import { Upload } from "@lucide/svelte";
import DownloadButton from "./ui/DownloadButton.svelte";
import { Sparkles, Upload } from "@lucide/svelte";
interface Props {
inputMode: "image" | "text" | "none";
canDownload: boolean;
running: boolean;
onupload: (file: File) => void;
ongenerate: () => void;
ondownload: () => void;
}
let {
inputMode,
canDownload,
running,
onupload,
ongenerate,
ondownload,
}: Props = $props();
let { inputMode, canDownload, running, onupload, ondownload }: Props =
$props();
</script>
<div class="actions">
{#if inputMode === "none"}
<button class="btn generate" onclick={ongenerate} disabled={running}>
<Sparkles size={14} />
{running ? "Generating…" : "Generate"}
</button>
{:else if inputMode === "image"}
{#if inputMode === "image"}
<label class="btn upload">
<Upload size={14} /> Open image
<input
@@ -40,7 +28,11 @@
</label>
{/if}
{#if canDownload}
<DownloadButton label="Download result" onclick={ondownload} />
<DownloadButton
label="Download result"
onclick={ondownload}
disabled={running}
/>
{/if}
</div>
@@ -62,15 +54,6 @@
color: var(--color-text-muted);
cursor: pointer;
}
.generate {
background: var(--color-main);
border-color: var(--color-main);
color: var(--color-background);
}
.generate:disabled {
opacity: 0.6;
cursor: default;
}
.upload input {
display: none;
}
+11 -16
View File
@@ -17,7 +17,6 @@
running?: boolean;
error?: string;
onupload: (file: File) => void;
ongenerate?: () => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
@@ -35,7 +34,6 @@
running = false,
error = "",
onupload,
ongenerate,
ontextsource,
onrendertext,
oncopytext,
@@ -43,16 +41,16 @@
ondownload,
}: Props = $props();
const isGenerator = $derived(inputMode === "none");
const sourceValue = $derived(
isGenerator
? "—"
: inputMode === "text"
? "text"
: source
? `${source.width} × ${source.height} px`
: "—",
);
const sourceValue = $derived.by(() => {
switch (inputMode) {
case "none":
return "—";
case "text":
return "text";
case "image":
return source ? `${source.width} × ${source.height} px` : "—";
}
});
const resultValue = $derived(
resultKind === "image"
? result
@@ -77,15 +75,12 @@
</script>
<div class="panel-head">
<span class="label"
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
>
<span class="label">PREVIEW PANEL</span>
<SchemaActions
{inputMode}
canDownload={hasResult}
{running}
{onupload}
ongenerate={ongenerate ?? (() => {})}
{ondownload}
/>
</div>
+14 -42
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import type { FileResult } from "$lib/registry";
import { Check, RefreshCw } from "@lucide/svelte";
import type { FileResult, ResultKind } from "$lib/registry";
import { Check } from "@lucide/svelte";
import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./layout/PreviewTile.svelte";
interface Props {
resultKind?: "image" | "text" | "verdict" | "files";
resultKind?: ResultKind;
result: PixelImage | null;
fileResult?: FileResult | null;
textResult?: string | null;
@@ -33,20 +34,13 @@
}))
: [],
);
let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
);
</script>
<figure class="tile">
<figcaption>
<span>
RESULT
{#if resultKind === "files" && fileResult}
<span class="count">{fileResult.files.length} parts</span>
{/if}
{#if running}
<RefreshCw class="rotating" size="16" />
{/if}
</span>
</figcaption>
<PreviewTile label="RESULT" viewMode={resultKind} loading={running} {parts}>
<div class="canvas" class:checker={resultKind === "image"}>
{#if resultKind === "text" && textResult}
<div class="text-result-wrap">
@@ -83,20 +77,9 @@
<span class="empty">no result yet</span>
{/if}
</div>
</figure>
</PreviewTile>
<style>
.tile figcaption {
display: flex;
align-items: center;
gap: var(--space-m);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
& :global(.rotating) {
animation: rotate var(--duration-l) linear infinite;
}
}
.canvas {
display: flex;
align-items: center;
@@ -109,8 +92,10 @@
color: var(--color-text-muted);
}
.canvas img {
width: 100%;
height: 100%;
width: auto;
max-width: 100%;
height: auto;
max-height: 100vh;
object-fit: contain;
display: block;
}
@@ -124,10 +109,6 @@
.empty {
font: var(--font-size-s) var(--font-mono);
}
.count {
margin-left: var(--space-m);
color: var(--color-main);
}
.parts-grid {
display: grid;
grid-template-columns: repeat(
@@ -170,13 +151,4 @@
padding: var(--space-l);
box-sizing: border-box;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
+19 -53
View File
@@ -2,6 +2,7 @@
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import type { InputMode } from "$lib/registry";
import PreviewTile from "./layout/PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte";
interface Props {
@@ -24,61 +25,26 @@
const sourceUrl = $derived(source ? toDataUrl(source) : null);
</script>
<figure class="tile">
<figcaption><span>SOURCE</span></figcaption>
<div class="canvas" class:checker={mode === "image"}>
{#if mode === "text"}
<div class="text-source-wrap">
<SchemaTextSource
value={textSource}
disabled={running}
oninput={ontextinput ?? (() => {})}
onrender={onrendertext ?? (() => {})}
/>
</div>
{:else if mode === "image" && sourceUrl}
<img src={sourceUrl} alt="source" />
{:else if mode === "image"}
<span class="empty">choose an image</span>
{:else}
<span class="empty">no source — configure the parameters</span>
{/if}
</div>
</figure>
<PreviewTile label="SOURCE" viewMode={mode}>
{#if mode === "text"}
<div class="text-source-wrap">
<SchemaTextSource
value={textSource}
disabled={running}
oninput={ontextinput ?? (() => {})}
onrender={onrendertext ?? (() => {})}
/>
</div>
{:else if mode === "image" && sourceUrl}
<img src={sourceUrl} alt="source" />
{:else if mode === "image"}
<span class="empty">choose an image</span>
{:else}
<span class="empty">no source — configure the parameters</span>
{/if}
</PreviewTile>
<style>
.tile figcaption {
display: flex;
align-items: center;
gap: var(--space-m);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
}
.canvas {
display: flex;
align-items: center;
justify-content: center;
min-height: clamp(var(--space-brand), 30vh, 60vh);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
overflow: hidden;
background: var(--color-background-muted);
color: var(--color-text-muted);
}
.canvas img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.canvas.checker {
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 28px 28px;
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
+5 -22
View File
@@ -8,12 +8,12 @@
import { execute } from "$lib/executor";
import { t } from "$lib/i18n/t";
import type { FileResult, ToolEntry } from "$lib/registry";
import { downloadZip } from "$lib/zip";
import {
defaultSchemaParams,
sanitizeSchemaParams,
type ToolSchema,
} from "$lib/registry-schema";
import { downloadZip } from "$lib/zip";
interface Props {
tool: ToolEntry;
@@ -142,8 +142,10 @@
});
$effect(() => {
if (inputMode !== "image" || !source || !started) return;
// TODO: can any edge case start infinite loop?
if (!started) return;
void values;
void source;
debouncedRun();
return () => debouncedRun.cancel();
});
@@ -159,11 +161,9 @@
<div class="schema-tool">
<header class="header">
<div class="title-block">
<span class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</span>
<h1>{tool.title}</h1>
<p class="lede">{tool.description}</p>
</div>
<span class="status"><i></i> LIVE PREVIEW</span>
</header>
<div class="workspace">
@@ -187,7 +187,6 @@
{running}
{error}
onupload={handleFile}
ongenerate={run}
ontextsource={(textValue) => {
textSource = textValue;
}}
@@ -221,14 +220,11 @@
line-height: 1.1;
color: var(--color-text);
}
.eyebrow,
.label,
.status {
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
color: var(--color-text-muted);
}
.eyebrow span,
.label {
color: var(--color-main);
}
@@ -239,19 +235,6 @@
font-size: var(--font-size-s);
line-height: 1.5;
}
.status {
display: inline-flex;
align-items: center;
gap: var(--space-m);
align-self: flex-start;
white-space: nowrap;
}
.status i {
width: var(--size-border-thick);
height: var(--size-border-thick);
border-radius: 50%;
background: var(--color-success);
}
.workspace {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) minmax(
@@ -0,0 +1,91 @@
<script lang="ts">
import type { InputMode, ResultKind } from "$lib/registry";
import { RefreshCw } from "@lucide/svelte";
import type { Snippet } from "svelte";
interface Props {
label: string;
viewMode: InputMode | ResultKind;
children: Snippet;
loading?: boolean;
parts?: number;
}
let {
label,
viewMode: mode,
children,
loading = false,
parts = undefined,
}: Props = $props();
// TODO: make canvas aspect ratio auto, from 16:9 to 9:16 max
</script>
<figure class="tile">
<figcaption>
<span>
{label}
{#if parts !== undefined}
<span class="count">{parts} parts</span>
{/if}
{#if loading}
<RefreshCw class="rotating" size="12" />
{/if}
</span>
</figcaption>
<div class="canvas" class:checker={mode === "image"}>
{@render children()}
</div>
</figure>
<style>
.tile figcaption {
display: flex;
align-items: center;
gap: var(--space-m);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
& :global(.rotating) {
animation: rotate var(--duration-l) linear infinite;
}
}
.count {
margin-left: var(--space-m);
color: var(--color-main);
}
.canvas {
display: flex;
align-items: center;
justify-content: center;
min-height: clamp(var(--space-brand), 30vh, 60vh);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
overflow: hidden;
background: var(--color-background-muted);
color: var(--color-text-muted);
}
.canvas :global(img) {
width: auto;
max-width: 100%;
height: auto;
max-height: 100vh;
object-fit: contain;
display: block;
}
.canvas.checker {
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 28px 28px;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -5,8 +5,9 @@
interface Props {
label: string;
onclick?: () => void;
disabled?: boolean;
}
let { label, onclick }: Props = $props();
let { label, onclick, disabled = false }: Props = $props();
</script>
<Button icon={Download} {label} {onclick} />
<Button icon={Download} {label} {onclick} {disabled} />