Compare commits

..
33 Commits
Author SHA1 Message Date
Ku6epXBOCTuK d24ee0a448 chore: add i18n dict consistency lint rule 2026-09-14 07:40:06 +05:00
Ku6epXBOCTuK 44cfca92ce fix: use base path for favicon 2026-09-13 13:16:02 +05:00
Ku6epXBOCTuK d0857c5d45 test: update tests 2026-09-13 13:07:10 +05:00
Ku6epXBOCTuK c2f3838485 fix: worker use only i18n keys instead of full i18n 2026-09-13 12:44:51 +05:00
Ku6epXBOCTuK 49d0a9444e docs: audit tools 2026-09-13 09:31:08 +05:00
Ku6epXBOCTuK 5167b0a9b1 docs: update backlog 2026-09-13 09:31:03 +05:00
Ku6epXBOCTuK b3e2735fc3 test: update i18n tests 2026-09-13 09:29:28 +05:00
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
Ku6epXBOCTuK 5cd2a586ce fix: restart tool after change source 2026-09-12 18:45:59 +05:00
Ku6epXBOCTuK ec585e1ee6 docs: update backlog 2026-09-12 18:42:30 +05:00
Ku6epXBOCTuK f2829de216 fix: update design - colors, remove texts 2026-09-12 18:39:50 +05:00
Ku6epXBOCTuK 0c2de3f191 fix: disable download button while running jobs, auto run generator jobs 2026-09-12 18:22:05 +05:00
Ku6epXBOCTuK 35df076732 fix: separate preview tile component 2026-09-12 17:17:25 +05:00
Ku6epXBOCTuK 05165031c3 docs: add i18n update plan 2026-09-12 16:27:14 +05:00
185 changed files with 5871 additions and 58701 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`.
## Цель ## Цель
+50 -60
View File
@@ -2,21 +2,33 @@
## lint и прочие правила ## lint и прочие правила
- [x] линт правило для `interface Props {...}` + `let {}: Props = $props()` - [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
(плагин `conventions`, правило `interface-props`; автофикс для значения, совпадение `{placeholder}`-переменных между всеми локалями) —
нетипизированной деструктуризации) правило `i18n/dict-consistency`, детали в §Фаза 8
- [x] линт правило для `object as const`, нежелательно использовать `docs/plan-preview-i18n.md`
`type Kind = 'a' | 'b' | 'c'` - это приводит к дублированию описаний в - [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
разных местах, и к непонятным новым типам типа `type Kind2 = 'a' | 'c'` что за `SonarLint` или написать кастомный линтер)
(правило `conventions/no-string-union-alias`) - [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
- [x] можно ли сделать правило для минимизации новых `const` определений? чтобы модуль
дубли автоматически определялись? ИИ агент иногда дублирует определения - [ ] консистентность комментариев - если есть описание к одному инструменту -
(как линт-правило — нереализуемо без семантики; решено ограничением в оно должно быть у всех или объяснено, почему этот инструмент такой
AGENTS.md «сначала искать», авто-детект дублей в файле — открытый вопрос) особенный и заслуживает комментарий
## Мелочи всякие ## Мелочи всякие
- [ ] сгенерировать или найти файлы для ручной проверки `A. Краевые PNG-файлы` - [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
количество инструментов, точное совпадение текстов, поиск по
`img[alt="Result image"]`, svelte классам и тому подобные
- [ ] можно ли в тестах получать i18n ключ вместо текста?
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha - [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым. channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Убрать описание формата в отображении исходник\результат - это должно быть - [ ] Убрать описание формата в отображении исходник\результат - это должно быть
@@ -29,17 +41,7 @@
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей) паспортом, семейства с кириллицей)
- [ ] Посмотреть темную тему - слишком темная и слишком много оттенка (убавить c
в hct?)
- [ ] на картинке результате при работе дергается высота надписи (высота иконки)
- [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)
- [ ] генераторы работают не автоматически - требуется нажать кнопку 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), добавить прозрачность
@@ -56,15 +58,16 @@
- [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop, - [ ] придумать процесс редактирования и настройки pipeline - drag-n-drop,
удаление, добавление инструментов. переход один инструмент - pipeline без удаление, добавление инструментов. переход один инструмент - pipeline без
перезагрузки перезагрузки
- [ ] Слаги инструментов (`*-png`) — надо обдумать, возможно что ссылки\маршруты - [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми
должны быть с png, а названия инструментов - без. Это упростит названия изображениями. Надо сделать отдельный интерфейс маппинга инструмент ->
инструментов и сделает их более понятными ссылка
## Переезд ## Переезд
- [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
@@ -132,35 +135,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` (и др.
форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого
— бесшовное внедрение выбора формата/качества прямо в кнопку/диалог — бесшовное внедрение выбора формата/качества прямо в кнопку/диалог
@@ -176,42 +171,37 @@
(целевой 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) она
работает не с изображением, а с мета данными из файла, 2) она не может быть
частью цепочки, т.к. результат не является изображением, 3) если надо
изменить теги, никакого форматирования и изменения в изображение вносить не
надо, только в мета информацияю
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) осознанно, как
@@ -220,7 +210,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`) того, что
@@ -235,7 +225,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-архивом, грид-превью частей. Реализовано на первом потребителе
+330
View File
@@ -0,0 +1,330 @@
# План: i18n в preview (новый UI)
> Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче
> «добавить i18n в preview» (`docs/backlog.md`). Реализация не начата.
## 1. Что получается
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
`v1`) переходит на существующий модуль `lib/i18n`: переключатель RU/EN в шапке
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
подключить к ней preview и закрыть пробелы словарей.
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
копия `lib/i18n``lib/v1/i18n`, у нового UI — своя независимая ветка
`lib/i18n`, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
между страницами.
## 2. Текущее состояние (исследование)
### Уже есть
- `lib/i18n/locale.svelte.ts` — состояние локали (runes), персист в
localStorage, обновление `document.documentElement.lang`.
- `lib/i18n/t.ts` — перевод по точечному ключу с фолбэком на базовую локаль и
интерполяцией `{var}`.
- `lib/i18n/dict.ts` — тип `Dict`, `BASE_LOCALE = "en"` (база — английский, ru —
перевод), `LOCALES = ["ru", "en"]`.
- `lib/i18n/ru.ts` (~1351 строка) и `en.ts` (188 строк) — полные переводы
инструментов v1 по id (title/description/params/options/results).
- `lib/i18n/tool-strings.ts` — хелперы для v1
(`toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc`).
- `lib/i18n/matching.ts` — кросс-языковой поиск (ё→е, диакритика, скоринг).
- Тесты: `i18n.test.ts`, `tools-i18n.test.ts`, `matching.test.ts`,
`smoke-i18n.test.ts`, `search-coverage.test.ts`.
- `+layout.svelte` уже вызывает `initLocale()` на клиенте.
### Пробелы в новом UI
- `LangToggle.svelte` — заглушка с локальным `let lang = $state("RU")`, не
связана с `locale.svelte.ts`.
- Новый UI почти целиком на хардкод-английском:
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria,
плейсхолдеры, подписи секций);
- 122 инструмента с английскими `title`/`description` в `lib/registry/*.ts`;
- ~60+ подписей опций select;
- ~30 заголовков layout-групп (`title: "Canvas"`, `"Background"`, …);
- вердикты анализаторов — raw English строки (`"Yes — valid PNG signature."`),
при том что v1 возвращает ключи словаря (`"verifyYes"`) и рендерит их через
`tools[id].results`;
- `labelOf(id)` в `SchemaFields.svelte` генерирует подписи полей из field ID
(keepAspect → "Keep Aspect");
- категории в `catalog.ts` (`GROUP_LABELS`) — English uppercase, хотя в `dict`
уже есть ключи `categories.X`.
- Из всех новых компонентов `t()` вызывает только `SchemaToolView.svelte`, и
только для ошибок (`ToolError.key`).
### Совпадение регистров
- Новый registry и старый v1 registry используют **одинаковые id** инструментов
(resize-png, flip-png, …) — переводы title/description из v1 `ru.ts` служат
исходником для новой ветки `lib/i18n/ru.ts` (копирование данных при наполнении
Фазы 2, не общий модуль).
- **Расхождение**: param ids в ru.ts (`width`/`height`/`keepAspect`) не равны
field ids новоно schema (`size` dimension, `anchor`). Нужны новые переводы
полей.
- `ru.ts` tools[id].results для анализаторов уже содержит ключи (`verifyYes`,
`grayscaleNo`, `orientationPortrait` и т.д.).
### Архитектурные ограничения
- Изоляция old↔new (`isolation/no-mixed-imports`): сейчас `lib/i18n/` — shared.
По решению задачи (Фаза 0) i18n **форкается**: v1 получает полную копию
`lib/v1/i18n`, а `lib/i18n` переходит в ветку «new» (правило изоляции запретит
старому коду импортить новый i18n). До форка в общем модуле два скрытых
v1-импорта: `dict.ts` тянет `CategoryId` из `../v1/categories`,
`tool-strings.ts``$lib/v1/registry`; после форка оба живут только в копии.
Новому UI нужен параллельный `schema-tool-strings.ts` поверх `$lib/registry`.
- Tone detection в `SchemaTextResult.svelte`: `/^(yes|true)/i` по value —
сломается при русских вердиктах («Да», «Нет»).
- `tools-i18n.test.ts` покрывает только v1 TOOLS; нужен аналог для нового
registry.
- `known-issues.spec.ts`:
`test.fixme("error message is localized, not a raw i18n key")`.
## 3. Как делаем
Принципы:
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит
разработчик после ревью.
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
- Каждый этап заканчивается зелёными `pnpm --dir web lint` +
`pnpm --dir web check` + тесты.
- Решения по §5 зафиксированы и учитываются с первых правок словарей.
## 4. Фазы
### Фаза 0: Форк i18n для v1 + подготовка общего слоя
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
которую дальше меняем свободно. На выходе фазы — зелёные `lint`, `check`,
`test`, `test:rules`.
- **0.1** Форк: скопировать `web/src/lib/i18n/``web/src/lib/v1/i18n/` (полный
снимок: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `tool-strings.ts` + все 5 тестов). v1 работает дальше только с
копией — поздние правки нового i18n её не затрагивают.
- **0.2** Привести копию к v1-канону (относительные пути от нового места):
- копия `dict.ts`: импорт `CategoryId` `../v1/categories``../categories` (=
`lib/v1/categories`);
- копии тестов (`tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts`): `../v1/registry``../registry` (=
`lib/v1/registry`);
- импорт `$lib/v1/registry` в копии `tool-strings.ts` остаётся валидным;
- перепроверить остальные относительные и `$lib`-пути в копии.
- **0.3** Переключить импорты старого UI: все `$lib/i18n/*` в `routes/v1/**` и
`lib/v1/components/**` (39 импортов в 23 файлах) → `$lib/v1/i18n/*`.
- **0.4** Очистить новый `lib/i18n/` от v1-специфики, иначе isolation поймает
new→old:
- удалить `tool-strings.ts` (завязан на v1-типы `ParamDef`/`ToolEntry`; для
нового UI вместо него `schema-tool-strings.ts`, см. 0.8);
- удалить v1-центричные тесты `tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts` (они импортируют `../v1/registry`; живут только в
копии);
- остаются: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `i18n.test.ts`, `matching.test.ts`.
- **0.5** Обновить правило изоляции и конфиги:
- `isolation/no-mixed-imports.js`: добавить `lib/i18n/**` в `DEFAULT_NEW`
(копия по-прежнему old через `lib/v1/**`);
- `eslint-plugins/__tests__/no-mixed-imports.test.ts`: кейсы «old → i18n
чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под
новую классификацию: old→новый i18n = нарушение; добавить фикстуру
`__fixtures__/src/lib/v1/i18n/t.ts` для кейса old-копия;
- `eslint.config.js`: в `allowDefaultProject` добавить фикстуру
`eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts`; поправить комментарий
изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;
- `eslint-plugins/README.md`: убрать i18n из списка shared (§ Isolation).
- **0.6** Новый `lib/i18n/dict.ts`: `CategoryId` переключить с
`../v1/categories` на `../categories` (новый структурно-идентичный тип) —
скрытых v1-зависимостей в новом i18n не остаётся.
- **0.7** Проверка форка: `pnpm --dir web lint`, `check`, `test` (v1-копия
тестов подхватывается `vitest include: src/**/*.test.ts`), `test:rules`;
смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих
версий согласованы.
- **0.8** Добавить `lib/i18n/schema-tool-strings.ts` поверх `$lib/registry`
(новый тип `ToolEntry`):
- `toolTitle(tool)`, `toolDescription(tool)` с фолбэком на registry English;
- `fieldLabel(field, id)` — перевод по ключу из `fields`-секции словаря (см.
решение C), фолбэк на `labelOf(id)`;
- `groupLabel(title)` — перевод по ключу из `groups`-секции словаря (см.
решение A);
- `verdictText(toolId, key)` — рендер вердикта по ключу;
- `verdictTone(key)``success`|`danger`|`info` по имени ключа
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
- секция `preview` **не заводится**: строки нового UI живут в собственных
категориях — расширяем существующие (`catalog`, `header`, `dropZone`,
`sourceCard`, `resultCard`, `paramsCard`, `textResult`, `ui`) и заводим две
маленькие новые: `actions` (Generate / Open image / Download result…) и
`textSource` (placeholder, Try sample…);
- новая глобальная секция `fields: Record<string, string>` — переводы подписей
полей по ключу из схемы (решение C);
- новая глобальная секция `groups: Record<string, string>` — переводы
заголовков групп полей (решение A).
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `actions`, `textSource`,
`fields`, `groups` и ключами недостающих строк в существующих секциях.
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
title/description в ru.ts новой ветки (по категориям — несколько коммитов).
- **1.3** Добавить в `ru.ts` ключи `fields`/`groups` для используемых в схемах
ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза
5).
- **1.4** Добавить `tools[id].options[fieldId][value]` для select-полей, чьи
опции нужно локализовать.
- **1.5** Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть
перевод в обоих словарях.
### Фаза 2: LangToggle → реальный locale
- **2.1** `LangToggle.svelte`: убрать локальный `lang`, подключить
`getLocale()`, `setLocale()`, `LOCALES`. Модель — language switch из
`routes/v1/+layout.svelte`.
- **2.2** Проверить e2e `navigation.spec.ts` «language toggle marks the active
button» — должен проходить без изменений.
### Фаза 3: Routes + layout
- **3.1** `+layout.svelte`: breadcrumbs через `t()`, динамический `lang`.
- **3.2** `+page.svelte`, `list-tools/+page.svelte`: footer-строка →
`t("header.footerNote")`.
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
### Фаза 4: Компоненты — основной блок (коммиты кучками по смыслу)
Все текстовые подписи в компонентах — информативные, сохраняем и переводим на
`t`/хелперы. Каждый коммит: замена строк + lint/check.
- `SchemaToolView.svelte`: title/description →
`toolTitle()`/`toolDescription()`, перевести `TOOL SETTINGS`, «Configure
output» и «This tool has no schema yet.».
- `SchemaSourceTile.svelte`: перевести figcaption `SOURCE`, «choose an
image»/«no source…» и alt.
- `SchemaResultTile.svelte`: перевести figcaption `RESULT`, «no result yet» и
alt; суффикс «parts» — в `PreviewTile`.
- `SchemaPreview.svelte`: перевести панельный лейбл `PREVIEW PANEL` и
meta-капшены `SOURCE / RESULT / FORMAT`; значения «parts»/«text» → `t()`.
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)`
`fieldLabel(field)`.
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
- `SchemaTextResult.svelte`: перевести лейбл `RESULT` у вердикта, Copy /
Download .txt; tone detection → `verdictTone()`.
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
`t("categories.X")`, поисковый placeholder.
- `CatalogHeader.svelte`: перевести heading/lead и счётчик «TOOLS AVAILABLE».
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
- Schema field controls (по одному или группой): Width/Height, From/To, Backing
plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color»,
«Remove color».
- `TopNav.svelte`, `ThemeToggle.svelte`, `CodeBlock.svelte`, `StepCard.svelte`,
`Footer.svelte`.
### Фаза 5: Вердикты + ключи в schema
- **5.1** Анализаторы в `registry/analyze.ts`: возвращают ключи (`"verifyYes"`
вместо raw English).
- **5.2** `SchemaPreview.svelte`: рендер вердикта через
`verdictText(tool.id, key)`.
- **5.3** Layout groups в `registry/*.ts` (решение A): `title` → ключ
(`title: "groups.canvas"`); в `SchemaFields.svelte``groupLabel(title)`. В
`Dict` — глобальная секция `groups`.
- **5.4** Лейблы полей в `registry/*.ts` (решение C): на поле в схеме
проставляется `label`-ключ (`field.number({ label: "fields.thickness", … })`),
вывод в UI — `fieldLabel(field)` = `t(label)`. В `Dict` — глобальная секция
`fields`.
- **5.5** Категории в `catalog.ts`: `GROUP_LABELS``t("categories.X")` (ключи
уже есть в dict).
### Фаза 6: Тесты
- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry:
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
- **6.3** В новой ветке `lib/i18n` появится собственный `smoke-i18n.test.ts`
(аналог v1: переключение локали, `html lang`, контент без смеси языков) —
v1-копия теста не трогается.
### Фаза 7: Кросс-языковой поиск
Переключить поиск в новом каталоге с `toLowerCase()` на
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
решение D). Последний шаг, аккуратный отдельный коммит.
### Фаза 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. Решения
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
`t(title)` с фолбэком на сырой ключ.
- **C. Лейблы полей**: в схеме задаём i18n-ключ
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
фолбэк на текущий `labelOf(id)` для несопоставленных.
- **D. Объём**: переводим всё сразу — и каркас, и контент реестра, и вердикты, и
кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие
атомарные шаги/коммиты.
## 6. Проверки
- `pnpm --dir web lint`
- `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`
- `pnpm --dir web test`
- `pnpm --dir web test:rules` (если менялся eslint/isolation-конфиг)
- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
вердикт — без смеси языков; `html lang` следует за локалью.
+122
View File
@@ -0,0 +1,122 @@
# Аудит использования - UX\UI
## везде
- возможность переключать пиксели\проценты
- инструменты без параметров выглядят странно, пересмотреть, может добавить какую-то надпись "нет параметров"
- удаление\маски и т.д. - надо добавить галочку сглаживание
- производительность: почему flip заметно тормозит, но нет индикации работы инструмента (также sharpen-png)
- производительность: исследовать, есть ли смысл на инструментах геометрии выводить рамку результата до полноценной работы (например на skew \ rotate будет удобнее)
## аудит инструментов
### transparency
- add-stroke-png: добавить галочку "расширить на толщину линии"
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо расширять на толщину линии
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле нет
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях, пересмотреть форму и параметры. как будто неудобно конфигурировать
- remove-alpha-channel-png: выбор цвета фона, как будет работать с полупрозрачной картинкой?
- extract-alpha-mask-png: галочка инвертировать маску
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в процентах, нету сглаживания по вырезанным пикселям
- invert-alpha-png: края со сглаживанием странно обрабатываются, может галочка\слайдер обрезать сглаживание?
- remove-background-png: возможность выбора точки, от которой удалять (или отдельный инструмент?)
- make-thicker-png: расширять картинку на толщину линии
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то остается выступ
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен? другой инструмент?)
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти тест кейс для него
- despeckle-alpha-png: найти более заметный тест-кейс
- close-holes-png: проверить, как должен работать, картинки с большой полупрозрачной областью - закрывает всё, по описанию должен закрывать только небольшие дырки
### colors
- two-colors-png: в range slider добавить описание что это luminance threshold
- gamma-png: проверить правильность работы - яркое изображение выглядит не контрастным, проверить максимум и минимум
- temperature-png: проверить правильность работы - не слишком ли отдает желтым\синим?
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле и нет выбора порога luminance
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
- sepia-png: не нужна настройка силы эффекта?
- extract-channel-png: мульти вывод сразу в 3 канала?
- swap-channels-png: обдумать описание подобных инструментов через flow более простых?
- black-and-white-png: аналог two-colors-png, только без выбора цветов
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
- auto-contrast-png: найти тестовый кейс
- decrease-color-count-png: сделать кнопки вместо селекта
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
- png-to-hsv: тоже самое ^
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
- png-to-cmyk: тоже самое ^
- png-to-lab: тоже самое ^
### geometry
- add-border-png: прозрачность цвета (аудит полей)
- fit-on-background-png: цвет
- change-canvas-size-png: использовать position9 вместо селекта
- resize-png: по умолчанию размеры 0х0, надо поменять; выбор метода масштабирования
- crop-png: offset - слишком большие значения
- rotate-png: кнопки вместо select
- flip-png: кнопки вместо select
- add-padding-png: чем отличается от add-border-png?
- center-by-alpha-png: найти тест кейс
- zoom-png: выбор точки центра
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю - переключение 0-100 проценты и 0-255)
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг результата, а не вокруг div
- swap-orientation-png: кнопки вместо select
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не работают (в зависимости от выбора `axis`)
- shift-png: цвет (прозрачность)
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
### filters
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста силы (как называется?)
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков делить. seed - поменять
- add-noise-png: кнопки вместо select, seed - поменять
- sharpen-png: добавить тест кейс
- silhouette-png: аналог extract-alpha-mask-png?
### text
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые буквы будут обрезаться - y, g)
- watermark-tile-png: заполняет не полностью
### analyze
- extract-color-from-png: надо описать или дать выбор на основе какого параметра работает threshold
- show-transparent-png: кнопки вместо select
- show-grayscale-pixels-png: кнопки вместо select
- show-color-pixels-png: кнопки вместо select
- light-pixel-mask-png: кнопки вместо select
- verify-is-png: название неверное, в описании указано изображение, а фактически проверяется текст
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный вердикт? может отдельные поля - 0\1, yes\no?
### generators
- color-spectrum-png: кнопки вместо select
- draw-grid-png: выбор цвета фона
- placeholder-png: с галочкой "показать текст" нет заполнения фона
- blend-two-png: высота не задается
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли маленький max steps?
- emoji-to-png: выбор эмодзи
- color-wheel-png: сглаживание краев
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота не задается
- triadic-png: кнопки вместо select, высота не задается
- tetradic-png: кнопки вместо select, высота не задается
- analogous-png: кнопки вместо select, высота не задается
- monochromatic-png: кнопки вместо select, высота не задается
- shades-png: кнопки вместо select, высота не задается
- mix-colors-png: высота не задается
- sort-colors-png: кнопки вместо select, высота не задается
## аудит полей
- position offset: должны быть отдельные range slider на x и y
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
- поле seed (random) - range slider не нужен, поле для ввода и "randomize" кнопка
-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)
_расхождений нет_
+2 -2
View File
@@ -1,7 +1,7 @@
import { expect, test } from "playwright/test"; import { expect, test } from "playwright/test";
import { expectNoErrors, trackErrors } from "./helpers/page"; import { expectNoErrors, trackErrors } from "./helpers/page";
const TOTAL = 121; const TOTAL = 122;
const GROUPS = 8; const GROUPS = 8;
test("catalog shows total and all groups", async ({ page }) => { test("catalog shows total and all groups", async ({ page }) => {
@@ -33,7 +33,7 @@ test("category filter shows only matching group and resets on ALL", async ({
await page.getByRole("button", { name: "CONVERT" }).click(); await page.getByRole("button", { name: "CONVERT" }).click();
await expect(page.locator(".catalog-group")).toHaveCount(1); await expect(page.locator(".catalog-group")).toHaveCount(1);
}).toPass(); }).toPass();
await expect(page.locator(".catalog-group")).toContainText("CONVERT"); await expect(page.locator(".catalog-group")).toContainText("Convert");
const convertCards = await page.locator(".tool-card").count(); const convertCards = await page.locator(".tool-card").count();
expect(convertCards).toBeGreaterThan(0); expect(convertCards).toBeGreaterThan(0);
expect(convertCards).toBeLessThan(allCount); expect(convertCards).toBeLessThan(allCount);
+1 -1
View File
@@ -12,7 +12,7 @@ test.describe("generators — known UI gap (#checklist, п.1)", () => {
const generate = page.getByRole("button", { name: "Generate" }); const generate = page.getByRole("button", { name: "Generate" });
await expect(generate).toBeVisible(); await expect(generate).toBeVisible();
await generate.click(); await generate.click();
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
}); });
test("create-empty-png page opens without errors", async ({ page }) => { test("create-empty-png page opens without errors", async ({ page }) => {
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "playwright/test";
import { opaquePng } from "./helpers/fixtures";
import { openTool, uploadImage } from "./helpers/page";
test("поиск каталога находит по названию из другой локали", async ({
page,
}) => {
await page.goto("/list-tools");
const input = page.locator(".catalog-search input");
const flipCard = page.locator('a.tool-card[href*="flip-png"]');
await input.fill("отразить");
await expect(flipCard).toContainText("Flip PNG");
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(flipCard).toContainText("Отразить PNG");
await input.fill("flip");
await expect(flipCard).toContainText("Отразить PNG");
});
test("RU/EN: переключение переводит заголовок инструмента", async ({
page,
}) => {
await openTool(page, "resize-png");
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
const enBtn = page.getByRole("button", { name: "EN", exact: true });
await expect(async () => {
await ruBtn.click();
await expect(page.locator(".schema-tool h1")).toHaveText(
"Изменить размер PNG",
);
}).toPass();
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
await enBtn.click();
await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG");
});
// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при
// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная
// ошибка: важно, что её текст локализован, а не сырой ключ.
// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
const alert = page.locator("[role='alert']");
await expect(alert).toBeVisible();
const text = (await alert.textContent()) ?? "";
expect(text).not.toMatch(/^errors\./);
expect(text.length).toBeGreaterThan(3);
});
+5 -16
View File
@@ -2,10 +2,11 @@ import { expect, test } from "playwright/test";
import { opaquePng } from "./helpers/fixtures"; import { opaquePng } from "./helpers/fixtures";
import { openTool, uploadImage } from "./helpers/page"; import { openTool, uploadImage } from "./helpers/page";
// FIXME: Весь файл зарегистрированные баги preview (см. docs/checklist-manual-testing.md // FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme // → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и // означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
// тест станет зелёным «сам по себе». // тест станет зелёным «сам по себе». (Тест про локализацию ошибок переехал в
// i18n.spec.ts — он больше не issue.)
test.describe("known bugs — documented as fixme", () => { test.describe("known bugs — documented as fixme", () => {
test.fixme("resize-png: upload produces a resized result (no alert)", async ({ test.fixme("resize-png: upload produces a resized result (no alert)", async ({
@@ -14,7 +15,7 @@ test.describe("known bugs — documented as fixme", () => {
await openTool(page, "resize-png"); await openTool(page, "resize-png");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator("[role='alert']")).toHaveCount(0); await expect(page.locator("[role='alert']")).toHaveCount(0);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
}); });
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({ test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
@@ -23,18 +24,6 @@ test.describe("known bugs — documented as fixme", () => {
await openTool(page, "crop-png"); await openTool(page, "crop-png");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator("[role='alert']")).toHaveCount(0); await expect(page.locator("[role='alert']")).toHaveCount(0);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
});
test.fixme("error message is localized, not a raw i18n key", async ({
page,
}) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
const alert = page.locator("[role='alert']");
await expect(alert).toBeVisible();
const text = (await alert.textContent()) ?? "";
expect(text).not.toMatch(/^errors\./);
expect(text.length).toBeGreaterThan(3);
}); });
}); });
+1 -1
View File
@@ -27,7 +27,7 @@ test("unknown tool route responds 404 on static build", async ({ page }) => {
// 36 | expect(["light", "dark"]).toContain(before); // 36 | expect(["light", "dark"]).toContain(before);
// 37 | expect(["light", "dark"]).toContain(after); // 37 | expect(["light", "dark"]).toContain(after);
// > 38 | expect(stored).toBe(after); // > 38 | expect(stored).toBe(after);
test("theme toggle flips preview theme and persists to localStorage", async ({ test.fixme("theme toggle flips preview theme and persists to localStorage", async ({
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
+24 -22
View File
@@ -1,29 +1,29 @@
import { expect, test } from "playwright/test"; import { expect, test } from "playwright/test";
import { import {
opaquePng,
landscapePng,
onePixelPng,
largePng,
corruptPng, corruptPng,
landscapePng,
largePng,
onePixelPng,
opaquePng,
} from "./helpers/fixtures"; } from "./helpers/fixtures";
import { import {
trackErrors,
openTool,
uploadImage,
metaValue,
suggestedDownloadName,
expectNoErrorAlert, expectNoErrorAlert,
expectNoErrors, expectNoErrors,
metaValue,
openTool,
suggestedDownloadName,
trackErrors,
uploadImage,
} from "./helpers/page"; } from "./helpers/page";
test("flip-png: full flow upload, result, meta, download", async ({ test("flip-png: full flow - upload, result, meta, download", async ({
page, page,
}) => { }) => {
const sink = trackErrors(page); const sink = trackErrors(page);
await openTool(page, "flip-png"); await openTool(page, "flip-png");
await uploadImage(page, landscapePng); await uploadImage(page, landscapePng);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await expect(page.locator('img[alt="result"]')).toHaveAttribute( await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
"src", "src",
/^(blob:|data:image\/png;base64,)/, /^(blob:|data:image\/png;base64,)/,
); );
@@ -42,7 +42,7 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
const sink = trackErrors(page); const sink = trackErrors(page);
await openTool(page, "convert-png-to-jpg"); await openTool(page, "convert-png-to-jpg");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
const downloadName = await suggestedDownloadName( const downloadName = await suggestedDownloadName(
page, page,
'button[aria-label="Download result"]', 'button[aria-label="Download result"]',
@@ -52,12 +52,12 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
expectNoErrors(sink); expectNoErrors(sink);
}); });
test("reset clears result but keeps source", async ({ page }) => { test.fixme("reset clears result but keeps source", async ({ page }) => {
await openTool(page, "flip-png"); await openTool(page, "flip-png");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await page.getByRole("button", { name: "Reset" }).click(); await page.getByRole("button", { name: "Reset" }).click();
await expect(page.locator('img[alt="result"]')).toHaveCount(0); await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
await expect(page.locator(".empty")).toContainText("no result yet"); await expect(page.locator(".empty")).toContainText("no result yet");
await expect(page.locator('img[alt="source"]')).toBeVisible(); await expect(page.locator('img[alt="source"]')).toBeVisible();
}); });
@@ -65,11 +65,13 @@ test("reset clears result but keeps source", async ({ page }) => {
test("blur: changing slider updates result image", async ({ page }) => { test("blur: changing slider updates result image", async ({ page }) => {
await openTool(page, "blur-png"); await openTool(page, "blur-png");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
const src1 = await page.locator('img[alt="result"]').getAttribute("src"); const src1 = await page
.locator('img[alt="Result image"]')
.getAttribute("src");
await expect(async () => { await expect(async () => {
await page.locator('input[type="range"]').fill("20"); await page.locator('input[type="range"]').fill("20");
await expect(page.locator('img[alt="result"]')).not.toHaveAttribute( await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute(
"src", "src",
src1 ?? "", src1 ?? "",
); );
@@ -88,7 +90,7 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
const sink = trackErrors(page); const sink = trackErrors(page);
await openTool(page, "flip-png"); await openTool(page, "flip-png");
await uploadImage(page, landscapePng); await uploadImage(page, landscapePng);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
await expectNoErrorAlert(page); await expectNoErrorAlert(page);
expectNoErrors(sink); expectNoErrors(sink);
await context.close(); await context.close();
@@ -99,7 +101,7 @@ test("invalid file upload shows error, no crash", async ({ page }) => {
await openTool(page, "flip-png"); await openTool(page, "flip-png");
await uploadImage(page, corruptPng); await uploadImage(page, corruptPng);
await expect(page.locator('[role="alert"]')).toBeVisible(); await expect(page.locator('[role="alert"]')).toBeVisible();
await expect(page.locator('img[alt="result"]')).toHaveCount(0); await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
expectNoErrors(sink); expectNoErrors(sink);
}); });
@@ -108,7 +110,7 @@ for (const fixture of [onePixelPng, largePng]) {
const sink = trackErrors(page); const sink = trackErrors(page);
await openTool(page, "flip-png"); await openTool(page, "flip-png");
await uploadImage(page, fixture); await uploadImage(page, fixture);
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
expectNoErrors(sink); expectNoErrors(sink);
}); });
} }
+12 -12
View File
@@ -1,17 +1,17 @@
import { expect, test } from "playwright/test"; import { expect, test } from "playwright/test";
import { import {
opaquePng, opaquePng,
transparentPng,
tinyBase64,
svgMarkup,
pixelRow, pixelRow,
svgMarkup,
tinyBase64,
transparentPng,
} from "./helpers/fixtures"; } from "./helpers/fixtures";
import { import {
trackErrors,
openTool,
uploadImage,
expectNoErrorAlert, expectNoErrorAlert,
expectNoErrors, expectNoErrors,
openTool,
trackErrors,
uploadImage,
} from "./helpers/page"; } from "./helpers/page";
for (const [id, input] of [ for (const [id, input] of [
@@ -30,7 +30,7 @@ for (const [id, input] of [
await openTool(page, id); await openTool(page, id);
await page.locator(".text-source textarea").fill(input); await page.locator(".text-source textarea").fill(input);
await page.getByRole("button", { name: "Render text" }).click(); await page.getByRole("button", { name: "Render text" }).click();
await expect(page.locator('img[alt="result"]')).toBeVisible(); await expect(page.locator('img[alt="Result image"]')).toBeVisible();
}).toPass({ timeout: 25_000 }); }).toPass({ timeout: 25_000 });
await expectNoErrorAlert(page); await expectNoErrorAlert(page);
expectNoErrors(sink); expectNoErrors(sink);
@@ -50,8 +50,8 @@ test("png-to-base64 shows decoded text result", async ({ page }) => {
}); });
for (const [input, expected] of [ for (const [input, expected] of [
[tinyBase64, "Yes — valid PNG signature."], [tinyBase64, "Yes — this is a valid PNG signature."],
["aGVsbG8=", "No — the content is not a PNG."], ["aGVsbG8=", "No — the signature does not match a PNG file."],
] as const) { ] as const) {
test(`verify-is-png verdict: ${expected}`, async ({ page }) => { test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
await expect(async () => { await expect(async () => {
@@ -67,7 +67,7 @@ test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
await openTool(page, "png-is-transparent"); await openTool(page, "png-is-transparent");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator(".verdict-text")).toContainText( await expect(page.locator(".verdict-text")).toContainText(
"No — fully opaque.", "No — all pixels are fully opaque.",
); );
}); });
@@ -75,7 +75,7 @@ test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
await openTool(page, "png-is-transparent"); await openTool(page, "png-is-transparent");
await uploadImage(page, transparentPng); await uploadImage(page, transparentPng);
await expect(page.locator(".verdict-text")).toContainText( await expect(page.locator(".verdict-text")).toContainText(
"Yes — has transparency.", "Yes — there are transparent or semi-transparent pixels.",
); );
}); });
@@ -83,7 +83,7 @@ test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
await openTool(page, "png-is-grayscale"); await openTool(page, "png-is-grayscale");
await uploadImage(page, opaquePng); await uploadImage(page, opaquePng);
await expect(page.locator(".verdict-text")).toContainText( await expect(page.locator(".verdict-text")).toContainText(
"No — contains colors.", "No — colored pixels were found.",
); );
}); });
+5 -5
View File
@@ -1,12 +1,12 @@
import { expect, test } from "playwright/test"; import { expect, test } from "playwright/test";
import { opaquePng, transparentPng } from "./helpers/fixtures";
import type { SourceFile } from "./helpers/fixtures"; import type { SourceFile } from "./helpers/fixtures";
import { opaquePng, transparentPng } from "./helpers/fixtures";
import { import {
trackErrors,
openTool,
uploadImage,
expectNoErrorAlert, expectNoErrorAlert,
expectNoErrors, expectNoErrors,
openTool,
trackErrors,
uploadImage,
} from "./helpers/page"; } from "./helpers/page";
type Kind = "image" | "text-out" | "verdict"; type Kind = "image" | "text-out" | "verdict";
@@ -101,7 +101,7 @@ test.describe("smoke: tools produce output without errors", () => {
const outputLocator = (): ReturnType<typeof page.locator> => { const outputLocator = (): ReturnType<typeof page.locator> => {
switch (kind) { switch (kind) {
case "image": case "image":
return page.locator('img[alt="result"]'); return page.locator('img[alt="Result image"]');
case "text-out": case "text-out":
return page.locator(".result-pre code"); return page.locator(".result-pre code");
case "verdict": case "verdict":
+37 -5
View File
@@ -9,9 +9,11 @@
- `web/eslint-plugins/` — локальные ESLint-плагины. - `web/eslint-plugins/` — локальные ESLint-плагины.
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов. - `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
- `isolation/` — правило изоляции old/new UI. - `isolation/` — правило изоляции old/new UI.
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`). - `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь - `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
токенов) и файлы для изоляционных тестов. токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
- `web/scripts/`: - `web/scripts/`:
- `lint-all.mjs` — оркестратор `lint:all`. - `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`. - `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
@@ -62,6 +64,35 @@
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от (ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.) `svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
## Правило плагина i18n (`i18n/dict-consistency`)
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
словарь подхватывается без правки правила.
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
расхождения:
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
пропавшая целая секция репортится один раз (не по каждому потомку);
- **пустые значения**: `""` и строка из одних пробелов;
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
— ловит потерянную при переводе переменную ровно один раз, даже когда
отклоняется одна-единственная локаль.
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
(синтакс, без типов).
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
(осознанные отклонения до достижения zero-warn).
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
диске.
## Токены-префиксы (целевой словарь дизайна) ## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две - Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
@@ -132,10 +163,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,24 @@
import type { Dict } from "./dict";
export const de: Dict = {
header: {
workspace: "Arbeitsbereich",
catalog: "Katalog",
},
home: {
hero: "Hallo {name}!",
restoreLast: "{title} wiederherstellen",
},
errors: {
sourceRequired: "Quelle erforderlich",
},
tools: {
addBorder: {
title: "Rahmen hinzufügen",
description: "Zeichnet einen {kind}-Rahmen.",
results: {
done: "Rahmen {what}",
},
},
},
};
@@ -0,0 +1,16 @@
export const LOCALES = ["en", "ru", "de"] as const;
export const BASE_LOCALE: "en" = "en";
export type Dict = {
header: Record<string, string>;
home: Record<string, string>;
errors: Record<string, string>;
tools: Record<string, ToolStrings>;
};
export type ToolStrings = {
title?: string;
description?: string;
results?: Record<string, string>;
};
@@ -0,0 +1,24 @@
import type { Dict } from "./dict";
export const en: Dict = {
header: {
workspace: "Workspace",
catalog: "Catalog",
},
home: {
hero: "Hello {name}!",
restoreLast: "Restore {title}",
},
errors: {
sourceRequired: "Source image required",
},
tools: {
addBorder: {
title: "Add border",
description: "Draws a {kind} frame.",
results: {
done: "Border {what}",
},
},
},
};
@@ -0,0 +1,24 @@
import type { Dict } from "./dict";
export const ru: Dict = {
header: {
workspace: "Рабочая область",
catalog: "Каталог",
},
home: {
hero: "Привет, {name}!",
restoreLast: "Вернуть {title}",
},
errors: {
sourceRequired: "Нужен исходник",
},
tools: {
addBorder: {
title: "Добавить рамку",
description: "Рисует рамку {kind}.",
results: {
done: "Рамка {what}",
},
},
},
};
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1,146 @@
// Tests for i18n/dict-consistency (web/eslint-plugins/i18n/dict-consistency.js).
//
// The rule reads its own dict dir from disk (LOCALES in dict.ts + sibling
// locale files), so it runs through the Linter API with cwd pinned to the
// fixture tree (see __tests__/helpers.ts). The committed fixtures at
// __fixtures__/src/lib/i18n/ are mutually consistent (zero-warn) — tests feed
// MODIFIED variants of en.ts / ru.ts as the linted code so each check is
// exercised against the on-disk references.
//
// Reporting contract: each file reports only its OWN gaps. A key missing in a
// file produces exactly ONE `missingKey` warning, emitted on the incomplete
// file itself (the union of all keys comes from the on-disk dicts), regardless
// of how many other locales have the key. Placeholder parity is compared
// against the canonical set — the one shared by the most locales (ties by
// LOCALES order) — so a deviation is reported exactly once.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import tseslint from "typescript-eslint";
import dictConsistency from "../i18n/dict-consistency.js";
import {
asRuleModule,
FIXTURES_SRC,
verifyInFixtures,
type FlatConfig,
} from "./helpers.js";
const I18N_DIR = path.join(FIXTURES_SRC, "lib", "i18n");
const fixture = (name: string) =>
fs.readFileSync(path.join(I18N_DIR, name), "utf8");
function lint(code: string, relFile: string, options?: unknown) {
const rule = options ? ["warn", options] : "warn";
const config: FlatConfig = [
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
{
files: ["**/*.ts"],
plugins: {
i18n: {
rules: { "dict-consistency": asRuleModule(dictConsistency) },
},
},
rules: { "i18n/dict-consistency": rule as "warn" },
},
];
return verifyInFixtures(config, code, relFile);
}
function messageIds(messages: { messageId?: string }[]) {
return messages.map((m) => m.messageId);
}
describe("i18n/dict-consistency", () => {
it("stays clean on the consistent fixture dicts", () => {
expect(messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts"))).toEqual([]);
expect(messageIds(lint(fixture("ru.ts"), "lib/i18n/ru.ts"))).toEqual([]);
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
});
it("flags a leaf key missing in the current file", () => {
const enMissing = fixture("en.ts").replace(
'\t\trestoreLast: "Restore {title}",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"home.restoreLast"');
expect(messages[0].message).toContain(" in en");
});
it("reports a missing whole section once, not per child key", () => {
const enNoHeader = fixture("en.ts").replace(/\theader: \{[\s\S]*?\},/, "");
const messages = lint(enNoHeader, "lib/i18n/en.ts");
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"header"');
expect(messages[0].message).not.toContain("header.workspace");
});
it("emits ONE warning per gap even when several locales own the key", () => {
const enMissing = fixture("en.ts").replace(
'\t\tsourceRequired: "Source image required",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"errors.sourceRequired"');
expect(messages[0].message).toContain(" in en");
});
it("flags empty and whitespace-only values", () => {
const enEmpty = fixture("en.ts")
.replace('workspace: "Workspace",', 'workspace: "",')
.replace('catalog: "Catalog",', 'catalog: " ",');
const messages = lint(enEmpty, "lib/i18n/en.ts");
expect(messageIds(messages)).toEqual(["emptyValue", "emptyValue"]);
expect(messages[0].message).toContain('"header.workspace"');
expect(messages[0].message).toContain(" in en");
expect(messages[1].message).toContain('"header.catalog"');
});
it("flags a placeholder lost in translation (vs the canonical set)", () => {
const ruLostPlaceholder = fixture("ru.ts").replace(
'hero: "Привет, {name}!",',
'hero: "Привет!",',
);
const messages = lint(ruLostPlaceholder, "lib/i18n/ru.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
expect(messages[0].message).toContain('"home.hero"');
expect(messages[0].message).toContain("{} vs expected {name}");
});
it("flags an extra placeholder introduced in one locale", () => {
const enExtraPlaceholder = fixture("en.ts").replace(
'description: "Draws a {kind} frame.",',
'description: "Draws a {kind} frame by {author}.",',
);
const messages = lint(enExtraPlaceholder, "lib/i18n/en.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
expect(messages[0].message).toContain('"tools.addBorder.description"');
expect(messages[0].message).toContain("{author, kind} vs expected {kind}");
});
it("honours allowPaths for conscious deviations", () => {
const enMissing = fixture("en.ts").replace(
'\t\tsourceRequired: "Source image required",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", {
allowPaths: ["errors.sourceRequired"],
});
expect(messageIds(messages)).toEqual([]);
});
it("ignores files that are not locale dicts", () => {
const notALocale = `
export const helper = { a: "b" };
`;
expect(messageIds(lint(notALocale, "lib/i18n/t.ts"))).toEqual([]);
expect(messageIds(lint(notALocale, "lib/i18n/misc.ts"))).toEqual([]);
});
});
@@ -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",
);
});
}); });
+341
View File
@@ -0,0 +1,341 @@
/**
* Cross-locale dictionary linter (i18n/dict-consistency).
*
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
* against the base locale). The set of locales comes from `LOCALES` in the
* sibling `dict.ts`, so a new locale is picked up automatically no rule
* changes needed. The rule only ever warns (a missing translation must not
* break the build) see docs/plan-preview-i18n.md §Фаза 8.
*
* The diagnostic lives where the fix lives each file reports its OWN gaps:
* - missing keys: the union of all keys is collected from the on-disk dicts;
* whatever the current file lacks is reported here (a missing whole
* section is reported once, not per descendant key);
* - empty values: `""` or whitespace-only values;
* - placeholders: for a shared key the `{name}` set is compared against the
* canonical set (the one shared by most locales, ties broken by LOCALES
* order) catches a variable lost in translation exactly once, even when
* only one locale deviates.
*
* The rule self-filters: files whose basename is not one of LOCALES are
* ignored, so it can be attached to the whole `lib/i18n/` directory.
*
* Options (object, all optional):
* - allowPaths: dot-path keys excluded from every check (conscious
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
*/
import fs from "node:fs";
import path from "node:path";
import tseslint from "typescript-eslint";
const PLACEHOLDER = /\{(\w+)\}/g;
/** Parse a TS source string into an ESTree Program (syntax only). */
function parseTs(code, filename) {
const result = tseslint.parser.parseForESLint(code, {
filePath: filename,
ecmaVersion: "latest",
sourceType: "module",
});
return result.ast;
}
/** Unwrap `as const` / `satisfies` wrappers around a value node. */
function unwrap(node) {
while (
node &&
(node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression")
) {
node = node.expression;
}
return node;
}
/**
* The exported dict `export const <name>: Dict = {...}` the declarator id
* (for reporting) and the object literal, or null.
*/
function findDictExport(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
const decl = node.declaration;
if (!decl || decl.type !== "VariableDeclaration") continue;
const d = decl.declarations[0];
if (!d || d.id.type !== "Identifier") continue;
const init = unwrap(d.init);
if (init && init.type === "ObjectExpression") {
return { id: d.id, object: init };
}
}
return null;
}
/** The `LOCALES` list exported from `dict.ts`, or null. */
function findLocalesList(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
const decl = node.declaration;
if (!decl || decl.type !== "VariableDeclaration") continue;
for (const d of decl.declarations) {
if (d.id.type !== "Identifier" || d.id.name !== "LOCALES") continue;
const init = unwrap(d.init);
if (init && init.type === "ArrayExpression") {
return init.elements
.map((el) => (isStringLiteral(el) ? el.value : null))
.filter((v) => typeof v === "string");
}
}
}
return null;
}
/**
* String literal node. The parser emits `Literal` in some ESTree versions and
* `StringLiteral` in others accept both.
*/
function isStringLiteral(node) {
if (!node) return false;
if (node.type === "StringLiteral") return true;
return node.type === "Literal" && typeof node.value === "string";
}
const OBJECT = "object";
const STRING = "string";
const OPAQUE = "opaque";
/** Leaf kind of a property value (objects are followed, the rest are opaque). */
function entryKind(node) {
if (node.type === "ObjectExpression") return OBJECT;
if (isStringLiteral(node)) return STRING;
if (
node.type === "TemplateLiteral" &&
node.expressions.length === 0 &&
node.quasis.length === 1
) {
return STRING;
}
return OPAQUE;
}
function entryValue(node) {
if (isStringLiteral(node)) return node.value;
if (node.type === "TemplateLiteral") return node.quasis[0].value.cooked;
return null;
}
function propKey(prop) {
if (prop.key.type === "Identifier") return prop.key.name;
if (isStringLiteral(prop.key)) return prop.key.value;
return null;
}
/**
* Flatten an exported dict object into `path -> { node, kind, value }`.
* Computed properties are skipped; intermediate objects are recorded too, so a
* missing whole section is reported once (children of a missing path are
* skipped when reporting).
*/
function buildKeyMap(objectNode) {
const map = new Map();
const stack = [[objectNode, []]];
while (stack.length > 0) {
const [obj, prefix] = stack.pop();
for (const prop of obj.properties) {
if (prop.type !== "Property" || prop.computed) continue;
const key = propKey(prop);
if (key == null) continue;
const dot = [...prefix, key].join(".");
const kind = entryKind(prop.value);
map.set(dot, {
node: prop,
kind,
value: kind === STRING ? entryValue(prop.value) : null,
});
if (kind === OBJECT) stack.push([prop.value, [...prefix, key]]);
}
}
return map;
}
function placeholders(value) {
const set = new Set();
for (const m of String(value).matchAll(PLACEHOLDER)) set.add(m[1]);
return set;
}
function formatSet(set) {
return `{${[...set].sort().join(", ")}}`;
}
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
function* ancestorPaths(key) {
let i = key.lastIndexOf(".");
while (i !== -1) {
yield key.slice(0, i);
i = key.lastIndexOf(".", i - 1);
}
}
// Sibling dicts are read once per directory and cached for the whole lint run
// (mirrors how no-undefined-in-svelte caches the token dictionary).
const dirCache = new Map();
function loadDir(dir) {
if (!dirCache.has(dir)) dirCache.set(dir, readDir(dir));
return dirCache.get(dir);
}
function readDir(dir) {
const dictPath = path.join(dir, "dict.ts");
if (!fs.existsSync(dictPath)) return null;
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
const locales = findLocalesList(ast);
if (!locales || locales.length === 0) return null;
const dicts = [];
for (const locale of locales) {
const file = path.join(dir, `${locale}.ts`);
if (!fs.existsSync(file)) {
// Declared in LOCALES but not yet translated — treated as an empty
// dict so every key it lacks reports `missing key ... in <locale>`.
dicts.push({ locale, map: new Map() });
continue;
}
const dictAst = parseTs(fs.readFileSync(file, "utf8"), file);
const dictExport = findDictExport(dictAst);
if (!dictExport) continue;
dicts.push({ locale, map: buildKeyMap(dictExport.object) });
}
// Union of every key across all locales (intermediate paths included), so a
// file can report exactly what it is missing.
const unionPaths = new Set();
// Non-empty string leaves per key: loccales contribute to the canonical
// placeholder set voting (empty values are already broken on their own).
const stringLeaves = new Map();
for (const d of dicts) {
for (const [key, entry] of d.map) {
unionPaths.add(key);
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
stringLeaves.get(key).push(placeholders(entry.value));
}
}
}
// Canonical placeholder set per key: the set shared by the most locales;
// on a tie the first one encountered (LOCALES order) wins.
const canonicalSets = new Map();
for (const [key, sets] of stringLeaves) {
const counts = new Map();
for (const set of sets) {
const sig = [...set].sort().join("\u0000");
counts.set(sig, (counts.get(sig) || 0) + 1);
}
let bestCount = 0;
for (const set of sets) {
const sig = [...set].sort().join("\u0000");
if (counts.get(sig) > bestCount) {
bestCount = counts.get(sig);
canonicalSets.set(key, set);
}
}
}
return { locales, dicts, unionPaths, canonicalSets };
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Check cross-locale consistency of the i18n dicts in lib/i18n (warn-only).",
category: "Best Practices",
},
schema: [
{
type: "object",
properties: {
allowPaths: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
],
messages: {
missingKey: 'missing key "{{key}}" in {{locale}}',
emptyValue: 'empty value "{{key}}" in {{locale}}',
placeholderMismatch:
'placeholder mismatch for "{{key}}" in {{locale}}: {{active}} vs expected {{expected}}',
},
},
create(context) {
const options = context.options[0] || {};
const allowPaths = new Set(options.allowPaths || []);
const filename = path.resolve(
context.filename || context.physicalFilename || "",
);
const locale = path.basename(filename, path.extname(filename));
const dirInfo = loadDir(path.dirname(filename));
if (!dirInfo || !dirInfo.locales.includes(locale)) return {};
const dictExport = findDictExport(context.sourceCode.ast);
if (!dictExport) return {};
const own = buildKeyMap(dictExport.object);
// Missing keys: everything the union has but the current file lacks.
// Top-level gaps only — a missing ancestor already covers its subtree.
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
const missingSet = new Set(missing);
const topMissing = missing.filter(
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
);
for (const key of topMissing) {
if (allowPaths.has(key)) continue;
context.report({
node: dictExport.id,
messageId: "missingKey",
data: { key, locale },
});
}
// Empty values and shared-key placeholder parity.
for (const [key, entry] of own) {
if (allowPaths.has(key) || entry.kind !== STRING) continue;
if ((entry.value ?? "").trim() === "") {
context.report({
node: entry.node,
messageId: "emptyValue",
data: { key, locale },
});
continue;
}
const canonical = dirInfo.canonicalSets.get(key);
if (!canonical) continue;
const active = placeholders(entry.value);
if (
active.size === canonical.size &&
[...active].every((p) => canonical.has(p))
) {
continue;
}
context.report({
node: entry.node,
messageId: "placeholderMismatch",
data: {
key,
locale,
active: formatSet(active),
expected: formatSet(canonical),
},
});
}
return {};
},
};
+20
View File
@@ -0,0 +1,20 @@
/**
* Local ESLint plugin "i18n".
*
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
* `lib/i18n/` is compared against all the others key parity, empty values,
* `{placeholder}` parity warn-only by design (a missing translation must not
* break the build). The set of locales comes from `LOCALES` in `dict.ts`, so
* new locales are picked up automatically.
*/
import dictConsistency from "./dict-consistency.js";
export default {
meta: {
name: "i18n",
version: "0.1.0",
},
rules: {
"dict-consistency": dictConsistency,
},
};
@@ -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 **, *, ?. */
+23 -2
View File
@@ -7,6 +7,7 @@ import tseslint from "typescript-eslint";
import designTokens from "./eslint-plugins/index.js"; import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js"; import isolationPlugin from "./eslint-plugins/isolation/index.js";
import conventionsPlugin from "./eslint-plugins/conventions/index.js"; import conventionsPlugin from "./eslint-plugins/conventions/index.js";
import i18nPlugin from "./eslint-plugins/i18n/index.js";
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые // FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас. // Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
@@ -72,6 +73,7 @@ export default tseslint.config(
"e2e/tools-smoke.spec.ts", "e2e/tools-smoke.spec.ts",
"e2e/generators.spec.ts", "e2e/generators.spec.ts",
"e2e/known-issues.spec.ts", "e2e/known-issues.spec.ts",
"e2e/i18n.spec.ts",
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в // Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
// tsconfig), поэтому для типизированного парсинга резолвятся // tsconfig), поэтому для типизированного парсинга резолвятся
// через default-проект. Перечисляются точечно: `**` в // через default-проект. Перечисляются точечно: `**` в
@@ -81,7 +83,13 @@ export default tseslint.config(
"eslint-plugins/__tests__/no-string-union-alias.test.ts", "eslint-plugins/__tests__/no-string-union-alias.test.ts",
"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/__tests__/dict-consistency.test.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/de.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",
@@ -90,7 +98,7 @@ export default tseslint.config(
"eslint-plugins/__fixtures__/src/routes/+page.svelte", "eslint-plugins/__fixtures__/src/routes/+page.svelte",
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte", "eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
], ],
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32, maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
}, },
extraFileExtensions: [".svelte"], extraFileExtensions: [".svelte"],
}, },
@@ -166,6 +174,19 @@ export default tseslint.config(
"conventions/no-string-union-alias": "error", "conventions/no-string-union-alias": "error",
}, },
}, },
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
// автоматически; правило игнорирует не-локали в этой папке само.
{
files: ["**/src/lib/i18n/*.ts"],
plugins: {
i18n: i18nPlugin,
},
rules: {
"i18n/dict-consistency": "warn",
},
},
// Полные recommended-наборы — только на новый код. // Полные recommended-наборы — только на новый код.
...[ ...[
...jsRecommended.map((cfg) => ({ ...jsRecommended.map((cfg) => ({
@@ -197,7 +218,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);
});
+11 -11
View File
@@ -101,11 +101,11 @@
--brand-main: #86efac; --brand-main: #86efac;
--brand-alt: hct(from var(--brand-main) calc(h + 90) c t); --brand-alt: hct(from var(--brand-main) calc(h + 90) c t);
--color-background: hct(from var(--brand-main) h 8 95); --color-background: hct(from var(--brand-main) h 2 95);
--color-background-muted: hct(from var(--brand-main) h 12 85); --color-background-muted: hct(from var(--brand-main) h 4 85);
--color-panel: hct(from var(--brand-main) h 6.5 98); --color-panel: hct(from var(--brand-main) h 3 98);
--color-checker-main: hct(from var(--brand-main) h 10.7 85.7); --color-checker-main: hct(from var(--brand-main) h 5 85.7);
--color-checker-alt: hct(from var(--brand-main) h 13.2 76); --color-checker-alt: hct(from var(--brand-main) h 7 76);
--color-border: hct(from var(--brand-main) h 13.1 69.4); --color-border: hct(from var(--brand-main) h 13.1 69.4);
@@ -186,13 +186,13 @@
--brand-main: #86efac; --brand-main: #86efac;
--brand-alt: hct(from var(--brand-main) calc(h + 90) c t); --brand-alt: hct(from var(--brand-main) calc(h + 90) c t);
--color-background: hct(from var(--brand-main) h 15 6); --color-background: hct(from var(--brand-main) h 2 16);
--color-background-muted: hct(from var(--brand-main) h 15 10); --color-background-muted: hct(from var(--brand-main) h 4 24);
--color-panel: hct(from var(--brand-main) h 12 8); --color-panel: hct(from var(--brand-main) h 3 26);
--color-checker-main: hct(from var(--brand-main) h 12 10); --color-checker-main: hct(from var(--brand-main) h 6 16);
--color-checker-alt: hct(from var(--brand-main) h 14 14); --color-checker-alt: hct(from var(--brand-main) h 8 24);
--color-border: hct(from var(--brand-main) h 15 25); --color-border: hct(from var(--brand-main) h 8 40);
--color-text: hct(from var(--brand-main) h 25 90); --color-text: hct(from var(--brand-main) h 25 90);
--color-text-muted: hct(from var(--brand-main) h 22 70); --color-text-muted: hct(from var(--brand-main) h 22 70);
-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 -13
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,16 +9,12 @@
<div class="catalog-head"> <div class="catalog-head">
<div> <div>
<div class="eyebrow">EASY-PNG-TOOLS / CATALOG</div> <h1>{t("catalog.heading")}</h1>
<h1>Tool catalog</h1> <p>{t("catalog.lead")}</p>
<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>
@@ -28,12 +26,6 @@
gap: var(--space-xxl); gap: var(--space-xxl);
flex-wrap: wrap; flex-wrap: wrap;
} }
.eyebrow {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-xl);
color: var(--color-text-muted);
margin-bottom: var(--space-m);
}
h1 { h1 {
margin: 0; margin: 0;
font-size: var(--font-size-2xl); font-size: var(--font-size-2xl);
+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>

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