Compare commits

...
20 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 371387ce81 fix: update i18n - update registry for tools 2026-09-13 09:04:05 +05:00
Ku6epXBOCTuK 08c9fbce3b fix: update i18n - update schema fields descriptions 2026-09-13 07:16:46 +05:00
Ku6epXBOCTuK 4de42b1e40 fix: update i18n - tool group descriptions 2026-09-13 06:47:14 +05:00
Ku6epXBOCTuK 372babc3b7 fix: update i18n - text verdicts from analyze tools 2026-09-13 06:38:58 +05:00
Ku6epXBOCTuK 4b44bc439c fix: update i18n - fields components 2026-09-13 06:25:53 +05:00
Ku6epXBOCTuK 6c88a23e48 fix: update i18n - schema components 2026-09-13 06:21:28 +05:00
Ku6epXBOCTuK a4c85c0814 fix: update i18n - catalog, nav, common components 2026-09-13 05:44:26 +05:00
Ku6epXBOCTuK e06970997e fix: update i18n text in pages and layout 2026-09-13 05:36:38 +05:00
Ku6epXBOCTuK 1097186487 fix: update lang toggle component 2026-09-13 05:29:53 +05:00
Ku6epXBOCTuK abfcbbfac2 docs: update plan and backlog 2026-09-13 05:25:56 +05:00
Ku6epXBOCTuK 9a27388119 feat: add new dics\translations 2026-09-13 05:11:40 +05:00
Ku6epXBOCTuK 3c10147ad8 style: fix formatting 2026-09-13 04:36:07 +05:00
Ku6epXBOCTuK a71487d30b docs: update dict for i18n 2026-09-13 04:23:41 +05:00
Ku6epXBOCTuK 71b5c90a4a docs: add audit tools 2026-09-12 21:48:39 +05:00
Ku6epXBOCTuK ce33201302 chore: update isolation plugin 2026-09-12 20:55:59 +05:00
Ku6epXBOCTuK dda5711a33 test: remove v1 test, later need impl new ones 2026-09-12 19:48:22 +05:00
Ku6epXBOCTuK 939038ffc0 refactor: change v1 to import own copy of i18n 2026-09-12 19:31:59 +05:00
Ku6epXBOCTuK de2a229545 chore: copy i18n to v1 for separate 2026-09-12 19:19:22 +05:00
Ku6epXBOCTuK 9fdd960d81 docs: update backlog 2026-09-12 18:57:26 +05:00
Ku6epXBOCTuK 7aefdc3848 chore: cleanup - remove old references and audit scripts 2026-09-12 18:54:11 +05:00
159 changed files with 4154 additions and 58377 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
# План: инструмент «Разрезать PNG на части» (split-into-parts-png) # План: инструмент «Разрезать PNG на части» (split-into-parts-png)
> Статус: **выполнено и в архиве** (2026-09). Инструмент и механизм «результат = > Статус: **выполнено и в архиве** (2026-09). Инструмент и механизм «результат =
> набор файлов» (1 → many) реализованы; первый потребитель — `split-into-parts-png`. > набор файлов» (1 → many) реализованы; первый потребитель —
> Общий процесс таких инструментов — задача 18 в `docs/backlog.md`. > `split-into-parts-png`. Общий процесс таких инструментов — задача 18 в
> `docs/backlog.md`.
## Цель ## Цель
+23 -60
View File
@@ -2,23 +2,13 @@
## lint и прочие правила ## lint и прочие правила
- [x] линт правило для `interface Props {...}` + `let {}: Props = $props()` - [ ] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
(плагин `conventions`, правило `interface-props`; автофикс для значения, совпадение `{placeholder}`-переменных между всеми локалями) —
нетипизированной деструктуризации) правило `i18n/dict-consistency`, детали в §Фаза 8
- [x] линт правило для `object as const`, нежелательно использовать `docs/plan-preview-i18n.md`
`type Kind = 'a' | 'b' | 'c'` - это приводит к дублированию описаний в
разных местах, и к непонятным новым типам типа `type Kind2 = 'a' | 'c'`
(правило `conventions/no-string-union-alias`)
- [x] можно ли сделать правило для минимизации новых `const` определений? чтобы
дубли автоматически определялись? ИИ агент иногда дублирует определения
(как линт-правило — нереализуемо без семантики; решено ограничением в
AGENTS.md «сначала искать», авто-детект дублей в файле — открытый вопрос)
## Мелочи всякие ## Мелочи всякие
- [x] сгенерировать или найти файлы для ручной проверки `A. Краевые PNG-файлы`
- [x] при загрузке нового изображения, результат не обновлятеся, только при
изменении параметров инструмента
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность - [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в - [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
@@ -39,17 +29,7 @@
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей) паспортом, семейства с кириллицей)
- [ ] Посмотреть темную тему - слишком темная и слишком много оттенка (убавить 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) - [ ] сделать загрузку картинки исходника по ctrl+v и drag-and-drop (dropzone)
- [x] не работают генераторы картинок (create-empty-png)
- [x] генераторы работают не автоматически - требуется нажать кнопку generate
(create-empty-png)
- [ ] square-mask-png - проверить работу - параметры неверно считаются. width - - [ ] square-mask-png - проверить работу - параметры неверно считаются. width -
100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку 100, height - 100, x - 100, y - 100, обрезает 50% на 50% картинку
- [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность - [ ] прикрутить кастомный color picker (есть в old), добавить прозрачность
@@ -72,9 +52,10 @@
## Переезд ## Переезд
- [x] составить план переноса preview -> корень, старый корень -> old
- [x] сам перенос (C17) — `docs/archive/plan-c17.md`
- [ ] добавить i18n в preview - [ ] добавить i18n в preview
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
`tPlural()` и формат `PluralForms`, детали в §Фаза 9
`docs/plan-preview-i18n.md`
## SEO/GEO ## SEO/GEO
@@ -142,35 +123,27 @@
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔ Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
file). file).
2. **UI-эксперимент: параметры между исходником и результатом** — оценка S/M. 2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
Раскладка «Исходник → Параметры → Результат» в один ряд вместо параметров
отдельным блоком снизу. Проверить на узких экранах; возможно за флагом/A-B,
чтобы сравнить с текущей. Перекрыто редизайном: старый план
(`docs/archive/plan-inline-params.md`) закрыт, страницы инструментов теперь
`SchemaToolView`/`SchemaPreview`. Эксперимент переосмыслить уже для нового
этапа инструментов (сетка source|params|result), если понадобится.
3. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
помнить в workspace-pipeline. помнить в workspace-pipeline.
4. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки 3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
в localStorage (список, создать/переименовать/удалить), быстрое переключение. в localStorage (список, создать/переименовать/удалить), быстрое переключение.
Формат экспортного JSON расширить опциональным полем имени, старые файлы Формат экспортного JSON расширить опциональным полем имени, старые файлы
читаются как безымянные. читаются как безымянные.
5. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив 4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
chain по каждому файлу, сборка результата обратно в zip для скачивания. chain по каждому файлу, сборка результата обратно в zip для скачивания.
Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON; Зависит от п.4 (выбор цепочки) и от экспортного формата pipeline JSON;
прогресс и ошибки — пофайлово. прогресс и ошибки — пофайлово.
6. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на 5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
карточках каталога, результатах поиска и в шапке страницы инструмента; список карточках каталога, результатах поиска и в шапке страницы инструмента; список
id в localStorage. Избранное показывается отдельной секцией сверху каталога и id в localStorage. Избранное показывается отдельной секцией сверху каталога и
поднимается в выдаче поиска (бонус к popularity при скоринге). поднимается в выдаче поиска (бонус к popularity при скоринге).
7. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download** 6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
— оценка M/L. Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др. — оценка M/L. Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др.
форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого
— бесшовное внедрение выбора формата/качества прямо в кнопку/диалог — бесшовное внедрение выбора формата/качества прямо в кнопку/диалог
@@ -186,13 +159,13 @@
(целевой KB, бинарный поиск по k из квантования). Проработать вместе с (целевой KB, бинарный поиск по k из квантования). Проработать вместе с
достройкой download-флоу нового UI. достройкой download-флоу нового UI.
8. **`watermark-image-png` (водяной знак картинкой)** — оценка M. Отложен из 7. **`watermark-image-png` (водяной знак картинкой)** — оценка M. Отложен из
миграции (`archive/plan-migrate-remaining.md`). Нужна overlay-механика: миграции (`archive/plan-migrate-remaining.md`). Нужна overlay-механика:
второй источник изображения (getOverlay/store), параметры scale/opacity/ второй источник изображения (getOverlay/store), параметры scale/opacity/
position/margin/aspect. Решается вместе с вопросом, как второй источник position/margin/aspect. Решается вместе с вопросом, как второй источник
картинки вписывается в schema-driven preview (`SchemaToolView`). картинки вписывается в schema-driven preview (`SchemaToolView`).
9. **`png-info` (детальная информация о PNG)** — оценка M/L. Отложен из 8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отложен из
миграции. Отдельный райз: exif-теги, редактирование, структурированный вывод; миграции. Отдельный райз: exif-теги, редактирование, структурированный вывод;
нужен свой отдельный случай в UI, не «ещё один text/verdict». возможно нужна нужен свой отдельный случай в UI, не «ещё один text/verdict». возможно нужна
вообще отдельная страница, несовместимая с другими инструментами: 1) она вообще отдельная страница, несовместимая с другими инструментами: 1) она
@@ -201,32 +174,22 @@
изменить теги, никакого форматирования и изменения в изображение вносить не изменить теги, никакого форматирования и изменения в изображение вносить не
надо, только в мета информацияю надо, только в мета информацияю
10. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/ 9. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
reverse-area) — ждут UI выделения области на превью. Из reverse-area) — ждут UI выделения области на превью. Из
`archive/plan-gap-waves.md` («вне очереди»). `archive/plan-gap-waves.md` («вне очереди»).
11. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors) 10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
— механизм «результат = набор файлов» (1 → many) реализован: `FileResult` + — механизм «результат = набор файлов» (1 → many) реализован: `FileResult` +
`result: "files"` + скачивание zip-архивом + грид-превью частей. Первый `result: "files"` + скачивание zip-архивом + грид-превью частей. Первый
потребитель — `split-into-parts-png`. Остальные подключаются по мере нужды. потребитель — `split-into-parts-png`. Остальные подключаются по мере нужды.
12. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное 11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
решение о формате. Из `archive/plan-gap-waves.md`. решение о формате. Из `archive/plan-gap-waves.md`.
13. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из 12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). Из
`archive/plan-gap-waves.md`. `archive/plan-gap-waves.md`.
14. ~~**Переезд старых файлов в папки `old/`**~~**ЗАКРЫТО.** Старые файлы 13. **Контраст текста на акцентных кнопках — пересмотреть после готовности
(`lib/registry.ts`, `lib/registry/`, `lib/registry-helpers.ts`,
`lib/categories.ts`, `lib/tools/`, `lib/components/` кроме `kit/`)
перенесены в `lib/old/`. Isolation-плагин упрощён до `lib/old/**`. Все
импорты обновлены. svelte-check 0, тесты 618/618, lint:all чист, билд
проходит.
15. ~~**Техдолг (чинить по заведённым задачам, не игнорировать правила)**~~
ЗАКРЫТО. Подробности: `docs/archive/plan-tech-debt.md`.
16. **Контраст текста на акцентных кнопках — пересмотреть после готовности
сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent` сайта**. Текст `--color-background` на фоне `--color-main`/`--color-accent`
(тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста; (тон 48) даёт контраст ~4.1–4.3 (light) — ниже AA 4.5 для обычного текста;
`palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как `palette.test.ts` держит для кнопок порог 4.0 (AA large-text) осознанно, как
@@ -235,7 +198,7 @@
проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но проверить `--color-danger` (dark, тон 55): текст на нём 4.93 — ок, но
light-danger тон 48 = 4.25 на границе. light-danger тон 48 = 4.25 на границе.
17. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и 14. **Отображение маски для инструментов (runMask/preview-mask)** — придумать и
реализовать процесс показа маски результата. **Контекст:** в старом реализовать процесс показа маски результата. **Контекст:** в старом
контракте поле `preview` у `remove-color`/`remove-background` показывало контракте поле `preview` у `remove-color`/`remove-background` показывало
быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что быстрый ч/б превью-маску (`colorMask`/`backgroundMaskPreview`) того, что
@@ -250,7 +213,7 @@
инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M). инструмент (всегда результат. Оценка — на усмотрение при проработке (S/M).
Пока живём без неё. Пока живём без неё.
18. **Процесс работы с инструментами 1 → many и many → 1** — абстракция 15. **Процесс работы с инструментами 1 → many и many → 1** — абстракция
результата «набор файлов» оформлена и работает: тип `FileResult`, результата «набор файлов» оформлена и работает: тип `FileResult`,
`result: "files"` в реестре, сериализация через executor, скачивание `result: "files"` в реестре, сериализация через executor, скачивание
zip-архивом, грид-превью частей. Реализовано на первом потребителе zip-архивом, грид-превью частей. Реализовано на первом потребителе
+67 -28
View File
@@ -156,8 +156,11 @@
- `verdictTone(key)``success`|`danger`|`info` по имени ключа - `verdictTone(key)``success`|`danger`|`info` по имени ключа
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке. (`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`): - **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
- новая секция `preview` для UI-строк нового дизайна (только те, что остаются - секция `preview` **не заводится**: строки нового UI живут в собственных
после чистки mono-лейблов, см. решение B); категориях — расширяем существующие (`catalog`, `header`, `dropZone`,
`sourceCard`, `resultCard`, `paramsCard`, `textResult`, `ui`) и заводим две
маленькие новые: `actions` (Generate / Open image / Download result…) и
`textSource` (placeholder, Try sample…);
- новая глобальная секция `fields: Record<string, string>` — переводы подписей - новая глобальная секция `fields: Record<string, string>` — переводы подписей
полей по ключу из схемы (решение C); полей по ключу из схемы (решение C);
- новая глобальная секция `groups: Record<string, string>` — переводы - новая глобальная секция `groups: Record<string, string>` — переводы
@@ -165,7 +168,8 @@
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем) ### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `preview`, `fields`, `groups`. - **1.1** Заполнить `en.ts` и `ru.ts` секциями `actions`, `textSource`,
`fields`, `groups` и ключами недостающих строк в существующих секциях.
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и - **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
title/description в ru.ts новой ветки (по категориям — несколько коммитов). title/description в ru.ts новой ветки (по категориям — несколько коммитов).
@@ -192,36 +196,29 @@
`t("header.footerNote")`. `t("header.footerNote")`.
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`. - **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
### Фаза 4: Компоненты — основной блок (по одному коммиту на компонент) ### Фаза 4: Компоненты — основной блок (коммиты кучками по смыслу)
Каждый коммит: удаление декоративных mono-лейблов (решение B) + замена Все текстовые подписи в компонентах — информативные, сохраняем и переводим на
оставшихся строк на `t`/хелперы + lint/check. Список mono-лейблов, которые `t`/хелперы. Каждый коммит: замена строк + lint/check.
**удаляем**: 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`, - `SchemaToolView.svelte`: title/description →
перевести «Configure output» и «This tool has no schema yet.». `toolTitle()`/`toolDescription()`, перевести `TOOL SETTINGS`, «Configure
- `SchemaSourceTile.svelte`: убрать figcaption `SOURCE`, перевести «choose an output» и «This tool has no schema yet.».
- `SchemaSourceTile.svelte`: перевести figcaption `SOURCE`, «choose an
image»/«no source…» и alt. image»/«no source…» и alt.
- `SchemaResultTile.svelte`: убрать figcaption `RESULT`, перевести «no result - `SchemaResultTile.svelte`: перевести figcaption `RESULT`, «no result yet» и
yet»/«parts» и alt. alt; суффикс «parts» — в `PreviewTile`.
- `SchemaPreview.svelte`: убрать панельные - `SchemaPreview.svelte`: перевести панельный лейбл `PREVIEW PANEL` и
`SOURCE / RESULT`/`GENERATOR / RESULT`, перевести meta-капшены (`FORMAT` и meta-капшены `SOURCE / RESULT / FORMAT`; значения «parts»/«text» → `t()`.
т.п.).
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result. - `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)` - `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)`
`fieldLabel(field)`. `fieldLabel(field)`.
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text. - `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
- `SchemaTextResult.svelte`: убрать лейбл `RESULT` у вердикта, перевести Copy / - `SchemaTextResult.svelte`: перевести лейбл `RESULT` у вердикта, Copy /
Download .txt; tone detection → `verdictTone()`. Download .txt; tone detection → `verdictTone()`.
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` + - `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
`t("categories.X")`, поисковый placeholder. `t("categories.X")`, поисковый placeholder.
- `CatalogHeader.svelte`: убрать eyebrow, перевести heading/lead и счётчик - `CatalogHeader.svelte`: перевести heading/lead и счётчик «TOOLS AVAILABLE».
«TOOLS AVAILABLE».
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS». - `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`. - `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
- Schema field controls (по одному или группой): Width/Height, From/To, Backing - Schema field controls (по одному или группой): Width/Height, From/To, Backing
@@ -262,17 +259,59 @@
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см. `scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
решение D). Последний шаг, аккуратный отдельный коммит. решение D). Последний шаг, аккуратный отдельный коммит.
### Фаза 8: Линтер словарей (i18n key linter)
Замена ручной проверки ключей на автоматическую. Инфраструктура уже есть:
`eslint-plugins/` умеет читать соседние файлы (`isolation/no-mixed-imports`) и
гоняться фикстурами (`test:rules`).
- **8.1** Новое правило `i18n/dict-consistency` в `web/eslint-plugins/`.
Применяется к файлам-словарям (конвенция: экспортируют `Dict`, лежат в
`lib/i18n/`); новые локали подхватываются автоматически — правка правила не
нужна.
- **8.2** Проверки — **только `warn`, никогда `error`** (пропущенный перевод не
валит сборку):
- **Паритет дерева ключей**: каждый ключ (dot-path `a.b.c`) присутствует во
всех остальных локалях, не только против BASE. Нет —
`missing key "x.y" in en`.
- **Пустые значения**: `""` и строка из одних пробелов —
`empty value "x.y" in en`.
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
всех локалях — поймать потерянную при переводе переменную.
- **8.3** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только
результаты-помощники, остальной английский — из реестра), поэтому после
включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции +
`tools` полностью, source — registry-English, без дрейфа). До достижения цели
допускается allowlist-механизм в ядре правила для осознанных отклонений.
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
### Фаза 9: Множественные числа (plural-формы)
- **9.1** Тип значения словаря расширяем: `string | PluralForms`, где
`PluralForms = { other: string } & Partial<{ one, few, many }>` по
CLDR-правилам локали (ru: обязательны `one/few/many`; `other` — всегда, как
фолбэк). Новая функция `tPlural(path, n)` в `lib/i18n/`: выбирает категорию
через `Intl.PluralRules(locale).select(n)` и подставляет её с интерполяцией
`{n}`.
- **9.2** Первые потребители: «часть/части/частей» (split-into-parts-png),
счётчики инструментов (`catalog.toolsCount`), «N параметров», «N файлов».
- **9.3** Паритет в линтере (Фаза 8): если ключ — plural-объект в одной локали,
он обязан быть plural-объектом во всех остальных (тип-паритет; string в одной
из локалей — warn). Набор форм между локалями не сравнивается дословно — он
проверяется по правилам конкретной локали (`other` обязателен; en: one/other,
ru: one/few/many).
- **9.4** Открытый вопрос на этапе реализации: неоднозначность «объект-секция»
(`Record<string, string>`) vs plural-объект в дереве словаря. Варианты:
отдельная секция `plurals` в словаре, конвенция имён ключей, вывод типа из
контекста. Фаза начинается с проработки этого вопроса, не с кода.
## 5. Решения ## 5. Решения
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме - **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря (`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` = (`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
`t(title)` с фолбэком на сырой ключ. `t(title)` с фолбэком на сырой ключ.
- **B. Mono-лейблы**: декоративные лейблы без полезной информации **удаляем**
(eyebrow'ы, статусы, панельные капшены, дублирующие подписи секций) — они
занимают место. Оставляем только там, где реально нужно, и локализуем:
счётчики инструментов, категории каталога, подписи данных (FORMAT, parts, px,
«ZIP (PNG)»), alt-тексты, содержательные headings.
- **C. Лейблы полей**: в схеме задаём i18n-ключ - **C. Лейблы полей**: в схеме задаём i18n-ключ
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод — (`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря, `fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
-42
View File
@@ -1,42 +0,0 @@
# refs-html — статические снимки нового дизайна
Отформатированный HTML, вытащенный из Next-референса `refs/`, чтобы смотреть
макеты как референс по разметке и классам: открыл файл в браузере — увидел
дизайн.
## Файлы
| Файл | Маршрут в нашем приложении (audit) | Что показывает |
| ------------------------- | -------------------------------------- | --------------------------------------------------- |
| `demo.html` | `/preview/demo` | Пайплайн-воркспейс + панель превью |
| `list-tools.html` | `/preview/list-tools` | Каталог инструментов (группы, поиск, фильтры) |
| `gradient.html` | `/preview/tools/linear-gradient-png` | Инструмент «Градиент»: настройки + превью + CSS |
| `background-remover.html` | `/preview/tools/remove-background-png` | Инструмент «Удаление фона»: сравнение source/result |
> `index.html` не используется: в Next-рефе это зеркало `demo` (главная == `/easy-png-tools/demo`),
> а в нашем приложении `/preview` — своя страница (воркспейс). Файл не генерируется
> экстрактором (`scripts/extract-static.mjs` пропускает `index.html`) и исключён
> из аудита (`EXCLUDE` в `web/scripts/audit.mjs`).
## Особенности
- **Это статика без JS**: все `<script>` удалены, интерактивности нет.
Тумблеры/кнопки/слайдеры не кликаются — это витрина состояний по умолчанию.
- Разметка отформатирована (отступы), CSS темы заинлайнен и причёсан —
файлы автономны, работают с `file://`.
- Тема по умолчанию: `gradient.html` и `background-remover.html` отрисованы
в тёмной (так в коде рефа), `index.html`/`demo.html` — в светлой. Чтобы
посмотреть светлый вариант тёмных страниц — убрать класс `dark-mode` у
`.app-shell`; для тёмной главной — добавить его же.
- Источник правды по пикселям — код в `refs/`; здесь только витрина.
## Как перегенерировать
```powershell
cd refs
pnpm install # если ещё не ставили
node node_modules\next\dist\bin\next build # output: 'export' включён в next.config.mjs
node extract-static.mjs .next\server\app ..\refs-html # форматирование, инлайн-CSS, без скриптов
```
Скрипт: `refs/extract-static.mjs`.
File diff suppressed because it is too large Load Diff
-2910
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
# v0 sandbox internal files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
.snowflake/
.v0-trash/
.vercel/
# Environment variables
.env*.local
# Common ignores
node_modules
.next/
.DS_Store
@@ -1,29 +0,0 @@
'use client'
import TopBar from '@/components/top-bar'
import { useMemo, useState } from 'react'
import { Download, RotateCcw, Upload, Check } from 'lucide-react'
export default function BackgroundRemoverPage() {
const [color, setColor] = useState('#E8EEF2')
const [similarity, setSimilarity] = useState(72)
const [outerOnly, setOuterOnly] = useState(true)
const [showMask, setShowMask] = useState(false)
const [downloaded, setDownloaded] = useState(false)
const previewBackground = showMask ? 'repeating-conic-gradient(#7b8791 0 25%, #cbd3da 0 50%) 50% / 28px 28px' : 'repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px'
const subjectStyle = useMemo(() => ({ background: showMask ? '#596773' : 'linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)', opacity: showMask ? .88 : 1, clipPath: `polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)` }), [showMask])
const reset = () => { setColor('#E8EEF2'); setSimilarity(72); setOuterOnly(true); setShowMask(false) }
return <main className="app-shell">
<TopBar section="BACKGROUND REMOVER" status="AUTO PROCESSING" />
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Remove background.</h1><p className="lede">Select a background color and tune the edge detection. Changes are processed automatically in your browser.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
<div className="remover-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">REMOVER SETTINGS</span><strong>Configure detection</strong></div><span className="step-type">TOOL 02</span></div>
<div className="setting-group"><label>BACKGROUND COLOR</label><div className="color-field"><span className="swatch" style={{ background: color }} /><input value={color} onChange={e => setColor(e.target.value)} /><input className="native-color" type="color" value={color} onChange={e => setColor(e.target.value)} aria-label="Choose background color" /></div><div className="color-reference"><span style={{ background: color }} /> sampled from image background</div></div>
<div className="setting-group"><label>COLOR SIMILARITY <output>{similarity}%</output></label><input type="range" min="0" max="100" value={similarity} onChange={e => setSimilarity(Number(e.target.value))} /><div className="range-hints"><span>strict edges</span><span>more removal</span></div></div>
<div className="setting-group toggle-group"><label><span>OUTER COLOR ONLY</span><input type="checkbox" checked={outerOnly} onChange={e => setOuterOnly(e.target.checked)} /><b className="toggle" /></label><p>Only remove connected background pixels from the edges.</p></div>
<div className="setting-group toggle-group"><label><span>SHOW MASK</span><input type="checkbox" checked={showMask} onChange={e => setShowMask(e.target.checked)} /><b className="toggle" /></label><p>Preview the detected transparency mask.</p></div>
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
</section>
<section className="remover-preview"><div className="preview-toolbar"><div><span className="label">SOURCE / RESULT</span><strong>comparison.png</strong></div><div className="preview-actions"><span className="processed"><Check size={14} /> processed</span><button className="download-btn" onClick={() => setDownloaded(true)}><Download size={15} /> {downloaded ? 'Downloaded' : 'Download result'}</button></div></div><div className="comparison-grid"><div className="image-card"><div className="image-label"><span>SOURCE</span><b>original.png</b></div><div className="remover-canvas source-canvas"><div className="subject subject-source" style={subjectStyle}><span>OBJECT</span></div><span className="canvas-size">1200 × 800</span></div></div><div className="image-card"><div className="image-label"><span>RESULT</span><b>{showMask ? 'mask-preview.png' : 'removed-bg.png'}</b></div><div className="remover-canvas" style={{ background: previewBackground }}><div className="subject" style={subjectStyle}><span>{showMask ? 'MASK' : 'PNG'}</span></div><span className="canvas-size">1200 × 800</span></div></div></div><div className="result-meta"><span>FORMAT <b>PNG-24</b></span><span>ALPHA <b>{showMask ? 'MASK' : 'ENABLED'}</b></span><span>SIMILARITY <b>{similarity}%</b></span></div></section></div>
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>background remover · local-only</span><span>© 2024</span></footer>
</main>
}
-1
View File
@@ -1 +0,0 @@
export { default } from '../../page'
-33
View File
@@ -1,33 +0,0 @@
'use client'
import TopBar from '@/components/top-bar'
import { useMemo, useState } from 'react'
import { Check, ChevronDown, Copy, Download, RotateCcw } from 'lucide-react'
const directions = ['0° →', '45° ↗', '90° ↑', '135° ↖', '180° ←', '225° ↙', '270° ↓', '315° ↘']
export default function GradientPage() {
const [start, setStart] = useState('#1769D2')
const [end, setEnd] = useState('#00A8C7')
const [direction, setDirection] = useState(135)
const [opacity, setOpacity] = useState(100)
const [type, setType] = useState<'linear' | 'radial'>('linear')
const [copied, setCopied] = useState(false)
const gradient = useMemo(() => type === 'linear' ? `linear-gradient(${direction}deg, ${start} 0%, ${end} 100%)` : `radial-gradient(circle, ${start} 0%, ${end} 100%)`, [direction, end, start, type])
const css = `background: ${gradient};\nopacity: ${opacity / 100};`
const reset = () => { setStart('#1769D2'); setEnd('#00A8C7'); setDirection(135); setOpacity(100); setType('linear') }
const copyCss = async () => { await navigator.clipboard?.writeText(css); setCopied(true); setTimeout(() => setCopied(false), 1400) }
return <main className="app-shell">
<TopBar section="GRADIENT" status="LIVE PREVIEW" />
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Gradient background.</h1><p className="lede">Create a clean, export-ready gradient with precise control over color, direction and transparency.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
<div className="gradient-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">GRADIENT SETTINGS</span><strong>Configure output</strong></div><span className="step-type">TOOL 01</span></div>
<div className="setting-group"><label>GRADIENT TYPE</label><div className="segmented wide"><button className={type === 'linear' ? 'selected' : ''} onClick={() => setType('linear')}>Linear</button><button className={type === 'radial' ? 'selected' : ''} onClick={() => setType('radial')}>Radial</button></div></div>
<div className="setting-group"><label>COLOR STOPS</label><div className="color-row"><div className="color-field"><span className="swatch" style={{ background: start }} /><input value={start} onChange={e => setStart(e.target.value)} /></div><span className="stop-arrow"></span><div className="color-field"><span className="swatch" style={{ background: end }} /><input value={end} onChange={e => setEnd(e.target.value)} /></div></div><div className="gradient-bar" style={{ background: gradient }} /></div>
<div className="setting-group"><label>DIRECTION <output>{direction}°</output></label><input type="range" min="0" max="360" value={direction} onChange={e => setDirection(Number(e.target.value))} /><div className="direction-grid">{directions.map((item, i) => <button key={item} className={direction === i * 45 ? 'selected' : ''} onClick={() => setDirection(i * 45)}>{item}</button>)}</div></div>
<div className="setting-group"><label>OPACITY <output>{opacity}%</output></label><input type="range" min="0" max="100" value={opacity} onChange={e => setOpacity(Number(e.target.value))} /></div>
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
</section>
<section className="gradient-preview"><div className="preview-toolbar"><div><span className="label">OUTPUT PREVIEW</span><strong>gradient.png</strong></div><div className="preview-actions"><button className="secondary-btn" onClick={copyCss}>{copied ? <Check size={15} /> : <Copy size={15} />} {copied ? 'Copied' : 'Copy CSS'}</button><button className="download-btn"><Download size={15} /> Download PNG <ChevronDown size={14} /></button></div></div><div className="large-canvas"><div className="gradient-art" style={{ background: gradient, opacity: opacity / 100 }}><div className="art-mark">PNG</div><span>easy-png-tools</span></div></div><div className="code-block"><div><span className="label">GENERATED CSS</span><button className="icon-btn" onClick={copyCss} aria-label="Copy CSS"><Copy size={14} /></button></div><pre>{css}</pre></div></section></div>
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>gradient tool · local-only</span><span>© 2024</span></footer>
</main>
}
@@ -1,28 +0,0 @@
"use client"
import TopBar from '@/components/top-bar'
import Link from "next/link"
import { useMemo, useState } from "react"
import { ArrowUpRight, FileImage, Filter, Search, Sparkles } from "lucide-react"
const groups = [
{ name: "CONVERT", tools: [["Convert JPG to PNG", "Re-encode JPEG files as lossless PNG while preserving transparency.", "/easy-png-tools/gradient"], ["Convert WebP to PNG", "Turn WebP images into a universal PNG format for any workflow."], ["PNG to Base64", "Encode an image as a base64 string for embedding in code or styles."], ["PNG to Data URI", "Build a complete data URI ready for HTML and CSS."], ["Convert PNG to JPG", "Composite transparency over a selected backdrop and export JPEG."]] },
{ name: "TRANSPARENCY", tools: [["Remove background PNG", "Remove a solid background by color, tolerance, or edge-connected regions.", "/easy-png-tools/background-remover"], ["Extract alpha mask", "Turn the alpha channel into a clean black-and-white mask."], ["Round corners PNG", "Clip the image corners by a precise radius percentage."], ["Outline PNG", "Add a colored ring around opaque content with adjustable thickness."], ["Change PNG opacity", "Multiply the alpha channel while keeping the original colors unchanged."]] },
{ name: "COLOR", tools: [["Create gradient PNG", "Generate a smooth transition between two colors with direction controls.", "/easy-png-tools/gradient"], ["Grayscale PNG", "Convert the image to luminance-based shades of gray."], ["Invert colors PNG", "Invert every color channel while leaving alpha untouched."], ["Brightness & contrast", "Adjust brightness and contrast across a controlled range."], ["Temperature PNG", "Make an image warmer or cooler with a single precise control."]] },
{ name: "GEOMETRY", tools: [["Resize PNG", "Scale an image with bilinear interpolation and optional aspect lock."], ["Crop PNG", "Cut a rectangular area with exact coordinates and dimensions."], ["Rotate PNG", "Rotate by 90, 180, or 270 degrees without quality loss."], ["Flip PNG", "Mirror the image horizontally or vertically."], ["Add padding to PNG", "Expand the canvas on all sides by a chosen number of pixels."]] },
{ name: "FILTERS", tools: [["Blur PNG", "Apply a fast Gaussian-style blur with transparent edge handling."], ["Sharpen PNG", "Emphasize edges with an adjustable sharpening kernel."], ["Vignette PNG", "Smoothly darken the image edges while preserving the center."], ["JPEG artifacts", "Simulate low-quality JPEG recompression for testing."]] },
{ name: "ANALYZE", tools: [["PNG info", "Inspect dimensions, alpha presence, and unique color count."], ["Check grayscale", "Report whether the image contains only shades of gray."], ["Check transparency", "Detect transparent and semi-transparent pixels."], ["PNG orientation", "Classify the image as portrait, landscape, or square."]] },
]
export default function ListToolsPage() {
const [query, setQuery] = useState("")
const [active, setActive] = useState("ALL")
const filtered = useMemo(() => groups.map(group => ({ ...group, tools: group.tools.filter(([name, description]) => (active === "ALL" || active === group.name) && `${name} ${description}`.toLowerCase().includes(query.toLowerCase())) })).filter(group => group.tools.length), [query, active])
return <main className="app-shell"><TopBar section="CATALOG" status="LOCAL MODE / READY" /><div className="catalog-page">
<div className="catalog-head"><div><div className="eyebrow">EASY-PNG-TOOLS / CATALOG</div><h1>Tool catalog</h1><p>Focused utilities for working with PNG. Inspect, transform, and export locally in your browser.</p></div><div className="catalog-total"><b>32</b><span>TOOLS<br />AVAILABLE</span></div></div>
<div className="catalog-toolbar"><label className="catalog-search"><Search size={16} /><input aria-label="Search tools" placeholder="Search tools..." value={query} onChange={e => setQuery(e.target.value)} /></label><div className="catalog-filters"><Filter size={15} />{["ALL", ...groups.map(g => g.name)].map(name => <button key={name} className={active === name ? "active" : ""} onClick={() => setActive(name)}>{name}</button>)}</div></div>
<div className="catalog-groups">{filtered.map(group => <section className="catalog-group" key={group.name}><div className="group-title"><span>{group.name}</span><i>{String(group.tools.length).padStart(2, "0")} TOOLS</i></div><div className="tool-cards">{group.tools.map(([name, description, href], index) => <Link className="tool-card" href={href || "#"} key={name} onClick={e => { if (!href) e.preventDefault() }}><span className="tool-icon"><FileImage size={19} /></span><span className="tool-copy"><strong>{name}</strong><span>{description}</span></span><span className="tool-index">{String(index + 1).padStart(2, "0")}</span><ArrowUpRight size={16} className="tool-arrow" /></Link>)}</div></section>)}</div>
{!filtered.length && <div className="catalog-empty"><Sparkles size={18} /> No tools match your search.</div>}
<footer className="catalog-footer">ALL OPERATIONS RUN LOCALLY <span></span> YOUR FILES NEVER LEAVE THIS DEVICE</footer>
</div></main>
}
File diff suppressed because one or more lines are too long
-18
View File
@@ -1,18 +0,0 @@
import { Analytics } from '@vercel/analytics/next'
import type { Metadata, Viewport } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'easy-png-tools / Demo',
description: 'Technical workspace for building PNG processing pipelines.',
generator: 'easy-png-tools',
}
export const viewport: Viewport = {
colorScheme: 'light',
themeColor: '#eef1f4',
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="ru" className="bg-background"><body className="antialiased">{children}{process.env.NODE_ENV === 'production' && <Analytics />}</body></html>
}
-40
View File
@@ -1,40 +0,0 @@
'use client'
import TopBar from '@/components/top-bar'
import { useMemo, useState } from 'react'
import { ArrowDownToLine, Check, ChevronDown, Download, GripVertical, Link2, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Upload, X } from 'lucide-react'
const initialSteps = [
{ id: 1, title: 'Gradient background', type: 'BACKGROUND' },
{ id: 2, title: 'Remove background', type: 'TRANSFORM' },
{ id: 3, title: 'Add outline', type: 'STYLE' },
{ id: 4, title: 'Round corners', type: 'STYLE' },
]
function PreviewTile({ label, sublabel, style, active = false }: { label: string; sublabel: string; style: React.CSSProperties; active?: boolean }) {
return <div className={`preview-tile ${active ? 'active' : ''}`}><div className="tile-canvas"><div className="image-preview" style={style}><span className="sample-icon">PNG</span><span>easy-png-tools</span></div></div><div className="tile-label"><span>{label}</span><b>{sublabel}</b></div></div>
}
export default function Page() {
const [steps, setSteps] = useState(initialSteps)
const [angle, setAngle] = useState(135)
const [radius, setRadius] = useState(18)
const [outline, setOutline] = useState(2)
const [language, setLanguage] = useState('RU')
const [showIntermediate, setShowIntermediate] = useState(true)
const [gradient, setGradient] = useState('#DCEBFF')
const gradientStyle = useMemo(() => ({ background: `linear-gradient(${angle}deg, ${gradient}, #8BC8F5)` }), [angle, gradient])
const finalStyle = { ...gradientStyle, borderRadius: radius, boxShadow: `0 0 0 ${outline}px #16202B` }
return <main className="app-shell">
<TopBar section="WORKSPACE" status="AUTO PIPELINE" />
<div className="page-grid">
<section className="workspace"><div className="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div><div className="title-row"><div><h1>Build your image pipeline.</h1><p className="lede">Chain simple tools together. Every change is processed automatically and previewed at each stage.</p></div><div className="file-chip"><Upload size={15} /><span>source.png</span><b>1.8 MB</b></div></div>
<div className="pipeline-head"><div><span className="label">PROCESSING PIPELINE</span><strong>{steps.length} active steps <em> LIVE</em></strong></div><button className="add-btn" onClick={() => setSteps([...steps, { id: Date.now(), title: 'New adjustment', type: 'STYLE' }])}><Plus size={15} /> Add tool</button></div>
<div className="steps-list">{steps.map((step, index) => <article className="step-card" key={step.id}><div className="step-index">{String(index + 1).padStart(2, '0')}</div><GripVertical className="drag" size={16} /><div className="step-body"><div className="step-heading"><div><span className="step-type">{step.type}</span><h2>{step.title}</h2></div><div className="step-tools"><span className="check"><Check size={12} /> AUTO</span><button aria-label="Remove step" onClick={() => setSteps(steps.filter(item => item.id !== step.id))}><X size={16} /></button><MoreHorizontal size={17} /></div></div>{index === 0 ? <div className="controls"><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch" style={{ background: gradient }} /><input value={gradient} onChange={e => setGradient(e.target.value)} aria-label="Gradient color" /><ChevronDown size={14} /></div></div><div className="control-block"><label>DIRECTION <output>{angle}°</output></label><input type="range" min="0" max="360" value={angle} onChange={e => setAngle(Number(e.target.value))} /></div><div className="control-block"><label>OPACITY <output>100%</output></label><div className="segmented"><button className="selected">100</button><button>75</button><button>50</button><button>25</button></div></div></div> : index === 2 ? <div className="controls compact"><div className="control-block"><label>WIDTH <output>{outline}px</output></label><input type="range" min="0" max="8" value={outline} onChange={e => setOutline(Number(e.target.value))} /></div><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch dark" /><input value="#16202B" readOnly /></div></div></div> : index === 3 ? <div className="controls compact"><div className="control-block"><label>RADIUS <output>{radius}px</output></label><input type="range" min="0" max="48" value={radius} onChange={e => setRadius(Number(e.target.value))} /></div><div className="toggle-row"><span>Preserve aspect ratio</span><button className="toggle on" aria-label="Preserve aspect ratio"><i /></button></div></div> : <div className="transform-note"><SlidersHorizontal size={15} /> Automatic subject detection enabled</div>}</div></article>)}</div>
<div className="pipeline-footer"><button className="reset-btn" onClick={() => setSteps(initialSteps)}><RotateCcw size={14} /> Reset pipeline</button><span className="auto-note"><i /> changes are applied automatically</span></div>
</section>
<section className="preview-panel"><div className="preview-top"><div><span className="label">PIPELINE OUTPUTS</span><strong>Visual history</strong></div><div className="preview-actions"><button className="history-toggle top-toggle" onClick={() => setShowIntermediate(!showIntermediate)} aria-expanded={showIntermediate}>{showIntermediate ? 'Hide intermediate' : 'Show intermediate'} <ChevronDown size={15} className={showIntermediate ? 'rotated' : ''} /></button><button className="download-btn"><Download size={16} /> Download result <ArrowDownToLine size={14} /></button><div className="preview-meta"><div><span>DIMENSIONS</span><b>1200 × 800 px</b></div><div><span>FORMAT</span><b>PNG-24</b></div><div><span>SIZE</span><b>1.2 MB</b></div></div><button className="icon-btn" aria-label="Preview settings"><Settings2 size={17} /></button></div></div><div className="preview-stack"><PreviewTile label="SOURCE" sublabel="original.png · 1200 × 800" style={{ background: '#8d9aa5' }} />{showIntermediate && <><PreviewTile label="STEP 01" sublabel="gradient applied" style={gradientStyle} /><PreviewTile label="STEP 02" sublabel="background removed" style={{ ...gradientStyle, clipPath: 'inset(10% 8% 10% 8% round 12px)' }} /><PreviewTile label="STEP 03" sublabel="outline added" style={{ ...gradientStyle, boxShadow: `0 0 0 ${outline}px #16202B` }} /></>}<PreviewTile label="FINAL OUTPUT" sublabel="ready · PNG-24" style={finalStyle} active /></div><p className="preview-note">Output is generated in-browser. Your files never leave this device.</p></section>
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span><Link2 size={13} /> pipeline is local-only</span><span>© 2024</span></footer>
</main>
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
-36
View File
@@ -1,36 +0,0 @@
"use client"
import Link from "next/link"
import { CircleHelp, Moon, Sun } from "lucide-react"
import { usePathname } from "next/navigation"
import { useEffect, useState } from "react"
const links = [
["Workspace", "/"],
["Catalog", "/easy-png-tools/list-tools"],
["Gradient", "/easy-png-tools/gradient"],
["Background remover", "/easy-png-tools/background-remover"],
] as const
export function TopBar({ section = "WORKSPACE", status = "LOCAL MODE / READY" }: { section?: string; status?: string }) {
const pathname = usePathname()
const [dark, setDark] = useState(false)
useEffect(() => {
const stored = window.localStorage.getItem("ep-theme")
const isDark = stored === "dark"
setDark(isDark)
document.documentElement.classList.toggle("dark-mode", isDark)
}, [])
const toggleTheme = () => {
const next = !dark
setDark(next)
document.documentElement.classList.toggle("dark-mode", next)
window.localStorage.setItem("ep-theme", next ? "dark" : "light")
}
return <header className="topbar"><Link href="/" className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ {section}</span></Link><nav className="top-nav" aria-label="Primary navigation">{links.map(([label, href]) => <Link key={href} className={pathname === href ? "active" : ""} href={href}>{label}</Link>)}</nav><div className="top-actions"><span className="status"><i /> {status}</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={toggleTheme}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className="active">RU</button><span>/</span><button>EN</button></div></div></header>
}
export default TopBar
-58
View File
@@ -1,58 +0,0 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
-7
View File
@@ -1,7 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-11
View File
@@ -1,11 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig
-37
View File
@@ -1,37 +0,0 @@
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@vercel/analytics": "1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"next": "16.3.0",
"react": "^19",
"react-dom": "^19",
"shadcn": "^4.8.0",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.3.3",
"typescript": "5.7.3"
},
"pnpm": {
"overrides": {
"hono": "4.12.25"
}
}
}
-3928
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
allowBuilds:
msw: set this to true or false
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-33
View File
@@ -1,33 +0,0 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
-252
View File
@@ -1,252 +0,0 @@
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, dirname, basename, extname } from "node:path";
import { fileURLToPath } from "node:url";
// Usage: node extract-static.mjs <source-out-dir> <dest-dir>
// e.g. node extract-static.mjs .next\server\app ..\refs-html
const root = process.argv[2];
const dest = process.argv[3];
// --- inject a minimal theme toggle (extraction strips all <script> tags, so the
// ref pages would otherwise have no working dark-mode switch) ---
const THEME_TOGGLE = `
<script>
(function () {
var btn = document.querySelector('button[aria-label="Toggle theme"]');
var shell = document.querySelector('.app-shell');
if (!btn || window.__themeToggleInjected) return;
window.__themeToggleInjected = true;
btn.addEventListener('click', function () {
document.documentElement.classList.toggle('dark-mode');
if (shell) shell.classList.toggle('dark-mode');
});
})();
</script>
`;
function injectThemeToggle(html) {
if (html.includes("__themeToggleInjected")) return html;
const tag = "</body>";
if (html.includes(tag)) return html.replace(tag, THEME_TOGGLE + "\n" + tag);
if (html.includes("</html>")) return html.replace("</html>", THEME_TOGGLE + "\n</html>");
return html + "\n" + THEME_TOGGLE;
}
// --- CSS: find the compiled chunk and pretty-print it ---
const cssChunks = join(root, "..", "..", "static", "chunks");
// --- fonts: inline @font-face + copy .woff2 next to the pages ---
// The Next reference never actually loads its `--font-sans/--font-mono`
// ("IBM Plex Sans"/"IBM Plex Mono"), so the extracted snapshots render with
// the system fallback. Self-host the fontsource in a `assets/fonts/` dir next
// to the HTML so the refs show the intended typefaces.
const FONT_ASSETS_DIR = "assets/fonts";
// fontsource packages relative to `web/` — same weights as design2.css imports.
const FONT_SOURCES = [
"ibm-plex-sans/400.css",
"ibm-plex-sans/500.css",
"ibm-plex-sans/600.css",
"ibm-plex-sans/700.css",
"ibm-plex-mono/400.css",
"ibm-plex-mono/500.css",
];
// Resolve `node_modules/@fontsource/<pkg>/<weight>.css`:
// 1. sibling repo dir (root `web/…` or standalone `refs/…` layouts);
// 2. upward walk from this script (monorepo with hoisted root deps).
function findFontsourceRoot() {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [join(here, "..", "web", "node_modules", "@fontsource")];
for (let dir = here; ; ) {
candidates.push(join(dir, "node_modules", "@fontsource"));
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const candidate of candidates) {
if (existsSync(join(candidate, FONT_SOURCES[0]))) return candidate;
}
return null;
}
function buildFontFaces(dest) {
const fontsource = findFontsourceRoot();
if (!fontsource) {
console.warn("WARN: @fontsource not found (web/node_modules missing?) — skipping font embedding");
return { style: "", copied: [] };
}
const seen = new Set();
const raw = [];
for (const rel of FONT_SOURCES) {
const pkg = dirname(rel); // e.g. ibm-plex-sans
const name = basename(rel, extname(rel)); // e.g. 400.css -> 400
const file = join(fontsource, pkg, `${name}.css`);
if (!existsSync(file)) continue;
raw.push([file, readFileSync(file, "utf8"), pkg]);
}
// Drop duplicate @font-face blocks (each `<name>.css` repeats across weights).
let css = raw.map(([, text]) => text.replace(/\/\*[^*]*\*\//g, "").trim()).join("\n");
css = css.replace(/@font-face\s*\{[\s\S]*?\}/g, (block) =>
seen.has(block) ? "" : (seen.add(block), block)
);
// Copy the woff2 files and point the css at `assets/fonts/`. Drop the woff
// entries — modern browsers only need woff2 (dev-only static snapshots).
const copied = [];
const destFonts = join(dest, FONT_ASSETS_DIR);
css = css.replace(/url\(\.\/files\/([^)]+\.woff2)\)\s*format\('woff2'\)\s*,\s*url\(\.\/files\/[^)]*\.woff\)\s*format\('woff'\)/g, (_m, f) => {
const src = (() => {
for (const [, , pkg] of raw) {
const candidate = join(fontsource, pkg, "files", f);
if (existsSync(candidate)) return candidate;
}
return null;
})();
if (!src) {
console.warn(`WARN: font file not found: ${f}`);
return "";
}
mkdirSync(destFonts, { recursive: true });
writeFileSync(join(destFonts, f), readFileSync(src));
if (!copied.includes(f)) copied.push(f);
return `url(${FONT_ASSETS_DIR}/${f}) format('woff2')`;
});
return { style: "<style>\n" + css.trim() + "\n</style>", copied };
}
const cssFile = readdirSync(cssChunks)
.filter((f) => f.endsWith(".css"))
.sort((a, b) => statSync(join(cssChunks, b)).size - statSync(join(cssChunks, a)).size)[0];
const css = readFileSync(join(cssChunks, cssFile), "utf8");
const prettyCss = css
.replace(/\r/g, "")
.replace(/\{/g, " {\n ")
.replace(/;/g, ";\n ")
.replace(/\}/g, "\n}\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
// --- conservative html formatter ---
const VOID = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const RAW = new Set(["pre", "textarea", "script", "style"]);
function fmtHtml(html) {
const tokens = html.match(/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi) || [];
const out = [];
let depth = 0;
let rawTag = null;
for (const tk of tokens) {
if (rawTag) {
out.push(tk);
if (new RegExp(`</${rawTag}\\s*>`, "i").test(tk)) {
depth--;
rawTag = null;
}
continue;
}
const isOpen = /^<[a-zA-Z]/.test(tk);
const isClose = /^<\//.test(tk);
let name = "";
if (isOpen || isClose) {
name = (tk.match(/^<\/?([a-zA-Z0-9-]+)/) || [])[1]?.toLowerCase() || "";
}
if (isOpen && RAW.has(name)) {
out.push("\n" + " ".repeat(depth) + tk);
if (!/\/>$/.test(tk)) {
depth++;
rawTag = name;
}
} else if (isOpen && !RAW.has(name)) {
out.push("\n" + " ".repeat(depth) + tk);
if (!/\/>$/.test(tk) && !VOID.has(name)) depth++;
} else if (isClose) {
depth = Math.max(0, depth - 1);
out.push("\n" + " ".repeat(depth) + tk);
} else if (/^</.test(tk)) {
out.push("\n" + " ".repeat(depth) + tk);
} else {
const t = tk.replace(/\s+/g, " ");
if (t.trim()) out.push(t);
}
}
return out
.join("")
.replace(/^\n/, "")
.replace(/\n{3,}/g, "\n\n");
}
// --- auto-discover pages by walking the export dir for *.html ---
function walk(dir, base = "") {
const found = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const rel = base ? join(base, entry) : entry;
if (statSync(full).isDirectory()) {
found.push(...walk(full, rel));
} else if (entry.endsWith(".html")) {
found.push(rel);
}
}
return found;
}
const pages = walk(root)
// skip Next internals
.filter((rel) => !basename(rel).startsWith("_") && !basename(rel).startsWith("404"))
// `index.html` ╨╜╨░╨╝ ╨╜╨╡ ╨╜╤Г╨╢╨╡╨╜ ╨║╨░╨║ ╤А╨╡╤Д, ╨┐╨╛╤Н╤В╨╛╨╝╤Г ╨╜╨╡ ╨│╨╡╨╜╨╡╤А╨╕╤А╤Г╨╡╨╝ ╨╡╨│╨╛.
.filter((rel) => basename(rel).toLowerCase() !== "index.html")
.map((rel) => [rel, basename(rel)]);
console.log(`discovered ${pages.length} page(s):`, pages.map((p) => p[1]).join(", "));
const { style: fontFacesStyle, copied: fontFiles } = buildFontFaces(dest);
if (fontFiles.length) {
console.log(`fonts: ${fontFiles.length} file(s) -> ${join(dest, FONT_ASSETS_DIR)}`);
}
for (const [src, out] of pages) {
let html = readFileSync(join(root, src), "utf8");
// strip scripts — pure static snapshot
html = html.replace(/<script[\s\S]*?<\/script>/g, "");
html = html.replace(/<link[^>]+rel="preload"[^>]+as="script"[^>]*>/g, "");
// inline compiled css instead of linking /_next/...
html = html.replace(/<link[^>]+rel="stylesheet"[^>]*>/g, () => "<style>\n" + prettyCss + "\n</style>");
// fonts first, so any later @font-face overrides win for the same family
if (fontFacesStyle) html = html.replace("<head>", "<head>\n " + fontFacesStyle.trim());
html = fmtHtml(html);
html = injectThemeToggle(html);
const file = join(dest, out);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, html);
console.log("written:", file);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
{
"generatedAt": "2026-08-29T04:04:14.643Z",
"reports": [
{
"route": "/preview/demo",
"ref": "demo.html",
"deltas": [],
"counts": {
"added": 0,
"removed": 0,
"tagMismatch": 0,
"textMismatch": 0,
"nodesAdded": 0,
"nodesRemoved": 0
}
},
{
"route": "/preview/list-tools",
"ref": "list-tools.html",
"deltas": [],
"counts": {
"added": 0,
"removed": 0,
"tagMismatch": 0,
"textMismatch": 0,
"nodesAdded": 0,
"nodesRemoved": 0
}
},
{
"route": "/preview/tools/linear-gradient-png",
"ref": "gradient.html",
"deltas": [],
"counts": {
"added": 0,
"removed": 0,
"tagMismatch": 0,
"textMismatch": 0,
"nodesAdded": 0,
"nodesRemoved": 0
}
},
{
"route": "/preview/tools/remove-background-png",
"ref": "background-remover.html",
"deltas": [],
"counts": {
"added": 0,
"removed": 0,
"tagMismatch": 0,
"textMismatch": 0,
"nodesAdded": 0,
"nodesRemoved": 0
}
}
]
}
-47
View File
@@ -1,47 +0,0 @@
# DOM audit (phase A)
_2026-08-29T04:04:14.645Z_
## Сводка
| route | ref | added | removed | tag | text |
|---|---|---|---|---|---|
| `/preview/demo` | demo.html | 0 | 0 | 0 | 0 |
| `/preview/list-tools` | list-tools.html | 0 | 0 | 0 | 0 |
| `/preview/tools/linear-gradient-png` | gradient.html | 0 | 0 | 0 | 0 |
| `/preview/tools/remove-background-png` | background-remover.html | 0 | 0 | 0 | 0 |
## /preview/demo (vs demo.html)
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
### Расхождения (топ 0)
_расхождений нет_
## /preview/list-tools (vs list-tools.html)
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
### Расхождения (топ 0)
_расхождений нет_
## /preview/tools/linear-gradient-png (vs gradient.html)
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
### Расхождения (топ 0)
_расхождений нет_
## /preview/tools/remove-background-png (vs background-remover.html)
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
### Расхождения (топ 0)
_расхождений нет_
+5 -4
View File
@@ -132,10 +132,11 @@ hct-авторство.
фактическому пути, поэтому относительным импортом правило не обойти. фактическому пути, поэтому относительным импортом правило не обойти.
- Старое: `routes/v1/**`, `lib/v1/**`. - Старое: `routes/v1/**`, `lib/v1/**`.
- Новое: `routes/**` без `v1/**`, `lib/components/**`, `lib/registry/**`, - Новое: `routes/**` без `v1/**`; корневой `lib` целиком (`lib/**`) без
`lib/catalog.ts`, `lib/categories.ts`, `lib/tool-icons.ts`, `lib/v1/**` и без общих пакетов.
`lib/registry-schema.ts`, `lib/registry-schema.test.ts`. - Общее (разрешено обоим) — только служебные пакеты: `core/`, `theme`. v1 не
- Общее (разрешено обоим): `core/`, `i18n/`, `theme`, `assets/`, корневой `lib`. высовывается: из `lib/v1/**` наружу разрешены импорты лишь в общие пакеты и в
собственный форк i18n (`lib/v1/i18n/**`).
- Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в - Плагин **конфигурируем** (опции `old`/`new` + `root`/`alias` в
`eslint.config.js`): перенос старых файлов в папки `old/` — это правка `eslint.config.js`): перенос старых файлов в папки `old/` — это правка
glob-паттернов в настройке, а не код правила. glob-паттернов в настройке, а не код правила.
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -7,8 +7,8 @@
// //
// Side map (mirrors the defaults in no-mixed-imports.js): // Side map (mirrors the defaults in no-mixed-imports.js):
// old -> routes/v1/**, lib/v1/** // old -> routes/v1/**, lib/v1/**
// new -> routes/** (minus v1), lib/components/**, lib/registry/** + a few // new -> routes/** (minus v1), lib/** (minus lib/v1/** and shared)
// shared-> everything else: core/, i18n/, theme, ... // shared-> everything else: core/, theme
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import noMixedImports from "../isolation/no-mixed-imports.js"; import noMixedImports from "../isolation/no-mixed-imports.js";
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js"; import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
@@ -80,15 +80,27 @@ describe("isolation/no-mixed-imports", () => {
expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";'); expectClean("lib/v1/old.ts", 'import { e } from "../core/errors";');
}); });
it("allows old -> shared (i18n and theme via $lib)", () => { it("flags old -> root i18n ($lib/i18n is the new branch now)", () => {
expectClean("routes/v1/+layout.svelte", 'import { t } from "$lib/i18n/t";'); expectViolation(
"routes/v1/+layout.svelte",
'import { t } from "$lib/i18n/t";',
"old",
"new",
);
});
it("allows old -> own i18n copy and theme via $lib", () => {
expectClean(
"routes/v1/+layout.svelte",
'import { t } from "$lib/v1/i18n/t";',
);
expectClean( expectClean(
"routes/v1/+layout.svelte", "routes/v1/+layout.svelte",
'import { g } from "$lib/theme.svelte";', 'import { g } from "$lib/theme.svelte";',
); );
}); });
it("allows new -> shared and new -> new", () => { it("allows new -> new (root i18n and components)", () => {
expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";'); expectClean("routes/+page.svelte", 'import { t } from "$lib/i18n/t";');
expectClean( expectClean(
"routes/+page.svelte", "routes/+page.svelte",
@@ -96,11 +108,26 @@ describe("isolation/no-mixed-imports", () => {
); );
}); });
it("leaves shared files unrestricted in both directions", () => { it("flags new i18n importing old and leaves core shared", () => {
expectClean("lib/i18n/t.ts", 'import { o } from "$lib/v1/old";'); expectViolation(
"lib/i18n/t.ts",
'import { o } from "$lib/v1/old";',
"new",
"old",
);
expectClean( expectClean(
"lib/core/errors.ts", "lib/core/errors.ts",
'import { c } from "$lib/components/CheckerCanvas.svelte";', 'import { c } from "$lib/components/CheckerCanvas.svelte";',
); );
}); });
it("keeps the old i18n copy on the old side", () => {
expectClean("lib/v1/i18n/t.ts", 'import { o } from "$lib/v1/old";');
expectViolation(
"lib/v1/i18n/t.ts",
'import { c } from "$lib/components/CheckerCanvas.svelte";',
"old",
"new",
);
});
}); });
@@ -5,7 +5,7 @@
* `$lib/...` alias and relative `./` / `../` paths), classifies the source * `$lib/...` alias and relative `./` / `../` paths), classifies the source
* file and each import target by their actual location, and reports whenever an * file and each import target by their actual location, and reports whenever an
* "old" file imports a "new" one or vice versa. Files that match neither side * "old" file imports a "new" one or vice versa. Files that match neither side
* are "shared" (core/, i18n/, theme, assets, tests, ...) and can import freely. * are "shared" (core/, theme, assets, tests, ...) and can import freely.
* *
* Options (object, all optional): * Options (object, all optional):
* - root: path from lint cwd to the source dir (default "src"); * - root: path from lint cwd to the source dir (default "src");
@@ -17,21 +17,19 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
// NEW declared as an allowlist: `lib/**` descends into v1/ and the shared dirs // NEW declared as an allowlist: `lib/**` descends into v1/ and the shared
// (core/, i18n/, assets/, theme), so matching by prefix would misclassify them. // packages (core/, theme), so negations carve them back out; `routes/**`
// Negations (`!`) carve old paths out of `routes/**`. // needs `!routes/v1/**` for the old branch. Last-match-wins
// (see matchesGlobList).
const DEFAULT_OLD = ["routes/v1/**", "lib/v1/**"]; const DEFAULT_OLD = ["routes/v1/**", "lib/v1/**"];
const DEFAULT_NEW = [ const DEFAULT_NEW = [
"routes/**", "routes/**",
"!routes/v1/**", "!routes/v1/**",
"lib/components/**", "lib/**",
"lib/registry/**", "!lib/v1/**",
"lib/catalog.ts", "!lib/core/**",
"lib/categories.ts", "!lib/theme.svelte.ts",
"lib/tool-icons.ts",
"lib/registry-schema.ts",
"lib/registry-schema.test.ts",
]; ];
/** Escape everything except glob metacharacters, then handle **, *, ?. */ /** Escape everything except glob metacharacters, then handle **, *, ?. */
+2 -1
View File
@@ -82,6 +82,7 @@ export default tseslint.config(
"eslint-plugins/__tests__/helpers.ts", "eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/no-mixed-imports.test.ts", "eslint-plugins/__tests__/no-mixed-imports.test.ts",
"eslint-plugins/__fixtures__/src/lib/v1/old.ts", "eslint-plugins/__fixtures__/src/lib/v1/old.ts",
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
"eslint-plugins/__fixtures__/src/lib/core/errors.ts", "eslint-plugins/__fixtures__/src/lib/core/errors.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/t.ts", "eslint-plugins/__fixtures__/src/lib/i18n/t.ts",
"eslint-plugins/__fixtures__/src/lib/theme.svelte.ts", "eslint-plugins/__fixtures__/src/lib/theme.svelte.ts",
@@ -197,7 +198,7 @@ export default tseslint.config(
// Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5). // Полная взаимная изоляция веток (см. plan-composite-params, Фаза 5).
// Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному // Кастомный плагин isolation/no-mixed-imports резолвит импорты по реальному
// пути (и $lib, и относительные) и ругается на old→new / new→old. // пути (и $lib, и относительные) и ругается на old→new / new→old.
// Общее (core/, i18n/, theme) разрешено обоим. // Общее (core/, theme) разрешено обоим.
{ {
files: ["**/*.{ts,svelte}"], files: ["**/*.{ts,svelte}"],
plugins: { plugins: {
@@ -1,30 +0,0 @@
// Runs audit-cdp.mjs across the commonly used desktop / tablet / phone
// resolutions. Same CLI options pass through, e.g.:
// node scripts/audit-cdp-responsive.mjs --route /preview/demo --viewport 390x844
import { spawnSync } from "node:child_process";
const VIEWPORTS = ["2560x1440", "1920x1080", "1440x900", "768x1024", "390x844"];
const args = [];
let viewportSeen = false;
for (let i = 0; i < process.argv.length; i++) {
const v = process.argv[i];
if (v === "--viewport") {
viewportSeen = true;
i++;
} else args.push(v);
}
if (!viewportSeen) args.push("--viewport", VIEWPORTS.join(","));
const r = spawnSync(
process.execPath,
["scripts/audit-cdp.mjs", ...args.slice(2)],
{
stdio: "inherit",
},
);
if (r.error) {
console.error(r.error.message);
process.exit(1);
}
process.exit(r.status ?? 1);
File diff suppressed because it is too large Load Diff
-523
View File
@@ -1,523 +0,0 @@
// Фаза B: CSS/визуальный аудит. Для каждого preview-маршрута снимает
// нормализованное дерево элементов с ВЫЧИСЛЕННЫМИ стилями (и геометрией)
// нашей страницы и соответствующего refs-html файла, выравнивает деревья
// по структуре (как в audit-dom — теги нормализованы, классы игнорируются,
// матчинг по ключу), и для совпавших узлов сравнивает стили + rect.
// Дополнительно сравнивает design-токены (CSS-переменные) light/dark.
// Запуск: pnpm refs-audit (опции: --route <path> --ref <file> --no-serve --ours <url>)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
const args = (() => {
const a = {};
for (let i = 0; i < process.argv.length; i++) {
const v = process.argv[i];
if (v === "--no-serve") a.noServe = true;
else if (v === "--ours") a.ours = process.argv[++i];
else if (v === "--ref") a.ref = process.argv[++i];
else if (v === "--route") a.route = process.argv[++i];
}
return a;
})();
const WEB = process.cwd();
const PORT = 5179;
const REFS_DIR = resolve(WEB, "../refs-html");
// Поля вычисленного стиля, которые сравниваем (визуально значимые).
const STYLE_FIELDS = [
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"lineHeight",
"letterSpacing",
"textAlign",
"textTransform",
"color",
"backgroundColor",
"display",
"flexDirection",
"alignItems",
"justifyContent",
"gap",
"gridTemplateColumns",
"padding",
"margin",
"borderRadius",
"borderTopWidth",
"borderTopColor",
"borderTopStyle",
"width",
"height",
"boxShadow",
"opacity",
];
// Динамические сегменты: явный список значений + сопоставление id → файл рефа.
const DYNAMIC = {
"tools/[id]": {
ids: ["linear-gradient-png", "remove-background-png"],
refFor: (id) =>
id === "linear-gradient-png"
? "gradient.html"
: id === "remove-background-png"
? "background-remover.html"
: null,
},
};
const EXCLUDE = new Set(["/preview"]);
const refNameFor = (route) => {
if (route === "/preview") return "index.html";
const last = route.split("/").filter(Boolean).pop();
return `${last}.html`;
};
function discoverRoutes() {
const base = resolve(WEB, "src/routes/preview");
const out = [];
const walk = (dir) => {
let ents;
try {
ents = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of ents) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name === "+page.svelte") {
const rel = p.slice(base.length).replace(/\\/g, "/");
const parts = rel.split("/").filter(Boolean);
parts.pop();
out.push(parts.length ? `/preview/${parts.join("/")}` : "/preview");
}
}
};
walk(base);
return out;
}
function refsList() {
try {
return readdirSync(REFS_DIR).filter((f) => f.endsWith(".html"));
} catch {
return [];
}
}
const refExists = (name) => refsList().includes(name);
function buildTargets() {
if (args.route) {
const ref = args.ref || refNameFor(args.route);
return [{ route: args.route, ref }];
}
const targets = [];
const seen = new Set();
const add = (route, ref) => {
if (!ref || seen.has(route)) return;
if (!refExists(ref)) {
console.warn(`[css-audit] ref not found, skipped: ${route} -> ${ref}`);
return;
}
seen.add(route);
targets.push({ route, ref });
};
for (const route of discoverRoutes()) {
if (route.includes("[")) continue;
if (EXCLUDE.has(route)) {
console.log(`[css-audit] excluded (no relevant ref): ${route}`);
continue;
}
add(route, refNameFor(route));
}
for (const [seg, cfg] of Object.entries(DYNAMIC)) {
for (const id of cfg.ids) {
add(`/preview/${seg.replace("[id]", id)}`, cfg.refFor(id));
}
}
const routed = new Set(targets.map((t) => t.ref));
for (const f of refsList()) {
if (!routed.has(f)) console.warn(`[css-audit] ref без маршрута: ${f}`);
}
return targets;
}
const waitFor = async (url, ms = 60000) => {
const t = Date.now();
while (Date.now() - t < ms) {
try {
const r = await fetch(url);
if (r.ok) return;
if (r.status >= 400) {
throw new Error(
`route ${url} returned HTTP ${r.status} (server is up, page failed to render)`,
);
}
} catch (e) {
if (e instanceof Error && e.message.includes("returned HTTP")) throw e;
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("dev server not up: " + url);
};
// Теги-обёртки нормализуем к "box", чтобы выравнивать по сути, а не по
// конкретному тегу (наш div/main vs рефовый div/main). Семантические — как есть.
const STRUCT_TAGS = new Set([
"div",
"main",
"section",
"article",
"aside",
"ul",
"li",
"form",
"fieldset",
]);
const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
const keyOf = (n) =>
n.children.length === 0 ? `${normTag(n.tag)}|${n.text}` : normTag(n.tag);
const snapshot = (page) =>
page.evaluate((styleFields) => {
const sig = (el) => {
const s = getComputedStyle(el);
const o = {};
for (const f of styleFields) o[f] = s[f];
return o;
};
const rectOf = (el) => {
const r = el.getBoundingClientRect();
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
};
};
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
const out = {};
for (const k of s)
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
return out;
};
const tokensLight = readTokens();
const prev = document.documentElement.getAttribute("data-theme");
document.documentElement.setAttribute("data-theme", "dark");
const tokensDark = readTokens();
if (prev) document.documentElement.setAttribute("data-theme", prev);
else document.documentElement.removeAttribute("data-theme");
const walk = (el) => {
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template"].includes(tag))
return null;
if (el.id === "svelte-announcer") return null;
if (el.hasAttribute("hidden")) return null;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return null;
let ownText = "";
for (const c of el.childNodes)
if (c.nodeType === 3) ownText += c.textContent;
ownText = ownText.replace(/\s+/g, " ").trim();
const node = {
tag,
text: ownText,
style: sig(el),
rect: rectOf(el),
children: [],
};
if (tag === "svg") return node;
for (const child of el.children) {
const cn = walk(child);
if (cn) node.children.push(cn);
}
return node;
};
return { tokensLight, tokensDark, tree: walk(document.body) };
}, STYLE_FIELDS);
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
function compareStyle(ours, ref, path, out) {
if (!ours || !ref) return;
if (ours.tag !== ref.tag) {
out.push({ path, type: "tagMismatch", ours: ours.tag, ref: ref.tag });
return;
}
if (ours.text && ref.text && ours.text !== ref.text) {
out.push({ path, type: "textMismatch", ours: ours.text, ref: ref.text });
}
const deltas = {};
for (const f of STYLE_FIELDS) {
const ov = (ours.style[f] ?? "").toString();
const rv = (ref.style[f] ?? "").toString();
if (ov !== rv) deltas[f] = { ours: ov, ref: rv };
}
const dr = ours.rect;
const rr = ref.rect;
if (
Math.abs(dr.x - rr.x) > 2 ||
Math.abs(dr.y - rr.y) > 2 ||
Math.abs(dr.w - rr.w) > 2 ||
Math.abs(dr.h - rr.h) > 2
)
deltas.rect = { ours: dr, ref: rr };
if (Object.keys(deltas).length)
out.push({ path, type: "style", tag: ours.tag, text: ours.text, deltas });
// Выравниваем детей по ключу (жадно), рекурсивно сравнивая стили.
const bByKey = new Map();
ref.children.forEach((c, idx) => {
const k = keyOf(c);
if (!bByKey.has(k)) bByKey.set(k, []);
bByKey.get(k).push(idx);
});
const used = new Set();
for (let i = 0; i < ours.children.length; i++) {
const aNode = ours.children[i];
const cands = bByKey.get(keyOf(aNode));
let bIdx = -1;
if (cands)
for (const ci of cands)
if (!used.has(ci)) {
bIdx = ci;
break;
}
if (bIdx >= 0) {
used.add(bIdx);
compareStyle(
aNode,
ref.children[bIdx],
`${path} > ${aNode.tag}:${i + 1}`,
out,
);
} else {
out.push({
path: `${path} > ${aNode.tag}:${i + 1}`,
type: "added",
tag: aNode.tag,
});
}
}
for (let j = 0; j < ref.children.length; j++) {
if (used.has(j)) continue;
out.push({
path: `${path} > ${ref.children[j].tag}:${j + 1}`,
type: "removed",
tag: ref.children[j].tag,
});
}
}
function tokDiff(a, b) {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
const rows = [];
for (const k of keys) {
if (TOK_NOISE.test(k)) continue;
const av = a[k] ?? "—";
const bv = b[k] ?? "—";
if (av !== bv) rows.push({ key: k, ours: av, ref: bv });
}
return rows;
}
async function auditOne(browser, target) {
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
await page.goto(oursUrl, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
// Рефы — только светлые; наше приложение по умолчанию может рендерить
// тёмную тему (app.html смотрит prefers-color-scheme). Нормализуем ОБЕ
// стороны к light, чтобы аудит мерил реальные расхождения дизайна, а не
// инверсию темы.
await page.evaluate(() => {
document.documentElement.dataset.theme = "light";
});
await page.waitForTimeout(500);
const ours = await snapshot(page);
await page.close();
// Реф грузим в ИЗОЛИРОВАННОМ контексте через goto(file://): переход
// http → file в одной странице уничтожает execution context и роняет прогон.
const refUrl = "file:///" + resolve(REFS_DIR, target.ref).replace(/\\/g, "/");
const ctx = await browser.newContext();
const refPage = await ctx.newPage();
await refPage.goto(refUrl, { waitUntil: "load" });
await refPage.evaluate(() => document.fonts.ready);
await refPage.evaluate(() => {
document.documentElement.dataset.theme = "light";
});
await refPage.waitForTimeout(400);
const ref = await snapshot(refPage);
await ctx.close();
const deltas = [];
compareStyle(ours.tree, ref.tree, "body", deltas);
const tokenDiffs = {
light: tokDiff(ours.tokensLight, ref.tokensLight),
dark: tokDiff(ours.tokensDark, ref.tokensDark),
};
return {
route: target.route,
ref: target.ref,
deltas,
tokenDiffs,
counts: tally(deltas, tokenDiffs),
};
}
function tally(deltas, tokenDiffs) {
const c = {
tagMismatch: 0,
textMismatch: 0,
added: 0,
removed: 0,
styleDiffs: 0,
rectDiffs: 0,
fieldDiffs: 0,
};
for (const d of deltas) {
if (d.type === "style") {
c.styleDiffs++;
c.fieldDiffs += Object.keys(d.deltas).length;
if (d.deltas.rect) c.rectDiffs++;
} else if (d.type in c) c[d.type]++;
}
return {
...c,
tokenLight: tokenDiffs.light.length,
tokenDark: tokenDiffs.dark.length,
};
}
function toMarkdown(reports) {
const tokTable = (rows) =>
rows.length
? `| token | ours | ref |\n|---|---|---|\n` +
rows.map((r) => `| \`${r.key}\` | ${r.ours} | ${r.ref} |`).join("\n")
: "_все совпадают_";
const elBlock = (el) => {
const d = Object.entries(el.deltas)
.map(([k, v]) => {
if (k === "rect") {
const f = (r) => `${r.x},${r.y} ${r.w}x${r.h}`;
return ` - **rect**: \`${f(v.ours)}\`\`${f(v.ref)}\``;
}
return ` - **${k}**: \`${v.ours}\`\`${v.ref}\``;
})
.join("\n");
return `### \`${el.path}\`${el.text ? ` — "${el.text}"` : ""}\n${d}`;
};
const summary =
`# CSS audit (phase B)\n\n_${new Date().toISOString()}_\n\n` +
`## Сводка\n\n| route | ref | tokL | tokD | style (el) | fields | rect | +struct | -struct | tag | text |\n|---|---|---|---|---|---|---|---|---|---|---|\n` +
reports
.map(
(r) =>
`| \`${r.route}\` | ${r.ref} | ${r.counts.tokenLight} | ${r.counts.tokenDark} | ${r.counts.styleDiffs} | ${r.counts.fieldDiffs} | ${r.counts.rectDiffs} | ${r.counts.added} | ${r.counts.removed} | ${r.counts.tagMismatch} | ${r.counts.textMismatch} |`,
)
.join("\n");
const sections = reports
.map((r) => {
const styleDiffs = r.deltas.filter((d) => d.type === "style");
const sorted = [...styleDiffs].sort(
(x, y) => Object.keys(y.deltas).length - Object.keys(x.deltas).length,
);
const els = sorted.length
? sorted.slice(0, 150).map(elBlock).join("\n\n")
: "_расхождений стилей нет_";
const struct = r.deltas
.filter((d) => d.type !== "style")
.map(
(d) => `- \`${d.path}\` — **${d.type}**${d.tag ? ` (${d.tag})` : ""}`,
)
.join("\n");
return (
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
`- Токены light: **${r.counts.tokenLight}**, dark: **${r.counts.tokenDark}**\n` +
`- Стиль-расхождений: **${r.counts.styleDiffs}** элементов / **${r.counts.fieldDiffs}** полей (из них геометрия: ${r.counts.rectDiffs})\n` +
`- Структурные: +${r.counts.added} / -${r.counts.removed} / tag ${r.counts.tagMismatch} / text ${r.counts.textMismatch}\n\n` +
`### Токены — Light\n\n${tokTable(r.tokenDiffs.light)}\n\n` +
`### Токены — Dark\n\n${tokTable(r.tokenDiffs.dark)}\n\n` +
`### Расхождения стилей (топ ${Math.min(150, sorted.length)})\n\n${els}\n\n` +
`### Структурные расхождения\n\n` +
(struct || "_—_")
);
})
.join("\n");
return summary + sections + "\n";
}
async function main() {
const targets = buildTargets();
if (!targets.length) {
console.error("no audit targets (refs-html/*.html missing?)");
process.exit(1);
}
console.log(`[css-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
let server;
if (!args.noServe) {
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
cwd: WEB,
stdio: "ignore",
shell: true,
detached: process.platform !== "win32",
});
await waitFor(args.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
}
const browser = await chromium.launch();
const reports = [];
try {
for (const target of targets) {
try {
const rep = await auditOne(browser, target);
reports.push(rep);
console.log(
`[css-audit] ${rep.route}: tokL=${rep.counts.tokenLight} tokD=${rep.counts.tokenDark} style=${rep.counts.styleDiffs} fields=${rep.counts.fieldDiffs} rect=${rep.counts.rectDiffs} +struct=${rep.counts.added} -struct=${rep.counts.removed}`,
);
} catch (e) {
console.error(`[css-audit] ${target.route} failed: ${e.message}`);
}
}
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(
resolve(WEB, "audit/audit-report.json"),
JSON.stringify(
{ generatedAt: new Date().toISOString(), reports },
null,
2,
),
);
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(reports));
console.log(`report: web/audit/audit-report.md`);
} finally {
await browser.close();
if (server) {
try {
if (process.platform === "win32")
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
stdio: "ignore",
});
else server.kill("SIGTERM");
} catch {}
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-415
View File
@@ -1,415 +0,0 @@
// Фаза A: структурный (DOM) аудит. Для каждого preview-маршрута снимает
// нормализованное дерево элементов нашей страницы и соответствующего refs-html
// файла, сравнивает по структурному пути (без IGNORE) и пишет отчёт о
// расхождениях (added / removed / tagMismatch / textMismatch).
// Запуск: pnpm refs-dom-audit (опции: --route <path> --ref <file> --no-serve --ours <url>)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
const args = (() => {
const a = {};
for (let i = 0; i < process.argv.length; i++) {
const v = process.argv[i];
if (v === "--no-serve") a.noServe = true;
else if (v === "--ours") a.ours = process.argv[++i];
else if (v === "--ref") a.ref = process.argv[++i];
else if (v === "--route") a.route = process.argv[++i];
}
return a;
})();
const WEB = process.cwd();
const PORT = 5179;
const REFS_DIR = resolve(WEB, "../refs-html");
// Динамические сегменты: явный список значений + сопоставление id → файл рефа.
const DYNAMIC = {
"tools/[id]": {
ids: ["linear-gradient-png", "remove-background-png"],
refFor: (id) =>
id === "linear-gradient-png"
? "gradient.html"
: id === "remove-background-png"
? "background-remover.html"
: null,
},
};
// Маршруты без релевантного рефа (refs-html/index.html — зеркало demo из Next, не реф).
const EXCLUDE = new Set(["/preview"]);
const refNameFor = (route) => {
if (route === "/preview") return "index.html";
const last = route.split("/").filter(Boolean).pop();
return `${last}.html`;
};
function discoverRoutes() {
const base = resolve(WEB, "src/routes/preview");
const out = [];
const walk = (dir) => {
let ents;
try {
ents = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of ents) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name === "+page.svelte") {
const rel = p.slice(base.length).replace(/\\/g, "/");
const parts = rel.split("/").filter(Boolean);
parts.pop();
out.push(parts.length ? `/preview/${parts.join("/")}` : "/preview");
}
}
};
walk(base);
return out;
}
function refsList() {
try {
return readdirSync(REFS_DIR).filter((f) => f.endsWith(".html"));
} catch {
return [];
}
}
function refExists(name) {
return refsList().includes(name);
}
function buildTargets() {
if (args.route) {
const ref = args.ref || refNameFor(args.route);
return [{ route: args.route, ref }];
}
const targets = [];
const seen = new Set();
const add = (route, ref) => {
if (!ref || seen.has(route)) return;
if (!refExists(ref)) {
console.warn(`[dom-audit] ref not found, skipped: ${route} -> ${ref}`);
return;
}
seen.add(route);
targets.push({ route, ref });
};
for (const route of discoverRoutes()) {
if (route.includes("[")) continue;
if (EXCLUDE.has(route)) {
console.log(`[dom-audit] excluded (no relevant ref): ${route}`);
continue;
}
add(route, refNameFor(route));
}
for (const [seg, cfg] of Object.entries(DYNAMIC)) {
for (const id of cfg.ids) {
add(`/preview/${seg.replace("[id]", id)}`, cfg.refFor(id));
}
}
const routed = new Set(targets.map((t) => t.ref));
for (const f of refsList()) {
if (!routed.has(f)) console.warn(`[dom-audit] ref без маршрута: ${f}`);
}
return targets;
}
const waitFor = async (url, ms = 60000) => {
const t = Date.now();
while (Date.now() - t < ms) {
try {
const r = await fetch(url);
if (r.ok) return;
if (r.status >= 400) {
throw new Error(
`route ${url} returned HTTP ${r.status} (server is up, page failed to render)`,
);
}
} catch (e) {
if (e instanceof Error && e.message.includes("returned HTTP")) throw e;
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("dev server not up: " + url);
};
// Нормализованное дерево элементов: тег, отсортированные классы, собственный
// текст (без SVG-иконок), дети. Пропускаем служебное; SVG не спускаемся внутрь.
const snapshot = (page) =>
page.evaluate(() => {
const walk = (el) => {
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template"].includes(tag))
return null;
// SvelteKit ін'єктить #svelte-announcer (aria-live) прямо в <body>;
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
if (el.id === "svelte-announcer") return null;
// Пропускаем не отображаемые узлы (Next-боилерплейт: <div hidden>,
// оверлеи), чтобы они не конкурировали за матчинг с реальным контентом.
if (el.hasAttribute("hidden")) return null;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return null;
const classes = [...el.classList].sort();
let ownText = "";
for (const c of el.childNodes)
if (c.nodeType === 3) ownText += c.textContent;
ownText = ownText.replace(/\s+/g, " ").trim();
const node = { tag, classes, ownText, children: [] };
if (tag === "svg") return node;
for (const child of el.children) {
const cn = walk(child);
if (cn) node.children.push(cn);
}
return node;
};
return walk(document.body);
});
// Ключ сопоставления: тег + собственный текст (классы НЕ входят — у нас свои
// имена по решению №2). Чисто-структурные обёртки (div/main/section/…)
// нормализуем к общему токену, чтобы выравнивать дерево по сути, а не по
// конкретному тегу оболочки (наш `.preview-root`/div vs рефовый `main`/`.app-shell`).
// Семантические теги (header/footer/nav/span/button…) оставляем как есть.
const STRUCT_TAGS = new Set([
"div",
"main",
"section",
"article",
"aside",
"ul",
"li",
"form",
"fieldset",
]);
const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
// Контейнеры (есть дочерние элементы) матчим по тегу, игнорируя собственный
// текст — иначе малейшее отличие текста в обёртке каскадом роняет всё поддерево.
// Листья (нет дочерних элементов) матчим по тегу + тексту.
const keyOf = (n) =>
n.children.length === 0 ? `${normTag(n.tag)}|${n.ownText}` : normTag(n.tag);
function subtreeCount(node) {
let c = 1;
for (const c0 of node.children) c += subtreeCount(c0);
return c;
}
// Несовпавшее поддерево — одна запись с числом узлов, без рекурсивного
// засорения отчёта. (Каскад внутри уже учтён как один факт расхождения.)
function reportUnmatched(node, path, type, out) {
out.push({
path,
type,
tag: node.tag,
text: node.ownText,
nodes: subtreeCount(node),
});
}
function diffNodes(a, b, path, out) {
if (a.tag !== b.tag)
out.push({ path, type: "tagMismatch", ours: a.tag, ref: b.tag });
if (a.ownText !== b.ownText)
out.push({ path, type: "textMismatch", ours: a.ownText, ref: b.ownText });
matchChildren(a.children, b.children, path, out);
}
// Выравниваем детей по ключу (жадно, в порядке), чтобы одно расхождение не
// каскадировало на всех потомков. Несовпавшие — added/removed целиком поддеревом.
function matchChildren(ac, bc, path, out) {
const bByKey = new Map();
bc.forEach((c, idx) => {
const k = keyOf(c);
if (!bByKey.has(k)) bByKey.set(k, []);
bByKey.get(k).push(idx);
});
const used = new Set();
for (let i = 0; i < ac.length; i++) {
const aNode = ac[i];
const k = keyOf(aNode);
const cands = bByKey.get(k);
let bIdx = -1;
if (cands)
for (const ci of cands)
if (!used.has(ci)) {
bIdx = ci;
break;
}
if (bIdx >= 0) {
used.add(bIdx);
diffNodes(aNode, bc[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
} else {
reportUnmatched(aNode, `${path} > ${aNode.tag}:${i + 1}`, "added", out);
}
}
for (let j = 0; j < bc.length; j++) {
if (used.has(j)) continue;
reportUnmatched(bc[j], `${path} > ${bc[j].tag}:${j + 1}`, "removed", out);
}
}
// Точка входа: сравниваем деревья от body.
function diff(ours, ref, path, out) {
if (!ours || !ref) return;
diffNodes(ours, ref, path, out);
}
function tally(deltas) {
const c = {
added: 0,
removed: 0,
tagMismatch: 0,
textMismatch: 0,
nodesAdded: 0,
nodesRemoved: 0,
};
for (const d of deltas) {
if (d.type in c) c[d.type]++;
if (d.type === "added") c.nodesAdded += d.nodes ?? 1;
if (d.type === "removed") c.nodesRemoved += d.nodes ?? 1;
}
return c;
}
async function auditOne(browser, target) {
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
await page.goto(oursUrl, { waitUntil: "load" });
await page.waitForTimeout(600);
const ours = await snapshot(page);
await page.close();
// Реф грузим через goto(file://) в ИЗОЛИРОВАННОМ контексте: так http-страница
// и статический реф не делят один execution context (переход http → file в
// одной странице уничтожал контекст и ронял прогон). setContent не годится —
// он не воспроизводит DOM рефа (губит 1 узел), поэтому используем goto.
const refUrl = "file:///" + resolve(REFS_DIR, target.ref).replace(/\\/g, "/");
const ctx = await browser.newContext();
const refPage = await ctx.newPage();
await refPage.goto(refUrl, { waitUntil: "load" });
await refPage.waitForTimeout(400);
const ref = await snapshot(refPage);
await ctx.close();
const deltas = [];
diff(ours, ref, "body", deltas);
return {
route: target.route,
ref: target.ref,
deltas,
counts: tally(deltas),
};
}
function toMarkdown(reports) {
const summary =
`# DOM audit (phase A)\n\n_${new Date().toISOString()}_\n\n` +
`## Сводка\n\n| route | ref | added | removed | tag | text |\n|---|---|---|---|---|---|\n` +
reports
.map(
(r) =>
`| \`${r.route}\` | ${r.ref} | ${r.counts.added} | ${r.counts.removed} | ${r.counts.tagMismatch} | ${r.counts.textMismatch} |`,
)
.join("\n");
const sections = reports
.map((r) => {
const deltas = r.deltas.slice(0, 150);
const body = deltas.length
? deltas
.map((d) => {
if (d.type === "textMismatch")
return `- \`${d.path}\` **text**: ours \`${d.ours}\` → ref \`${d.ref}\``;
if (d.type === "tagMismatch")
return `- \`${d.path}\` **tag**: ours \`${d.ours}\` → ref \`${d.ref}\``;
if (d.type === "added")
return `- \`${d.path}\` **added** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
if (d.type === "removed")
return `- \`${d.path}\` **removed** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
return "";
})
.join("\n")
: "_расхождений нет_";
const more =
r.deltas.length > deltas.length
? `\n\n_…и ещё ${r.deltas.length - deltas.length} (см. json)_`
: "";
return (
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
`- added: **${r.counts.added}** (≈${r.counts.nodesAdded} узл.), removed: **${r.counts.removed}** (≈${r.counts.nodesRemoved} узл.), tag: **${r.counts.tagMismatch}**, text: **${r.counts.textMismatch}**\n\n` +
`### Расхождения (топ ${Math.min(150, r.deltas.length)})\n\n${body}${more}`
);
})
.join("\n");
return summary + sections + "\n";
}
async function main() {
const targets = buildTargets();
if (!targets.length) {
console.error("no audit targets (refs-html/*.html missing?)");
process.exit(1);
}
console.log(`[dom-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
let server;
if (!args.noServe) {
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
cwd: WEB,
stdio: "ignore",
shell: true,
detached: process.platform !== "win32",
});
await waitFor(args.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
}
const browser = await chromium.launch();
const reports = [];
try {
for (const target of targets) {
try {
const rep = await auditOne(browser, target);
reports.push(rep);
console.log(
`[dom-audit] ${rep.route}: added=${rep.counts.added} removed=${rep.counts.removed} tag=${rep.counts.tagMismatch} text=${rep.counts.textMismatch}`,
);
} catch (e) {
console.error(`[dom-audit] ${target.route} failed: ${e.message}`);
}
}
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(
resolve(WEB, "audit/dom-report.json"),
JSON.stringify(
{ generatedAt: new Date().toISOString(), reports },
null,
2,
),
);
writeFileSync(resolve(WEB, "audit/dom-report.md"), toMarkdown(reports));
console.log(`report: web/audit/dom-report.md`);
} finally {
await browser.close();
if (server) {
try {
if (process.platform === "win32")
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
stdio: "ignore",
});
else server.kill("SIGTERM");
} catch {}
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-15
View File
@@ -3,29 +3,14 @@ import { TOOLS, type ToolEntry } from "./registry";
export type PreviewGroup = { export type PreviewGroup = {
id: string; id: string;
label: string;
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-каталог: полный реестр инструментов, сгруппированный по категориям. * Preview-каталог: полный реестр инструментов, сгруппированный по категориям.
* Больше не урезаем до референс-набора показываем все инструменты из TOOLS.
*/ */
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category, id: category,
label: GROUP_LABELS[category] ?? category.toUpperCase(),
tools: TOOLS.filter((tool) => tool.category === category), tools: TOOLS.filter((tool) => tool.category === category),
})).filter((group) => group.tools.length > 0); })).filter((group) => group.tools.length > 0);
+4 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { t } from "$lib/i18n/t";
interface Props { interface Props {
label: string; label: string;
@@ -12,7 +13,8 @@
<section class="catalog-group"> <section class="catalog-group">
<div class="group-title"> <div class="group-title">
<span>{label}</span> <span>{label}</span>
<i>{count.toString().padStart(2, "0")} <!-- --> TOOLS</i> <i>{count.toString().padStart(2, "0")} <!-- --> {t("catalog.toolsCount")}</i
>
</div> </div>
<div class="tool-cards"> <div class="tool-cards">
{@render children()} {@render children()}
@@ -36,6 +38,7 @@
.group-title span { .group-title span {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-xl); letter-spacing: var(--space-text-xl);
text-transform: uppercase;
color: var(--color-text); color: var(--color-text);
} }
.group-title i { .group-title i {
+5 -6
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
interface Props { interface Props {
total: number; total: number;
} }
@@ -7,15 +9,12 @@
<div class="catalog-head"> <div class="catalog-head">
<div> <div>
<h1>Tool catalog</h1> <h1>{t("catalog.heading")}</h1>
<p> <p>{t("catalog.lead")}</p>
Focused utilities for working with PNG. Inspect, transform, and export —
locally in your browser.
</p>
</div> </div>
<div class="catalog-total"> <div class="catalog-total">
<b>{total}</b> <b>{total}</b>
<span>TOOLS<br /> AVAILABLE</span> <span>{t("catalog.toolsAvailable")}</span>
</div> </div>
</div> </div>
+13 -11
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Funnel, Search } from "@lucide/svelte"; import { Funnel, Search } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
interface Props { interface Props {
query: string; query: string;
@@ -8,15 +9,15 @@
let { query = $bindable(), category = $bindable() }: Props = $props(); let { query = $bindable(), category = $bindable() }: Props = $props();
const FILTERS = [ const FILTERS = [
{ value: "all", label: "ALL" }, { value: "all", label: t("categories.all") },
{ value: "convert", label: "CONVERT" }, { value: "convert", label: t("categories.convert") },
{ value: "alpha", label: "TRANSPARENCY" }, { value: "alpha", label: t("categories.alpha") },
{ value: "color", label: "COLOR" }, { value: "color", label: t("categories.color") },
{ value: "geometry", label: "GEOMETRY" }, { value: "geometry", label: t("categories.geometry") },
{ value: "filters", label: "FILTERS" }, { value: "filters", label: t("categories.filters") },
{ value: "text", label: "TEXT" }, { value: "text", label: t("categories.text") },
{ value: "analyze", label: "ANALYZE" }, { value: "analyze", label: t("categories.analyze") },
{ value: "generate", label: "GENERATE" }, { value: "generate", label: t("categories.generate") },
]; ];
// TODO: Button component // TODO: Button component
@@ -26,8 +27,8 @@
<label class="catalog-search"> <label class="catalog-search">
<Search size={16} /> <Search size={16} />
<input <input
aria-label="Search tools" aria-label={t("search.aria")}
placeholder="Search tools..." placeholder={t("search.placeholder")}
bind:value={query} bind:value={query}
/> />
</label> </label>
@@ -87,6 +88,7 @@
color: var(--color-text-muted); color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m); letter-spacing: var(--space-text-m);
text-transform: uppercase;
padding: var(--space-s) var(--space-m); padding: var(--space-s) var(--space-m);
border-radius: var(--radius-m); border-radius: var(--radius-m);
cursor: pointer; cursor: pointer;
+6 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Copy } from "@lucide/svelte"; import { Copy } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import IconButton from "./ui/IconButton.svelte"; import IconButton from "./ui/IconButton.svelte";
interface Props { interface Props {
@@ -16,7 +17,11 @@
<pre class="code-pre"><code>{code}</code></pre> <pre class="code-pre"><code>{code}</code></pre>
{#if copyable} {#if copyable}
<div class="code-copy"> <div class="code-copy">
<IconButton icon={Copy} label="Copy" onclick={() => oncopy?.()} /> <IconButton
icon={Copy}
label={t("textResult.copy")}
onclick={() => oncopy?.()}
/>
</div> </div>
{/if} {/if}
</div> </div>
+3 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Upload } from "@lucide/svelte"; import { Upload } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
interface Props { interface Props {
accept?: string; accept?: string;
@@ -53,8 +54,8 @@
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)} onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
/> />
<Upload size={22} /> <Upload size={22} />
<span class="dz-title">Drop a PNG here</span> <span class="dz-title">{t("dropZone.pickTitle")}</span>
<span class="dz-sub">or click to browse — processed locally</span> <span class="dz-sub">{t("dropZone.pickHint")}</span>
</div> </div>
<style> <style>
+4 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Upload } from "@lucide/svelte"; import { Upload } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import DownloadButton from "./ui/DownloadButton.svelte"; import DownloadButton from "./ui/DownloadButton.svelte";
interface Props { interface Props {
@@ -16,7 +17,8 @@
<div class="actions"> <div class="actions">
{#if inputMode === "image"} {#if inputMode === "image"}
<label class="btn upload"> <label class="btn upload">
<Upload size={14} /> Open image <Upload size={14} />
{t("actions.openImage")}
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
@@ -29,7 +31,7 @@
{/if} {/if}
{#if canDownload} {#if canDownload}
<DownloadButton <DownloadButton
label="Download result" label={t("actions.downloadResult")}
onclick={ondownload} onclick={ondownload}
disabled={running} disabled={running}
/> />
+13 -12
View File
@@ -4,6 +4,8 @@
FieldSpecKind, FieldSpecKind,
ToolSchema, ToolSchema,
} from "$lib/registry-schema"; } from "$lib/registry-schema";
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { RotateCcw } from "@lucide/svelte"; import { RotateCcw } from "@lucide/svelte";
import type { Component } from "svelte"; import type { Component } from "svelte";
import DimensionField from "./fields/DimensionField.svelte"; import DimensionField from "./fields/DimensionField.svelte";
@@ -24,6 +26,8 @@
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: FieldSpec;
toolId: string;
fieldId: string;
onchange?: (value: unknown) => void; onchange?: (value: unknown) => void;
} }
@@ -47,17 +51,11 @@
interface Props { interface Props {
schema: ToolSchema<Record<string, unknown>>; schema: ToolSchema<Record<string, unknown>>;
values: Record<string, unknown>; values: Record<string, unknown>;
toolId: string;
onchange: (id: string, value: unknown) => void; onchange: (id: string, value: unknown) => void;
onreset: () => void; onreset: () => void;
} }
let { schema, values, onchange, onreset }: Props = $props(); let { schema, values, toolId, onchange, onreset }: Props = $props();
function labelOf(id: string): string {
return id
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
interface LayoutGroup { interface LayoutGroup {
key: string; key: string;
@@ -95,7 +93,7 @@
{#each layoutGroups as group (group.key)} {#each layoutGroups as group (group.key)}
{#if group.title} {#if group.title}
<span class="group-title">{group.title}</span> <span class="group-title">{groupLabel(group.title)}</span>
{/if} {/if}
<div <div
class="group-fields" class="group-fields"
@@ -106,9 +104,11 @@
{#each group.fields as id (id)} {#each group.fields as id (id)}
{@const Control = FIELDS[schema.fields[id].spec.kind]} {@const Control = FIELDS[schema.fields[id].spec.kind]}
<Control <Control
label={labelOf(id)} label={fieldLabel(schema.fields[id], id)}
value={values[id]} value={values[id]}
spec={schema.fields[id].spec} spec={schema.fields[id].spec}
{toolId}
fieldId={id}
onchange={(v) => onchange(id, v)} onchange={(v) => onchange(id, v)}
/> />
{/each} {/each}
@@ -117,9 +117,10 @@
<div class="panel-foot"> <div class="panel-foot">
<button class="reset-btn" onclick={onreset}> <button class="reset-btn" onclick={onreset}>
<RotateCcw size={14} /> Reset <RotateCcw size={14} />
{t("ui.reset")}
</button> </button>
<span class="auto-note"><i></i> updates automatically</span> <span class="auto-note"><i></i> {t("ui.autoUpdate")}</span>
</div> </div>
<style> <style>
+12 -7
View File
@@ -4,11 +4,13 @@
import SchemaResultTile from "$lib/components/SchemaResultTile.svelte"; import SchemaResultTile from "$lib/components/SchemaResultTile.svelte";
import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte"; import SchemaSourceTile from "$lib/components/SchemaSourceTile.svelte";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { FileResult, InputMode, ResultKind } from "$lib/registry"; import type { FileResult, InputMode, ResultKind } from "$lib/registry";
interface Props { interface Props {
inputMode: InputMode; inputMode: InputMode;
resultKind?: ResultKind; resultKind?: ResultKind;
toolId: string;
source: PixelImage | null; source: PixelImage | null;
result: PixelImage | null; result: PixelImage | null;
fileResult?: FileResult | null; fileResult?: FileResult | null;
@@ -26,6 +28,7 @@
let { let {
inputMode, inputMode,
resultKind = "image", resultKind = "image",
toolId,
source, source,
result, result,
fileResult = null, fileResult = null,
@@ -46,7 +49,7 @@
case "none": case "none":
return "—"; return "—";
case "text": case "text":
return "text"; return t("textInput.heading");
case "image": case "image":
return source ? `${source.width} × ${source.height} px` : "—"; return source ? `${source.width} × ${source.height} px` : "—";
} }
@@ -58,10 +61,10 @@
: "—" : "—"
: resultKind === "files" : resultKind === "files"
? fileResult ? fileResult
? `${fileResult.files.length} parts` ? `${fileResult.files.length} ${t("resultCard.parts")}`
: "—" : "—"
: textResult : textResult
? "text" ? t("textInput.heading")
: "—", : "—",
); );
const formatValue = $derived(resultKind === "files" ? "ZIP (PNG)" : "PNG"); const formatValue = $derived(resultKind === "files" ? "ZIP (PNG)" : "PNG");
@@ -75,7 +78,7 @@
</script> </script>
<div class="panel-head"> <div class="panel-head">
<span class="label">PREVIEW PANEL</span> <span class="label">{t("resultCard.previewPanel")}</span>
<SchemaActions <SchemaActions
{inputMode} {inputMode}
canDownload={hasResult} canDownload={hasResult}
@@ -103,6 +106,7 @@
{result} {result}
{fileResult} {fileResult}
{textResult} {textResult}
{toolId}
{running} {running}
oncopy={oncopytext} oncopy={oncopytext}
{ondownloadtxt} {ondownloadtxt}
@@ -112,9 +116,9 @@
<div class="meta"> <div class="meta">
<MetaList <MetaList
items={[ items={[
{ caption: "SOURCE", value: sourceValue }, { caption: t("sourceCard.source"), value: sourceValue },
{ caption: "RESULT", value: resultValue }, { caption: t("resultCard.result"), value: resultValue },
{ caption: "FORMAT", value: formatValue }, { caption: t("resultCard.format"), value: formatValue },
]} ]}
/> />
</div> </div>
@@ -132,6 +136,7 @@
.label { .label {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l); letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main); color: var(--color-main);
} }
.error { .error {
+12 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind } from "$lib/registry"; import type { FileResult, ResultKind } from "$lib/registry";
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
import SchemaTextResult from "./SchemaTextResult.svelte"; import SchemaTextResult from "./SchemaTextResult.svelte";
@@ -11,6 +12,7 @@
result: PixelImage | null; result: PixelImage | null;
fileResult?: FileResult | null; fileResult?: FileResult | null;
textResult?: string | null; textResult?: string | null;
toolId?: string;
running?: boolean; running?: boolean;
oncopy?: () => void; oncopy?: () => void;
ondownloadtxt?: () => void; ondownloadtxt?: () => void;
@@ -20,6 +22,7 @@
result, result,
fileResult = null, fileResult = null,
textResult = null, textResult = null,
toolId = "",
running = false, running = false,
oncopy, oncopy,
ondownloadtxt, ondownloadtxt,
@@ -40,7 +43,12 @@
); );
</script> </script>
<PreviewTile label="RESULT" viewMode={resultKind} loading={running} {parts}> <PreviewTile
label={t("resultCard.result")}
viewMode={resultKind}
loading={running}
{parts}
>
<div class="canvas" class:checker={resultKind === "image"}> <div class="canvas" class:checker={resultKind === "image"}>
{#if resultKind === "text" && textResult} {#if resultKind === "text" && textResult}
<div class="text-result-wrap"> <div class="text-result-wrap">
@@ -56,6 +64,7 @@
<SchemaTextResult <SchemaTextResult
value={textResult} value={textResult}
kind="verdict" kind="verdict"
{toolId}
oncopy={oncopy ?? (() => {})} oncopy={oncopy ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})} ondownload={ondownloadtxt ?? (() => {})}
/> />
@@ -70,11 +79,11 @@
{/each} {/each}
</div> </div>
{:else if resultKind === "image" && resultUrl} {:else if resultKind === "image" && resultUrl}
<img src={resultUrl} alt="result" /> <img src={resultUrl} alt={t("resultCard.alt")} />
{:else if running} {:else if running}
<Check size={22} /> <Check size={22} />
{:else} {:else}
<span class="empty">no result yet</span> <span class="empty">{t("resultCard.noResult")}</span>
{/if} {/if}
</div> </div>
</PreviewTile> </PreviewTile>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { InputMode } from "$lib/registry"; import type { InputMode } from "$lib/registry";
import PreviewTile from "./layout/PreviewTile.svelte"; import PreviewTile from "./layout/PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte"; import SchemaTextSource from "./SchemaTextSource.svelte";
@@ -25,7 +26,7 @@
const sourceUrl = $derived(source ? toDataUrl(source) : null); const sourceUrl = $derived(source ? toDataUrl(source) : null);
</script> </script>
<PreviewTile label="SOURCE" viewMode={mode}> <PreviewTile label={t("sourceCard.source")} viewMode={mode}>
{#if mode === "text"} {#if mode === "text"}
<div class="text-source-wrap"> <div class="text-source-wrap">
<SchemaTextSource <SchemaTextSource
@@ -36,11 +37,11 @@
/> />
</div> </div>
{:else if mode === "image" && sourceUrl} {:else if mode === "image" && sourceUrl}
<img src={sourceUrl} alt="source" /> <img src={sourceUrl} alt={t("sourceCard.alt")} />
{:else if mode === "image"} {:else if mode === "image"}
<span class="empty">choose an image</span> <span class="empty">{t("sourceCard.chooseImage")}</span>
{:else} {:else}
<span class="empty">no source — configure the parameters</span> <span class="empty">{t("sourceCard.noSource")}</span>
{/if} {/if}
</PreviewTile> </PreviewTile>
+13 -11
View File
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Copy, Download, FileText } from "@lucide/svelte"; import { Copy, Download, FileText } from "@lucide/svelte";
import { verdictTone, verdictText } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import IconButton from "./ui/IconButton.svelte"; import IconButton from "./ui/IconButton.svelte";
interface Props { interface Props {
value: string; value: string;
kind: "text" | "verdict"; kind: "text" | "verdict";
toolId?: string;
fileName?: string; fileName?: string;
oncopy: () => void; oncopy: () => void;
ondownload: () => void; ondownload: () => void;
@@ -12,26 +15,24 @@
let { let {
value, value,
kind, kind,
toolId = "",
fileName = "result.txt", fileName = "result.txt",
oncopy, oncopy,
ondownload, ondownload,
}: Props = $props(); }: Props = $props();
const tone = $derived( const tone = $derived(verdictTone(value));
/^(yes|true)/i.test(value.trim()) const displayValue = $derived(
? "success" kind === "verdict" && toolId ? verdictText(toolId, value) : value,
: /^(no|false)/i.test(value.trim())
? "danger"
: "info",
); );
</script> </script>
{#if kind === "verdict"} {#if kind === "verdict"}
<div class="verdict"> <div class="verdict">
<span class="verdict-label">RESULT</span> <span class="verdict-label">{t("resultCard.result")}</span>
<div class="badge badge-tone--{tone}" role="status"> <div class="badge badge-tone--{tone}" role="status">
<span class="verdict-text">{value}</span> <span class="verdict-text">{displayValue}</span>
<IconButton icon={Copy} label="Copy" onclick={oncopy} /> <IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
</div> </div>
</div> </div>
{:else} {:else}
@@ -39,10 +40,10 @@
<div class="result-head"> <div class="result-head">
<span class="result-label"><FileText size={14} /> {fileName}</span> <span class="result-label"><FileText size={14} /> {fileName}</span>
<div class="result-actions"> <div class="result-actions">
<IconButton icon={Copy} label="Copy" onclick={oncopy} /> <IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
<IconButton <IconButton
icon={Download} icon={Download}
label="Download .txt" label={t("textResult.downloadTxt")}
onclick={ondownload} onclick={ondownload}
/> />
</div> </div>
@@ -98,6 +99,7 @@
color: var(--color-text-muted); color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l); letter-spacing: var(--space-text-l);
text-transform: uppercase;
} }
.badge { .badge {
display: inline-flex; display: inline-flex;
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ArrowUp, Upload } from "@lucide/svelte"; import { ArrowUp, Upload } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
interface Props { interface Props {
value: string; value: string;
@@ -11,7 +12,7 @@
} }
let { let {
value, value,
placeholder = "Paste base64 / hex / bytes…", placeholder = t("textSource.placeholder"),
disabled = false, disabled = false,
oninput, oninput,
onrender, onrender,
@@ -35,11 +36,13 @@
<div class="actions"> <div class="actions">
{#if onsample} {#if onsample}
<button type="button" class="secondary" onclick={onsample}> <button type="button" class="secondary" onclick={onsample}>
<Upload size={14} /> Try sample <Upload size={14} />
{t("textSource.trySample")}
</button> </button>
{/if} {/if}
<button type="button" class="render" onclick={onrender} {disabled}> <button type="button" class="render" onclick={onrender} {disabled}>
<ArrowUp size={14} /> Render text <ArrowUp size={14} />
{t("textSource.render")}
</button> </button>
</div> </div>
</div> </div>
+22 -7
View File
@@ -6,6 +6,11 @@
import { decodeFile, encode } from "$lib/core/io"; import { decodeFile, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor"; import { execute } from "$lib/executor";
import {
toolDescription,
toolTitle,
verdictText,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { FileResult, ToolEntry } from "$lib/registry"; import type { FileResult, ToolEntry } from "$lib/registry";
import { import {
@@ -119,8 +124,10 @@
async function copyText() { async function copyText() {
if (!textResult) return; if (!textResult) return;
const copyValue =
resultKind === "verdict" ? verdictText(tool.id, textResult) : textResult;
try { try {
await navigator.clipboard.writeText(textResult); await navigator.clipboard.writeText(copyValue);
} catch (e) { } catch (e) {
error = errorText(e); error = errorText(e);
} }
@@ -161,22 +168,29 @@
<div class="schema-tool"> <div class="schema-tool">
<header class="header"> <header class="header">
<div class="title-block"> <div class="title-block">
<h1>{tool.title}</h1> <h1>{toolTitle(tool)}</h1>
<p class="lede">{tool.description}</p> <p class="lede">{toolDescription(tool)}</p>
</div> </div>
</header> </header>
<div class="workspace"> <div class="workspace">
<section class="panel settings"> <section class="panel settings">
<div class="panel-head"> <div class="panel-head">
<span class="label">TOOL SETTINGS</span> <span class="label">{t("paramsCard.toolSettings")}</span>
<strong>Configure output</strong> <strong>{t("paramsCard.configureOutput")}</strong>
</div> </div>
<SchemaFields {schema} {values} onchange={setValue} onreset={reset} /> <SchemaFields
{schema}
{values}
toolId={tool.id}
onchange={setValue}
onreset={reset}
/>
</section> </section>
<section class="panel"> <section class="panel">
<SchemaPreview <SchemaPreview
toolId={tool.id}
{source} {source}
{result} {result}
{fileResult} {fileResult}
@@ -199,7 +213,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<p class="no-schema">This tool has no schema yet.</p> <p class="no-schema">{t("paramsCard.noSchema")}</p>
{/if} {/if}
<style> <style>
@@ -223,6 +237,7 @@
.label { .label {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l); letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.label { .label {
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Ellipsis, GripVertical, X } from "@lucide/svelte"; import { Ellipsis, GripVertical, X } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface Props { interface Props {
@@ -28,7 +29,7 @@
<button <button
type="button" type="button"
class="step-remove" class="step-remove"
aria-label="Remove step" aria-label={t("chain.removeStepAria")}
onclick={() => onremove?.()} onclick={() => onremove?.()}
> >
<X size={16} /> <X size={16} />
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
import type { import type {
Dimension, Dimension,
DimensionSpec, DimensionSpec,
@@ -37,7 +38,7 @@
<span class="dimension-label">{label}</span> <span class="dimension-label">{label}</span>
<div class="dimension-field"> <div class="dimension-field">
<label class="dimension-axis"> <label class="dimension-axis">
<span>Width</span> <span>{t("ui.width")}</span>
<input <input
type="number" type="number"
min={sp.min} min={sp.min}
@@ -48,7 +49,7 @@
/> />
</label> </label>
<label class="dimension-axis"> <label class="dimension-axis">
<span>Height</span> <span>{t("ui.height")}</span>
<input <input
type="number" type="number"
min={sp.min} min={sp.min}
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
interface Preset { interface Preset {
value: number; value: number;
label: string; label: string;
@@ -15,7 +17,7 @@
} }
let { let {
label = "Angle", label = t("ui.angle"),
value, value,
min = 0, min = 0,
max = 360, max = 360,
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
import type { import type {
ColorList, ColorList,
ColorListSpec, ColorListSpec,
@@ -59,14 +60,16 @@
class="remove" class="remove"
onclick={() => removeColor(i)} onclick={() => removeColor(i)}
disabled={current.length <= 1} disabled={current.length <= 1}
aria-label="Remove color" aria-label={t("ui.removeColor")}
> >
× ×
</button> </button>
</div> </div>
{/each} {/each}
</div> </div>
<button type="button" class="add" onclick={addColor}>+ Add color</button> <button type="button" class="add" onclick={addColor}
>{t("ui.addColor")}</button
>
</div> </div>
<style> <style>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
import type { TextFont } from "$lib/core/domText"; import type { TextFont } from "$lib/core/domText";
import type { import type {
FieldSpec, FieldSpec,
@@ -35,10 +36,10 @@
onchange?.({ ...current, [key]: val }); onchange?.({ ...current, [key]: val });
} }
const FONTS: { value: TextFont; label: string }[] = [ const FONTS: { value: TextFont; labelKey: string }[] = [
{ value: "sans", label: "Sans" }, { value: "sans", labelKey: "ui.fontSans" },
{ value: "serif", label: "Serif" }, { value: "serif", labelKey: "ui.fontSerif" },
{ value: "mono", label: "Mono" }, { value: "mono", labelKey: "ui.fontMono" },
]; ];
</script> </script>
@@ -46,7 +47,7 @@
<span class="fs-label">{label}</span> <span class="fs-label">{label}</span>
<div class="fs-grid"> <div class="fs-grid">
<label class="fs-cell"> <label class="fs-cell">
<span class="fs-sub">Font</span> <span class="fs-sub">{t("ui.font")}</span>
<select <select
class="fs-select" class="fs-select"
value={current.font} value={current.font}
@@ -54,12 +55,12 @@
set("font", (e.target as HTMLSelectElement).value as TextFont)} set("font", (e.target as HTMLSelectElement).value as TextFont)}
> >
{#each FONTS as option (option.value)} {#each FONTS as option (option.value)}
<option value={option.value}>{option.label}</option> <option value={option.value}>{t(option.labelKey)}</option>
{/each} {/each}
</select> </select>
</label> </label>
<label class="fs-cell"> <label class="fs-cell">
<span class="fs-sub">Size</span> <span class="fs-sub">{t("ui.size")}</span>
<input <input
class="fs-input" class="fs-input"
type="number" type="number"
@@ -71,7 +72,7 @@
/> />
</label> </label>
<label class="fs-cell"> <label class="fs-cell">
<span class="fs-sub">Color</span> <span class="fs-sub">{t("ui.color")}</span>
<span class="fs-color"> <span class="fs-color">
<span class="swatch" style="background:{current.color}"></span> <span class="swatch" style="background:{current.color}"></span>
<input <input
@@ -83,7 +84,7 @@
</span> </span>
</label> </label>
<label class="fs-cell fs-bold"> <label class="fs-cell fs-bold">
<span class="fs-sub">Bold</span> <span class="fs-sub">{t("ui.bold")}</span>
<input <input
type="checkbox" type="checkbox"
checked={current.bold} checked={current.bold}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import AngleControl from "./AngleControl.svelte"; import AngleControl from "./AngleControl.svelte";
import { t } from "$lib/i18n/t";
import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema"; import type { FieldSpec, Gradient, GradientSpec } from "$lib/registry-schema";
interface Props { interface Props {
@@ -32,7 +33,7 @@
<span class="gradient-label">{label}</span> <span class="gradient-label">{label}</span>
<div class="gradient-colors"> <div class="gradient-colors">
<label class="color-field"> <label class="color-field">
<span>From</span> <span>{t("ui.from")}</span>
<span class="swatch" style="background:{current.from}"></span> <span class="swatch" style="background:{current.from}"></span>
<input <input
type="color" type="color"
@@ -41,7 +42,7 @@
/> />
</label> </label>
<label class="color-field"> <label class="color-field">
<span>To</span> <span>{t("ui.to")}</span>
<span class="swatch" style="background:{current.to}"></span> <span class="swatch" style="background:{current.to}"></span>
<input <input
type="color" type="color"
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema"; import type { FieldSpec, Plate, PlateSpec } from "$lib/registry-schema";
interface Props { interface Props {
@@ -31,7 +32,7 @@
<span class="plate-label">{label}</span> <span class="plate-label">{label}</span>
<div class="plate-row {current.enabled ? '' : 'off'}"> <div class="plate-row {current.enabled ? '' : 'off'}">
<label class="plate-enabled"> <label class="plate-enabled">
<span>Backing plate</span> <span>{t("ui.backingPlate")}</span>
<input <input
type="checkbox" type="checkbox"
checked={current.enabled} checked={current.enabled}
@@ -49,7 +50,7 @@
</label> </label>
<label class="plate-opacity"> <label class="plate-opacity">
<span> <span>
Opacity {t("ui.opacity")}
<output>{current.opacity}%</output> <output>{current.opacity}%</output>
</span> </span>
<input <input
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { optionLabel } from "$lib/i18n/schema-tool-strings";
import type { FieldSpec, SelectSpec } from "$lib/registry-schema"; import type { FieldSpec, SelectSpec } from "$lib/registry-schema";
interface Props { interface Props {
label: string; label: string;
value: unknown; value: unknown;
spec: FieldSpec; spec: FieldSpec;
toolId: string;
fieldId: string;
onchange?: (value: string) => void; onchange?: (value: string) => void;
} }
let { label, value, spec, onchange }: Props = $props(); let { label, value, spec, toolId, fieldId, onchange }: Props = $props();
const sp = $derived(spec as SelectSpec); const sp = $derived(spec as SelectSpec);
const current = $derived( const current = $derived(
@@ -25,7 +28,9 @@
oninput={(e) => onchange?.((e.target as HTMLSelectElement).value)} oninput={(e) => onchange?.((e.target as HTMLSelectElement).value)}
> >
{#each sp.options as option (option.value)} {#each sp.options as option (option.value)}
<option value={option.value}>{option.label}</option> <option value={option.value}>
{optionLabel(toolId, fieldId, option.value, option.label)}
</option>
{/each} {/each}
</select> </select>
</label> </label>

Some files were not shown because too many files have changed in this diff Show More