Compare commits

...
41 Commits
Author SHA1 Message Date
Ku6epXBOCTuK 4794523084 fix: update css variables, update badge component 2026-09-02 10:07:31 +05:00
Ku6epXBOCTuK 98a4bacd23 chore: update css rules, add hct plugin 2026-09-02 09:51:14 +05:00
Ku6epXBOCTuK 1310c51543 chore: add design tokens\css lint rules 2026-09-02 00:14:29 +05:00
Ku6epXBOCTuK 191ca71553 fix: update some styles 2026-09-01 21:16:54 +05:00
Ku6epXBOCTuK d38d51c7d9 docs: add readme, license 2026-09-01 15:20:28 +05:00
Ku6epXBOCTuK 749b97cb4f fix: change style, demo page visual looks as done 2026-08-31 18:07:34 +05:00
Ku6epXBOCTuK d73b9056d7 chore: update audit cdp - group margin\padding\gap 2026-08-31 13:33:25 +05:00
Ku6epXBOCTuK 1907fb9106 chore: update cdp audit script 2026-08-31 12:31:07 +05:00
Ku6epXBOCTuK 80def1f380 fix: resolve new audit styles 2026-08-31 10:38:35 +05:00
Ku6epXBOCTuK 95f1eeeecc chore: update audit cdp script - css var fixes, add grid styles 2026-08-31 10:23:46 +05:00
Ku6epXBOCTuK 9cd86104fc chore: update cdp parsing css layers 2026-08-31 09:31:47 +05:00
Ku6epXBOCTuK 0b13d4c9e8 fix: update font styles 2026-08-31 09:14:33 +05:00
Ku6epXBOCTuK ecc8efcc45 chore: update html extract script - inject fonts 2026-08-31 08:52:26 +05:00
Ku6epXBOCTuK 586c66a159 fix: update style, resolve old css leaks issue 2026-08-31 08:31:33 +05:00
Ku6epXBOCTuK b7d66f0430 chore: update cdp audit script 2026-08-31 07:07:09 +05:00
Ku6epXBOCTuK bbd5532529 fix: resolve style issues 2026-08-31 03:25:42 +05:00
Ku6epXBOCTuK 6cefd5c0a1 chore: update cdp audit script 2026-08-31 03:02:29 +05:00
Ku6epXBOCTuK 8eff9a9483 fix: step fields styles 2026-08-31 02:41:57 +05:00
Ku6epXBOCTuK 2c70c445d5 fix: update styles 2026-08-31 01:25:46 +05:00
Ku6epXBOCTuK 6c8f7b2d8b fix: update preview styles 2026-08-30 22:40:32 +05:00
Ku6epXBOCTuK 86b1c813e7 fix: update top bar styles 2026-08-30 22:40:25 +05:00
Ku6epXBOCTuK b0f0b79ee0 chore: cdp audit - fix hover\focus\active pseudo classes 2026-08-30 21:45:47 +05:00
Ku6epXBOCTuK 11b4c87486 chore: fix cdp audit css selectors specifity 2026-08-30 21:21:31 +05:00
Ku6epXBOCTuK 87dbb200d9 chore: update audit script - different resolutions 2026-08-30 20:59:16 +05:00
Ku6epXBOCTuK 2d72848e8e chore: update audit format - show really different styles 2026-08-30 19:16:18 +05:00
Ku6epXBOCTuK 453a5908e6 chore: add chrome devtools protocol audit 2026-08-30 19:16:13 +05:00
Ku6epXBOCTuK dbeffac082 feat: css audit fixes 2026-08-30 04:11:17 +05:00
Ku6epXBOCTuK 2ef4709038 feat: WIP: add css fixes 2026-08-29 11:07:02 +05:00
Ku6epXBOCTuK 1831c3d100 feat: add refs css audit 2026-08-29 10:33:32 +05:00
Ku6epXBOCTuK 9ef6ae03c6 feat: fix tools pages, dom audit fix 2026-08-29 09:08:09 +05:00
Ku6epXBOCTuK b695f5d58c feat: add preview catalog and catalog group and header 2026-08-29 07:54:45 +05:00
Ku6epXBOCTuK 7578a185b6 feat: fix list tools page to correspond refs 2026-08-29 07:44:41 +05:00
Ku6epXBOCTuK d29ec73322 feat: fix demo page to correspond refs 2026-08-29 07:23:31 +05:00
Ku6epXBOCTuK 9ff732f2cf chore: add new audit-dom script and make dom audit 2026-08-29 06:39:56 +05:00
Ku6epXBOCTuK b19ddbe45d docs: add new audit plan - resolve old audit flow 2026-08-29 05:57:11 +05:00
Ku6epXBOCTuK 795c03ac87 chore: update refs audit 2026-08-28 21:27:13 +05:00
Ku6epXBOCTuK 0d95acba89 fix: resolve design issues from refs audit 2026-08-28 19:56:50 +05:00
Ku6epXBOCTuK 6959abb159 fix: remove tile label doubling 2026-08-28 17:44:07 +05:00
Ku6epXBOCTuK 86e7b7250d refactor: update pages and refs audit 2026-08-28 17:03:30 +05:00
Ku6epXBOCTuK c0f48ecd97 feat: add new simple components 2026-08-28 17:02:52 +05:00
Ku6epXBOCTuK d30deddce0 docs: update plans and refs audit 2026-08-28 16:46:17 +05:00
128 changed files with 48715 additions and 6289 deletions
+1
View File
@@ -8,3 +8,4 @@ refs/public/apple-icon.png
refs/public/placeholder-logo.svg
refs/public/placeholder-logo.png
refs/public/placeholder.svg
web/audit/cdp-audit*.txt
+71 -2
View File
@@ -27,6 +27,13 @@
реальные маршруты), на C19 при переименовании `kit/``ui/` — и компонентный
glob. Не оставлять устаревшие пути в `eslint.config.js`.
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через `let`).
- **Единый запуск всей дизайн-проверки: `pnpm --dir web lint:all`**
(`web/scripts/lint-all.mjs`) — гоняет по очереди: ESLint (все правила,
включая design-tokens), stylelint (`lint:css`) и токен-аудит
(`lint:tokens`). Каждый шаг выполняется, даже если предыдущий упал; выход
ненулевой, если хоть один не прошёл. Текущие «ожидаемые долги» (hardcoded
размеры в kit, не-Prefix токены в preview.css и т.п.) — техдолг: чинить
только по заведённым tasks, не игнорировать правилом.
## Правила кода
@@ -70,6 +77,68 @@
используется.
- Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/kit/`, даже «просто div с двумя стилями».
- Токены нового дизайна живут в `design2.css`; `refs/` и `refs-html/`
одноразовый источник, не поддерживаемый (правки дизайна — сразу в код).
### Линтинг дизайн-токенов («единая точка правды» дизайна)
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index
обязаны приходить из CSS-переменных. Стек: **stylelint** прогоняется по всем
CSS-файлам (`web/stylelint.config.js`), **ESLint** лезет в `<style>`-блоки
svelte-компонентов через локальный плагин `web/eslint-plugins/design-tokens/`
(постпрефес postcss AST от svelte-eslint-parser). Правила применяются к тем же
scoped-путям (`kit/**`, `preview/**`).
Команды:
- `pnpm --dir web lint:css` — stylelint по всем `src/**/*.css` (`old.css` и
`node_modules/build/.svelte-kit/static` игнорируются).
- `pnpm --dir web exec node scripts/check-tokens.mjs` — токен-аудит по
preview.css (лёгкий оркестратор поверх `web/scripts/token-audit/*`);
проверяет три вещи. Каждый цветовой
токен из `:root` обязан иметь пару в `[data-theme="dark"]` и наоборот.
Производные токены (значение содержит `var()`, напр. `hct(from var(--...))`)
из пары исключены — они наследуют тему автоматически. Выводит **варнинг** о
неиспользуемых токенах preview.css (определены, но нигде не используются) —
выход он не меняет. Провалом (exit 1) считаются только parity и hct-авторство.
- Тот же скрипт проверяет **hct-only для цветов**: ЛЮБОЕ цветовое значение в
preview.css обязано быть `hct(...)` — и литерал, и производное
`hct(from var(...) h c t)` (вся палитра считается через hct-каналы; эммит в
sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`). Исключения:
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
`color-mix(...)` (единственный легальный способ смешать два токена).
`oklch()/rgb()/#hex` в `--color-*` запрещены.
Токены-префиксы (целевой словарь дизайна):
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные
две переменные, которым разрешено быть hex/rgb, остальные цвета — только
`hct()` (эммит в sRGB-hex делает postcss-плагин `web/scripts/postcss-hct.mjs`)
и только в preview.css.
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
- Брейкпоинты: `--bp-mobile` (640px), `--bp-tablet` (800px), `--bp-desktop`
(1100px) — mobile-first.
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
- Правило `custom-property-pattern` в stylelint тестирует паттерн **без** `--`
(`--x``x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
`ignore`, не в `except`): пустые строки между подряд идущими токенами
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
токены в preview.css отдельными блоками.
Правила плагина (компоненты, `eslint.config.js`):
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
color-токен (`--color-*`/`--brand-*`) и наоборот.
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
производные от токенов (`var()`, `calc()`, unitless-числа).
- `design-tokens/no-undefined-in-svelte``var(--x)` в `<style>` должен быть
определён в `src/preview.css` (словарь токенов) или локально в компоненте.
Файл читается один раз и кэшируется (не `glob`-зависим).
Линтер только показывает ошибки, старый код (непрефиксованные токены, hex в
preview.css) — известный техдолг, его НЕ чинить и не игнорировать правилами.
- Коммиты мелкие (< ~500 строк), атомарные
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ku6epXBOCTuK
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
# easy-png-tools
Browser-based PNG utility toolkit — no server, no data upload, everything runs locally.
Inspired by online services like onlinepngtools.com, but fully offline-capable and open source.
## Features
- **Pixel-level image processing** — format conversion, transparency/alpha channel, color transformations, geometry, filters/convolutions, morphology, quantization & palettes, image generation, text/watermarks, analysis & checks
- **Pipeline system (workspace)** — load image → chain multiple tool steps → sequential application → download result; pipelines saved to localStorage, exportable as JSON
- **Web Worker execution** — heavy operations run off the main thread with automatic fallback to direct calls
- **i18n** — full Russian and English localization with search matching
- **Dark/Light themes** — persisted to localStorage
- **Pure TypeScript core** — `lib/core/` operates on `ImageData`/`Uint8ClampedArray` with no DOM dependency
- **~30+ tools** across categories: transparency, color, geometry, filters, morphology, palettes, generation, text, analysis
## Tech Stack
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export, no server
- **Vite** + **TypeScript** (strict)
- **Vitest** for unit tests
- **ESLint** + **Prettier**
- **pnpm** (required, ^11.20.0)
- **Lucide** icons, **IBM Plex** fonts (self-hosted)
## Getting Started
### Prerequisites
- [pnpm](https://pnpm.io/) v11.20+
### Install & Run
```bash
pnpm install
pnpm dev
```
### Build
```bash
pnpm build
```
Static output is written to `web/build/`.
### Other Commands
| Command | Description |
| ------------------------ | ------------------------- |
| `pnpm --dir web test` | Run unit tests (Vitest) |
| `pnpm --dir web check` | Type-check (svelte-check) |
| `pnpm --dir web lint` | Lint (ESLint) |
| `pnpm --dir web format` | Format (Prettier) |
| `pnpm --dir web preview` | Preview production build |
## Project Structure
```txt
easy-png-tools/
├── web/ # Main SvelteKit application
│ ├── src/
│ │ ├── lib/
│ │ │ ├── core/ # Pure TS image processing (ImageData-based)
│ │ │ ├── components/ # UI components
│ │ │ ├── tools/ # Pipeline, executor, overlay store
│ │ │ ├── i18n/ # Localization (ru/en)
│ │ │ └── registry.ts # Tool registry (source of truth)
│ │ └── routes/ # SvelteKit routes
│ └── build/ # Static export output
├── docs/ # Architecture docs, roadmap, plans
└── refs/ # Design reference (Next.js, not part of runtime)
```
## Architecture
The project is built around a **tool registry** (`registry.ts`) — each tool is a self-contained entry with `{id, title, description, category, params, run}`. Pages, forms, and pipelines are generated from this registry.
Core image processing lives in `lib/core/` and operates directly on `ImageData` objects with no browser API dependencies, making it portable to other environments (WASM, CLI) in the future.
## Roadmap
See [docs/roadmap.md](docs/roadmap.md) for the full development plan. Current phases:
1. **Phase 1** — TypeScript core + web UI (in progress)
2. **Phase 2** — Rust/WASM core for performance-critical operations
3. **Phase 3** — CLI tool
## License
[MIT](LICENSE)
@@ -1,5 +1,11 @@
# Demo fidelity: /preview/demo vs refs-html/demo.html
> ⚠️ **УТРАТИЛ АКТУАЛЬНОСТЬ — архивная копия.** Актуальный план фикса верности и
> текущий статус пунктов B/F — в `docs/plan-design-fix.md` (Этап 1). Аудит верности
> теперь автоматизирован (`pnpm audit``web/audit/audit-report.md`; пиксель-дифф не
> используется); темы в `refs-html` переключаются (скрипт инжектится в
> `scripts/extract-static.mjs`). Ниже — исходный текст на момент написания.
Метод: Playwright, viewport 1440×900, скриншоты обеих страниц + замер вычисленных
стилей ключевых элементов + пиксель-дифф (без чтения картинки моделью).
Пиксель-дифф: **24.9%** (наш скролл 1731px, реф 1579px). Зоны различий размазаны
@@ -88,6 +94,9 @@ flex/grid раскладок против статичного мока); виз
## План действий (предлагаю)
> Актуальный пошаговый план фикса вынесен в `docs/plan-design-fix.md`
> (Этапы 0–1: рефакторинг + верность). Ниже — исходные наброски.
1. Сразу поправить B1 + B2 (чёткие баги, улучшают все страницы).
2. По F1–F4 — решение за тобой (совпадать с рефом дословно ИЛИ оставить как сигнатуру нового дизайна).
3. F5–F10 — мелочи, подчистить в том же проходе, если решаем «совпадать с рефом».
+207
View File
@@ -0,0 +1,207 @@
# План: аудит верности `preview/*` против `refs-html` (DOM → CSS)
> Статус: к выполнению. Ветка `feat/redesign`.
> Источник правды по пикселям: `refs-html/*.html` (статический экспорт рефа).
> Актуальный рабочий аудит сейчас — `pnpm refs-audit` (`scripts/audit.mjs`), но он
> **сломан** (см. «Почему текущий аудит не работает») и подлежит замене.
## Почему текущий аудит не работает
`scripts/audit.mjs` сопоставляет элементы **только по тексту** и берёт в
сравнение **только листовые узлы** (у которых нет детей). Контейнеры/карточки/
панели, несущие `border-radius`, отступы и позиционирование, в сравнение
**вообще не попадают** (`byText` кладёт в мапу только элементы с непустым
`text`). Следствия:
- разница радиусов у карточек невидима (карточка — родитель без своего текста);
- позиция кнопки (`Download result`) не ловится — это структурная разница;
- `onlyOurs / onlyRef` — это просто счётчик текстовых строк на одной стороне,
**не метрика верности**.
Плюс он не делает пиксельного сравнения, то есть принципиально не может сказать
«выглядит по-другому». Это текстовый дифф стилей листовых текстов — и всё.
## Решения (подтверждены автором)
1. **Копирайт превью — через i18n `t()` с EN-базой.** Сейчас RU-литералы
хардкодом только в `preview/+page.svelte:26-27` и
`preview/list-tools/+page.svelte:31` (эти страницы не используют i18n, в
отличие от `(old)/` и компонентов `DownloadButton`/`ChainToolBlock`/…).
Заводим их через `t()` — совпадёт с рефом (EN).
2. **Имена классов НЕ сводим к рефу.** Матчинг делаем по **структурному пути**,
а не по классу. Компоненты `kit/` остаются идиоматичными, Stage 0 не
откатываем. Требуется только эквивалентность *структуры* (теги, вложенность,
порядок сиблингов, наличие узлов, текст).
3. **Пиксельный дифф (Playwright + pixelmatch) пока НЕ делаем.** DOM+CSS-аудита
достаточно; добавим как сеть позже, если стили что-то упустят.
4. **Порядок прогона маршрутов:** `demo``list-tools` → затем 2 инструмента
(`tools/linear-gradient-png``gradient.html`,
`tools/remove-background-png``background-remover.html`).
## Принципы
- Никаких правок старого дизайна `(old)/` и старого `ui/`.
- Коммиты атомарные (< ~500 строк), по 1–2 компонента/скрипта; после каждого —
`lint` + `svelte-check` + `build` + соответствующий аудит (diff не должен
расти без причины).
- Коммиты делает автор (агент не коммитит).
- Шрифты уже совпадают: `design2.css:1-6` грузит IBM Plex Sans/Mono через
`@fontsource`, токены `:34-35``"IBM Plex Sans"/"IBM Plex Mono"` (как в рефе
`demo.html:67-68`). Пиксельный шум от шрифтов не предвидится.
## Подход: две фазы
Корень проблемы — нет **стабильного ключа сопоставления**. Разбиваем на две
фазы: сначала доводим DOM до структурного равенства с рефом (фаза A), затем на
стабильном DOM сравниваем стили по тому же пути (фаза B). Фаза A сама по себе
ловит именно то, из-за чего возникли претензии (кнопка не в том родителе,
лишние/недостающие обёртки, перепутанный порядок) — и без эстетических решений.
---
## Фаза A — DOM/структурный аудит (`scripts/audit-dom.mjs`)
**Цель:** Svelte рендерит структуру, идентичную `refs-html` (дерево тегов,
вложенность, порядок сиблингов, наличие узлов, текст). Имена классов могут
отличаться.
### Нормализация узла (ключ сравнения)
Для каждого элемента (пропускаем `SCRIPT`/`STYLE`/`NOSCRIPT` и скрытые
поддеревья, игнорируем служебное):
- **тег** (`tagName` в lower-case);
- **классы** — как отсортированный набор (для инфо, не для матчинга);
- **нормализованный текст** — `trim`, `collapse-whitespace`, без SVG-иконок
внутри (текст иконок игнорируем);
- **порядок детей** — позиция среди сиблингов (`nth-child`).
Матчинг — по **структурному пути** от корня сравнения:
`tag:nth-child(i) > tag:nth-child(j) …`. Не по классу и не по тексту.
### Полное соответствие, без IGNORE
Оболочка (наш `TopBar`/`AppShell`/`preview-footer` vs рефовый
`.topbar`/`.footer`/`.language`) тоже сводим к рефу — **IGNORE-список не
заводим**. Сравниваем всё дерево от `<body>` (без пропуска служебного, кроме
Svelte-атрибутов и комментов гидрации), чтобы получить полное структурное
соответствие, а не только контента. Шапка/футер/переключатель языка — часть
верности; позже, после полного фикса дизайна, всё равно планируется рефакторинг
компонентов, где оболочку приведём в порядок и унифицируем. Аудит на этом этапе
просто показывает все расхождения, включая оболочечные.
### Дифф
Для каждого маршрута — набор дельт:
- `added` — у нас, нет в рефе;
- `removed` — в рефе, нет у нас;
- `moved` — тот же текст/тег, другой путь (перепутанный порядок/родитель);
- `textMismatch` — нормализованный текст различается (в т.ч. RU vs EN).
Класс-мисматч — **только информационно**, не блокирует (классы свои по
решению №2). Счётчики: `added / removed / moved / textMismatch`.
### Рендеринг
- **наше:** dev-сервер на `5179` (как сейчас в `audit.mjs`).
- **реф:** `pathToFileURL` к `refs-html/<ref>.html` (`file://`). Реф —
самодостаточный (инлайн `<style>`), рендерится полностью. Шрифты для фазы A
не важны (сравниваем структуру/текст, не растеризацию).
- Переиспользуем из текущего `audit.mjs`: `discoverRoutes()`, `EXCLUDE`
(`/preview`), `DYNAMIC` (`tools/[id]``gradient.html` /
`background-remover.html`), `refNameFor()`.
### Шаги (на каждом маршруте)
1. Прогнать `pnpm refs-dom-audit`, получить дельты.
2. Поправить структуру Svelte под реф (добавить/убрать/переставить узлы,
вынести тексты в `t()` с EN-базой).
3. Перезапустить, пока `added/removed/moved/textMismatch` ≃ 0 (кроме `IGNORE`).
---
## Фаза B — CSS-аудит по стабильному DOM (`scripts/audit-css.mjs`)
**Цель:** на совпадающем дереве сравнить вычисленные стили по тому же
структурному пути и получить «элемент по пути X: свойство → наше/реф».
### Сравниваемые свойства (computed)
`borderRadius`, позиция/размер (`getBoundingClientRect`: x/y/w/h),
`padding` (все стороны), `margin`, `color`, `backgroundColor`,
`borderTop/Right/Bottom/Left Width|Color`, `fontFamily`, `fontSize`,
`fontWeight`, `letterSpacing`, `lineHeight`, `gap`, `display`, `boxShadow`.
### Дифф
Для каждого состыкованного по пути элемента — список свойств с дельтой
(наше значение → реф). Элементы, не совпавшие по пути (остаток из фазы A),
идут в `unmatched`. Отчёт: элемент → свойство → наше/реф. Правится одной
строкой CSS, без брутфорса.
### Шаги
1. Прогнать `pnpm refs-css-audit` (только после того, как фаза A по маршруту
дала ≃ 0 структурных расхождений).
2. Поправить стили в `design2.css` / стилях компонентов.
3. Перезапустить, пока delta% по свойствам ≃ 0.
---
## Порядок маршрутов и особенности
1. **`/preview/demo`** (↔ `demo.html`) — уже ближе всего; идеальная «обкатка»
тулинга фаз A и B. Здесь же проверим, что `Download result` стоит в шапке
`.preview-top` (как в рефе), а не внизу панели.
2. **`/preview/list-tools`** (↔ `list-tools.html`) — объёмнее; **зависит от
`docs/plan-list-tools.md`**: реальный аудит имеет смысл только после того,
как страница переписана на `CatalogHeader`/`CatalogToolbar`/`CatalogGroup`
(шаги 15 плана list-tools). То есть фаза A/B для list-tools стартует после
landing этих шагов.
3. **`tools/linear-gradient-png`** (↔ `gradient.html`) и
**`tools/remove-background-png`** (↔ `background-remover.html`) — **последние**.
Известные расхождения: наши инструменты могут иметь **другие наборы
параметров / UI**, чем конкретные рефовые тулзы. Ожидаются структурные и
стилевые дельты, которые не сводятся к «подровнять паддинг». Для них —
триаж после прогона:
- реальный разрыв верности → фиксим;
- принципиальное отличие (параметр, которого нет в реф-тулзе; иной контрол)
→ либо аккуратно приводим наш UI параметра к рефу, либо **документируем
как допустимое расхождение / заносим в allowlist** конфига аудита
(селектор + причина). «Скипы/фиксы» для инструментов оформляем явно, чтобы
аудит не шумел на осознанных отличиях.
---
## Структура скриптов
- `scripts/audit-dom.mjs` — фаза A (нормализация + структурный дифф).
- `scripts/audit-css.mjs` — фаза B (матчинг по пути + дифф стилей).
- `package.json`: `refs-dom-audit`, `refs-css-audit` (опционально
`refs-audit`, прогоняющий обе фазы).
- Переиспользуем из старого `audit.mjs`: `discoverRoutes`, `EXCLUDE`,
`DYNAMIC`, `refNameFor`, `waitFor` (fast-fail на 4xx/5xx).
- Отчёты: `web/audit/dom-report.{md,json}` и `web/audit/css-report.{md,json}`
(старый `audit-report.*` заменяем).
## Верификация (после каждого коммита)
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`,
`pnpm build` — без ошибок.
- `pnpm refs-dom-audit` (фаза A) / `pnpm refs-css-audit` (фаза B) по
затронутому маршруту: счётчики расхождений не растут без причины.
## Риски / заметки
- **Стабильность матчинга:** нужна аккуратная нормализация (сортировка
классов, игнор svelte-атрибутов/`data-*`, игнор whitespace-текстовых узлов,
игнор внутренностей SVG-иконок). Подстраивается по первым прогонам demo.
- **Оболочка** (TopBar/footer/язык) сейчас отличается по дизайну — сводим к рефу
в рамках полного соответствия (без IGNORE); финальная унификация компонентов —
отдельным рефакторингом позже.
- **Динамические маршруты:** `DYNAMIC` уже есть; оставляем.
- **Hover / адаптив / тёмная тема** — вне объёма статического аудита (initial
state, 1440×900, light). Отмечаем как будущие прогоны.
- **list-tools** зависит от landing `plan-list-tools.md` (шаги 15) — не
аудируем вслепую.
+131
View File
@@ -0,0 +1,131 @@
# План: доводка дизайна до верности (фикс по refs)
> Статус: к выполнению. Ветка `feat/redesign`.
> Источники: `docs/archive/demo-fidelity.md` (архивная копия старого отчёта по
> верности, неактуальна), `docs/plan-redesign.md` (общая стратегия, фазы 1–4
> выполнены до C16). Актуальный аудит верности — `pnpm refs-audit` (Playwright) →
> `web/audit/audit-report.md`.
## Контекст
- Новый дизайн собран параллельно под `preview/*` (см. `plan-redesign.md`),
старый сайт `(old)/` не трогаем до C17.
- `refs/` и `refs-html/` приведены к консистентному виду — текущая «правда по
пикселям». Механизм синхронизации ref→код не создаётся (см. §9 plan-redesign).
- Аудит верности автоматизирован: `pnpm refs-audit` (Playwright, 1440×900) пишет
`web/audit/audit-report.md` — light/dark токены + поэлементный дифф стилей;
пиксель-дифф не используется. Палитры токенов уже сверены с рефом (light
совпадает, dark собран). Темы в `refs-html` переключаются — скрипт инжектится
в `scripts/extract-static.mjs`.
- Исходный отчёт `demo-fidelity.md` перенесён в `docs/archive/` как утративший
актуальность; его данные актуализированы ниже (Этап 1).
- Зафиксированные решения (подтверждены автором):
- **F1 — плоский фон холста**, без blueprint-сетки (как в рефе).
- **F4 — preview-stack в одну колонку** (как в рефе).
## Принципы
- Никаких правок старого дизайна `(old)/` и старого `ui/`.
- Компоненты пишем в `kit/` (переименование в `ui/` — на C19 по plan-redesign).
- Коммиты атомарные (< ~500 строк), по 1–2 компонента; после каждого —
`svelte-check` + `lint` + `build` + `refs-audit` (diff% не должен расти без причины).
- Коммиты делает автор (агент не коммитит).
## Этап 0 — Рефакторинг (SRP / дизайн-обёртки), без изменения визуала
Гигантские страницы (`demo` ~513, `tools/[id]` ~451 строк) дробим на
компоненты единой ответственности; убираем дублирующиеся inline-стили.
Новые обёртки в `src/lib/components/kit/`:
- `FieldGrid` — сетка полей (заменяет `.controls` / `.controls.compact`).
- `ToggleRow` — лейбл + `Toggle` (дублировался в `demo` и `tools/[id]`).
- `PreviewStack` — контейнер превью-плиток, **одна колонка** (реализует F4 в
одном месте).
- `SectionLabel` — label + strong + actions (вместо `.pipeline-head` /
`.preview-top`).
- `FileChip` — пилюля файла.
- `PipelineFooter` — reset + auto-note.
- `WorkspaceLayout` — двухпанельная сетка (settings | preview) со sticky +
брейкпоинт 800px.
Декомпозиция страниц:
- `tools/[id]`: вынести `ParamControl` (диспетчер полей по `ParamDef`),
`SettingsPanel` (левая панель), `PreviewPanel` (правая панель) —
колоцированно в `routes/preview/tools/[id]/`. Страница сжимается до
состояния + глёв.
- `demo`: переписать на те же обёртки + `WorkspaceHeader` (eyebrow + h1 + lede +
file-chip).
Побочный баг, устраняемый тут же: в `demo` поля сейчас обёрнуты в
`<div class="control-block"><MonoLabel>…</MonoLabel><SliderField label>`
**двойной лейбл**, т.к. `ColorField`/`SliderField`/`SelectField`/`NumberField`
уже содержат `Field` (MonoLabel) изнутри. При рефакторе убираем внешний
`MonoLabel`, оставляем само поле.
Критерий Этапа 0: визуал идентичен (audit diff% без регрессии), старые тесты
зелёные.
## Этап 1 — Верность (по refs-html; статус на момент плана)
Аудит: `pnpm refs-audit` (Playwright, 1440×900) пишет `web/audit/audit-report.md`
light/dark токены + поэлементный дифф стилей. Пиксель-дифф не используется.
Палитры токенов уже сверены с рефом: light совпадает (`--background`,
`--panel`, `--foreground`, `--muted`, `--line`, `--blue`), dark собран (16
токенов). Темы в `refs-html` переключаются (скрипт инжектится в
`extract-static.mjs`).
Открытые / решённые пункты (исходник — `docs/archive/demo-fidelity.md`):
- **B1** (открыт, P1): `button, input, select, textarea` рендерятся Arial —
добавить в reset `design2.css` `font-family: inherit`. Влияет на все страницы.
- **B2** (решён — проверить): двойного топбара нет. `preview/+layout.svelte`
рендерит ровно один `TopBar`; `demo` свой `.topbar` не рендерит. Подтвердить
аудитом/визуально при Этапе 1.
- **F1** (решено — плоский фон): наш `--background` уже `#EEF1F4` (как реф);
видимое отличие было в blueprint-сетке поверх фона. Фикс: убрать `BlueprintGrid`
из `AppShell`.
- **F2** (сделано C16): тип шага (`StepCard`) — приглушённый mono-текст, не
синий бейдж; индекс шага — синий `#1769D2`.
- **F3** (сделано C16): подпись превью-тайла (`PreviewTile`) — синяя `#1769D2`.
- **F4** (решено — одна колонка): реализуется в `PreviewStack` (Этап 0),
колонка = `1fr`.
- **F5** (открыт): вес `MonoLabel` 500 → 400 (как в рефе).
- **F6** (открыт): мета-подписи (MetaRow/MetaList, DIMENSIONS/FORMAT/SIZE)
fs 10px → 9px.
- **F7** (низкий): копирайт/стиль — у нас `+ Add tool` (реф `Add tool`);
кнопка `Download result` — сверить с рефом (синяя, ~478×42). Проверить
визуально (замеры NOT FOUND — артефакт вложенного чипа размера).
- **F8** (низкий): `h1` line-height чуть выше рефа (150px vs 115px).
- **F9** (открыт): вертикальный ритм — подровнять паддинги секций под реф
(pipeline-head и пр.).
- **F10** (открыт): внутренние отступы preview-панели (паддинг панели).
После Этапа 1 — повторный `refs-audit`, зафиксировать остаточный diff (sub-pixel /
структурный) и обновить статус пунктов выше прямо в этом файле.
## Этап 2 — plan-redesign.md (C17C21), ОТЛОЖЕНО
Выполняется **только после** того, как новый дизайн доведён до приемлемого
уровня верности (Этапы 0–1). Сами пункты — в `plan-redesign.md` §10 Шаг 56:
- **C17**: `preview/*` → реальные маршруты, удалить `(old)/`, корневой
`+layout.svelte``design2.css`, поправить `newCode` в `eslint.config.js`
(`src/routes/preview/**``src/routes/**`).
- **C18**: правки импортов/редиректов, проверка билда.
- **C19**: удалить старое `ui/` + `app.css`, `kit/``ui/` (или оставить),
раскрыть ESLint `recommended` на весь код.
- **C20**: брейкпоинты 1200/1100/800/480; build без предупреждений; `grep` по
старым токенам/классам пуст.
- **C21**: опц. удалить `refs/` и `refs-html/` из репо.
## Порядок коммитов (Этап 0)
1. `kit/`: `FieldGrid`, `ToggleRow`, `PreviewStack`, `SectionLabel`, `FileChip`,
`PipelineFooter`, `WorkspaceLayout` — по 1–2 на коммит.
2. `tools/[id]`: `ParamControl``SettingsPanel` + `PreviewPanel` → тонкая
страница.
3. `demo`: переписать на обёртки.
После каждого коммита: `svelte-check` + `lint` + `build` + `refs-audit`.
+97
View File
@@ -0,0 +1,97 @@
# Plan: `/preview/list-tools` → design2 (catalog)
Ветка `feat/redesign`. Цель — привести каталог инструментов к рефу
`refs-html/list-tools.html`. Сейчас страница вообще не на design2: по
мульти-аудиту `onlyOurs=257, onlyRef=54` (демо-страница — `18/11`).
## Текущее состояние
- `src/routes/preview/list-tools/+page.svelte` использует собственную вёрстку
(`.catalog` / `.cat-head` / `.cat-grid`) + `Panel` / `PanelHeading` /
`ToolCard` (kit). Нет поиска, фильтров, счётчика, навигация по категориям
сделана через `Panel`-блоки, а не через `.catalog-groups`.
- Реф (`list-tools.html`) строит:
- `.catalog-page``.catalog-head` (`.eyebrow`, `h1` «Tool catalog»,
`p` lede, `.catalog-total` «NN TOOLS AVAILABLE» с большим числом),
- `.catalog-toolbar` (`.catalog-search` + `.catalog-filters` с кнопками),
- `.catalog-groups` (grid `repeat(2,minmax(0,1fr))`, gap `56px 28px`) →
`.catalog-group` (`.group-title` «CAT · NN TOOLS») → `.tool-cards`
`.tool-card`.
- `.tool-card` (реф) — `display:grid`, колонки `42px minmax(0,1fr) 24px 18px`,
`min-height:106px`, `gap:14px`, `padding:16px`: `.tool-icon` (42×42
синий размытый квадрат), `.tool-copy` (`strong` 13px mono + `span` 12px mono
muted), `.tool-index` (10px mono, сверху), `.tool-arrow` (появляется при hover).
- Токены `design2.css` уже совпадают с рефом (`--background #eef1f4`,
`--panel #f8fafb`, `--foreground #17212b`, `--muted #6d7883`, `--line #cbd3da`,
`--blue #1769d2`, `--radius 4px`) — править токены не нужно.
## Шаги
1. **Restyle `kit/ToolCard.svelte`** под реф (меняется «на месте» — используется
ещё в `preview/+page` и `preview/kit`, стиль унифицируется, ок):
- grid-колонки `42px minmax(0,1fr) 24px 18px`, `align-items:center`,
`gap:14px`, `min-height:106px`, `padding:16px`, `border:1px solid line`,
`background:panel`, `border-radius:radius`.
- `.tool-icon`: 42×42 `place-items:center`, `background:color-mix(in srgb,
var(--blue) 12%, transparent)`, `color:var(--blue)` (реф использует
`--panel-strong`; вместо нового токена берём этот `color-mix`).
- `.tool-copy strong`: `600 13px var(--font-mono)`; `span`: `12px/1.5 mono`
`muted`.
- `.tool-index`: `10px mono muted`, `align-self:start`.
- `.tool-arrow`: `color:var(--blue)`, `opacity:0` → `1` + `translate(2px,-2px)`
на `.tool-card:hover`.
- DOM-порядок как в рефе: `icon, copy, index, arrow`.
- `href` через `resolve` из `$app/paths` (убрать `svelte-ignore`).
2. **`kit/CatalogHeader.svelte`**: `.eyebrow` «EASY-PNG-TOOLS / CATALOG»,
`h1` «Tool catalog», lede; `.catalog-total` с динамическим `TOOLS.length`
(реф показывает `32`).
3. **`kit/CatalogToolbar.svelte`**: `.catalog-search` (иконка + `<input>`) и
`.catalog-filters` (кнопки по категориям). Состояние `query` / `category`
поднимается наружу через `bind:` (или callback-пропы), чтобы страница
фильтровала список. Фильтры функциональные (клиентский фильтр по названию +
категории), не декоративные.
4. **`kit/CatalogGroup.svelte`**: `.group-title` (uppercase label категории +
«NN TOOLS») + слот со списком карточек; сетка `.tool-cards` — `gap:10px`
(реф), на мобиле (`@media max-width:800px`) всё схлопывается в 1 колонку.
5. **Переписать `preview/list-tools/+page.svelte`** как тонкую композицию:
- обёртка `.catalog-page` (`padding:60px clamp(24px,4vw,72px) 72px`);
- `<CatalogHeader />`;
- `<CatalogToolbar bind:query bind:category />`;
- `.catalog-groups` из отфильтрованных групп (по `CATEGORIES`, пересчитав
`tools` с учётом `query`/`category`);
- внутри `<CatalogGroup>` — `<ToolCard>` на каждый tool (`href=
"/preview/tools/{id}"`, `icon` из `tool-icons`, `index` по порядку,
`title`/`description` из registry);
- убрать `Panel`/`PanelHeading` из этого маршрута.
6. **Категории/локализация**: labels привести к рефу (реф: ALL / CONVERT /
TRANSPARENCY / COLOR / GEOMETRY / FILTERS / ANALYZE). Наши `CATEGORIES`
(`convert/alpha/color/geometry/filters/text/analyze/generate`) — сопоставить
(`alpha`→TRANSPARENCY и т.п.); точное сопоставление уточнить по рефу и
`i18n/ru.ts`. Фильтры = ALL + те же label'ы. Тексты заголовков/lede — по
рефу (англ. «Tool catalog» / «Focused utilities for working with PNG…»),
либо оставить RU-вариант, если это сознательное отклонение (зафиксировать).
7. **Адаптив**: перенести правила рефа (`@media max-width:800px`) —
`.catalog-groups` → 1fr, `.tool-card` → `38px minmax(0,1fr) 18px`, скрыть
`.tool-index`/показать `.tool-arrow`, `.catalog-head` в колонку и т.д.
## Верификация
- `pnpm lint`, `pnpm exec svelte-check --tsconfig ./tsconfig.json`,
`pnpm build` — без ошибок.
- `pnpm refs-audit` (теперь мульти-маршрутный) → глянуть секцию
`/preview/list-tools`: `onlyOurs`/`onlyRef` должны упасть с `257/54`
примерно до уровня demo (`18/11`). Остаточный diff (если будет) зафиксировать
в этом файле.
## Примечания
- `ToolCard` правится на месте — влияет на `preview/+page` и `preview/kit`
(там тоже карточки каталога, унификация уместна).
- Старый дизайн (`routes/(old)/**`, `components/search/**`) не трогать.
- Коммиты делает автор (агент не коммитит).
+8 -2
View File
@@ -1,6 +1,8 @@
# План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: план к выполнению.
> Статус: план к выполнению. Шаги 56 (C17–C21) **отложены** до вывода нового
> дизайна на приемлемый уровень верности — см. `docs/plan-design-fix.md`
> (Этапы 0–1: рефакторинг + верность по `demo-fidelity.md`).
>
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
@@ -341,7 +343,11 @@ src/routes/preview/tools/[id]/+page.svelte
(доказательство отсутствия регресса). Фиксы по результатам — отдельными
мелкими коммитами.
### Шаг 5. Сделать новый дизайн основным
### Шаг 5. Сделать новый дизайн основным — ОТЛОЖЕНО
> Выполняется только после Этапов 0–1 из `docs/plan-design-fix.md` (новый
> дизайн доведён до приемлемой верности). Пока не трогаем `preview/*` и
> `(old)/`.
- [ ] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый shell.
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build",
"format": "pnpm --dir web format",
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html"
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html",
"cdp-audit": "pnpm --dir web refs-cdp-audit:large"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
+36 -36
View File
@@ -8,51 +8,51 @@ importers:
packageManagerDependencies:
'@pnpm/exe':
specifier: ^11.20.0
version: 11.22.0
version: 11.25.0
pnpm:
specifier: ^11.20.0
version: 11.22.0
version: 11.25.0
packages:
'@pnpm/exe@11.22.0':
resolution: {integrity: sha512-B1SGeKm+v9pX9YkzMmrnO2FbgBd8TDwzZ3jSj6J6ThxdyGvI4TsOfMeHARW4Wb25visPpmMDfIXrU76EZdJM4g==}
'@pnpm/exe@11.25.0':
resolution: {integrity: sha512-X19R2uC+VAJ4UJQE9c/PCdOXbDaWnZtad7WUrTHBFQuMVaK9MAHbyO/WgahQCuRtTmJkLbxcWKKCk+JeTYFm/g==}
hasBin: true
'@pnpm/linux-arm64@11.22.0':
resolution: {integrity: sha512-xzzn3jYG9QaiFZPaHcWM3yX4Fm9UGz5E42mzpbvUEvXYf5O9fwNolenOhGLpRl2qS5u35fjZSvDZbWlOwHMcug==}
'@pnpm/linux-arm64@11.25.0':
resolution: {integrity: sha512-ra8akqhzsbcOhKSJ9fFV8H+Oc9uGQAcp/XmIBEdT+8hPPoZCit3+RNCHmg8bLoNvpSLtRvZE0WFCMj7WyzxeBA==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.22.0':
resolution: {integrity: sha512-isvaPctGinbsM2hsTtRsMarN8Sr5QhXDTNmn8Xv9Lp1PjincCvH2RBbhpo+xYIOxzgls1dtQSdgoORUbfRVALQ==}
'@pnpm/linux-x64@11.25.0':
resolution: {integrity: sha512-pQl/L10diKQCbF73viRrtVU8qVWMTudUCSG1uZ+EWBNKtU9Sbxe6Q378diXDb1uTwSgUVMQoypxlC1MTzh7qvQ==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.22.0':
resolution: {integrity: sha512-i4J+AQWW0T3JBdXaLsvxu8ZmMG1HLS6JZQESdOR9uBv8Zumb4yg8GYT83eWRLacr6ngVdhEBiC3W4eOG64MFbA==}
'@pnpm/linuxstatic-arm64@11.25.0':
resolution: {integrity: sha512-cYcrbB/xN1N6/5VUlFuHblb1gNyJgZv1CF5Pk1T0sHJxQMY4DFJV3OqHVAgahxyUfSTaQcVLvH1H2JFvd/+sgw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.22.0':
resolution: {integrity: sha512-QYzk8jhSuSbVthW/OxEOhU3f3zhjpw4HqgedrlwbVNb7btCWn5del2Hb5PsYYka2PbmKq5EtF5IzYN8Yp1CfxQ==}
'@pnpm/linuxstatic-x64@11.25.0':
resolution: {integrity: sha512-HXDtaeQod16DDMUUcVLIMxvEc1jKn7IYsNPwbwAPs/Je/d3umRPJi3Ejjdn6e9qpXN6Oon+yvSsJr7B9oDt6KA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.22.0':
resolution: {integrity: sha512-Io8Axk5kutPgMuAfOg1QGj3J0/KpLvH5iiPcmp7up8D4q7BNWT02ndQo3RyGWqrlkf5nwspM41GFpWTShrZ4Aw==}
'@pnpm/macos-arm64@11.25.0':
resolution: {integrity: sha512-m/eAgEqKhiSexGxWPHNXNnSRI0hudymg6K7LbrU2EoDs9IgJ28OKEz9mGxMzhkYBweEquR4k5teMNes/aToy9w==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.22.0':
resolution: {integrity: sha512-QgaRuKGQKov7xW2utPCgDT83fn/PU5cD6HFgib48oTslz7wv26E89d4jMCxL4GRmEL2YCfkYuj5ITj1oVWg5WQ==}
'@pnpm/win-arm64@11.25.0':
resolution: {integrity: sha512-oAeECbtZ+eJziaBmPUkwJM8Dx7KVQ5FO367AXTjD2HGfB5ob1Bcu5hoUF5RBTgDBiP9fRQ1u13uAEpqar/6NLA==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.22.0':
resolution: {integrity: sha512-iWYsiSwpgxqur+TwsnSoPdOQaEfd4ygB84mb5tJUOgim6PHr1xhKJBndaQzH+At/WkLxLdYN+K8dYc4M7naezg==}
'@pnpm/win-x64@11.25.0':
resolution: {integrity: sha512-8/n+wCc0a8TRYTMFqti93Vh0cscdJ25xfMyYSDdXrLUh2ccxSFuS+SE7cNPjd/nOXoFAJvdW0kwfbyRPLa16/w==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.22.0:
resolution: {integrity: sha512-H/hwxMYTPf2I+yr8Rt0T1H8JyXlLQ4xv20fKmMrzvBY4HuC+k6CRuOOCTPAfiJ9G19niCRD7C+GrD7W6qA3WIQ==}
pnpm@11.25.0:
resolution: {integrity: sha512-XN6SW08HX3Jetx+64YpC/+eEUkeJ8ZthxzHLhyHsKKruFg4BqNWvT+2ypCzb8wDv4j2zVrDUoXtNY+EfirfJVg==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.22.0':
'@pnpm/exe@11.25.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.22.0
'@pnpm/linux-x64': 11.22.0
'@pnpm/linuxstatic-arm64': 11.22.0
'@pnpm/linuxstatic-x64': 11.22.0
'@pnpm/macos-arm64': 11.22.0
'@pnpm/win-arm64': 11.22.0
'@pnpm/win-x64': 11.22.0
'@pnpm/linux-arm64': 11.25.0
'@pnpm/linux-x64': 11.25.0
'@pnpm/linuxstatic-arm64': 11.25.0
'@pnpm/linuxstatic-x64': 11.25.0
'@pnpm/macos-arm64': 11.25.0
'@pnpm/win-arm64': 11.25.0
'@pnpm/win-x64': 11.25.0
'@pnpm/linux-arm64@11.22.0':
'@pnpm/linux-arm64@11.25.0':
optional: true
'@pnpm/linux-x64@11.22.0':
'@pnpm/linux-x64@11.25.0':
optional: true
'@pnpm/linuxstatic-arm64@11.22.0':
'@pnpm/linuxstatic-arm64@11.25.0':
optional: true
'@pnpm/linuxstatic-x64@11.22.0':
'@pnpm/linuxstatic-x64@11.25.0':
optional: true
'@pnpm/macos-arm64@11.22.0':
'@pnpm/macos-arm64@11.25.0':
optional: true
'@pnpm/win-arm64@11.22.0':
'@pnpm/win-arm64@11.25.0':
optional: true
'@pnpm/win-x64@11.22.0':
'@pnpm/win-x64@11.25.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.22.0: {}
pnpm@11.25.0: {}
---
lockfileVersion: '9.0'
+11 -6
View File
@@ -6,12 +6,17 @@
## Файлы
| Файл | Маршрут в refs | Что показывает |
| ------------------------- | ------------------------------------ | --------------------------------------------------- |
| `index.html` | `/` | Главная: пайплайн-воркспейс + панель превью |
| `demo.html` | `/easy-png-tools/demo` | То же, что главная (зеркало) |
| `gradient.html` | `/easy-png-tools/gradient` | Инструмент «Градиент»: настройки + превью + CSS |
| `background-remover.html` | `/easy-png-tools/background-remover` | Инструмент «Удаление фона»: сравнение source/result |
| Файл | Маршрут в нашем приложении (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`).
## Особенности
+302
View File
@@ -1,6 +1,308 @@
<!DOCTYPE html>
<html lang="ru" class="bg-background">
<head>
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-greek-500-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-600-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-600-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-greek-600-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-vietnamese-600-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-greek-700-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
</style>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
+302
View File
@@ -1,6 +1,308 @@
<!DOCTYPE html>
<html lang="ru" class="bg-background">
<head>
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-greek-500-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-600-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-600-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-greek-600-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-vietnamese-600-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-greek-700-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
</style>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
+302
View File
@@ -1,6 +1,308 @@
<!DOCTYPE html>
<html lang="ru" class="bg-background">
<head>
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-greek-500-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-600-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-600-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-greek-600-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-vietnamese-600-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-greek-700-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
</style>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
-2608
View File
File diff suppressed because it is too large Load Diff
+302
View File
@@ -1,6 +1,308 @@
<!DOCTYPE html>
<html lang="ru" class="bg-background">
<head>
<style>
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-sans-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-greek-500-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-sans-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-600-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-cyrillic-600-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-greek-600-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-vietnamese-600-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(assets/fonts/ibm-plex-sans-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-ext-700-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-cyrillic-700-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-greek-700-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-vietnamese-700-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(assets/fonts/ibm-plex-sans-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-400-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(assets/fonts/ibm-plex-mono-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-ext-500-normal.woff2) format('woff2');
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-cyrillic-500-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-vietnamese-500-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(assets/fonts/ibm-plex-mono-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
</style>
<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
+151 -43
View File
@@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
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
@@ -25,40 +26,134 @@ const THEME_TOGGLE = `
`;
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;
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');
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'))
.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 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')
.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',
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const RAW = new Set(['pre','textarea','script','style']);
const RAW = new Set(["pre", "textarea", "script", "style"]);
function fmtHtml(html) {
const tokens = html.match(
/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi
) || [];
const tokens = html.match(/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi) || [];
const out = [];
let depth = 0;
let rawTag = null;
@@ -66,7 +161,7 @@ function fmtHtml(html) {
for (const tk of tokens) {
if (rawTag) {
out.push(tk);
if (new RegExp(`</${rawTag}\\s*>`, 'i').test(tk)) {
if (new RegExp(`</${rawTag}\\s*>`, "i").test(tk)) {
depth--;
rawTag = null;
}
@@ -75,39 +170,45 @@ function fmtHtml(html) {
const isOpen = /^<[a-zA-Z]/.test(tk);
const isClose = /^<\//.test(tk);
let name = '';
let name = "";
if (isOpen || isClose) {
name = (tk.match(/^<\/?([a-zA-Z0-9-]+)/) || [])[1]?.toLowerCase() || '';
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; }
out.push("\n" + " ".repeat(depth) + tk);
if (!/\/>$/.test(tk)) {
depth++;
rawTag = name;
}
} else if (isOpen && !RAW.has(name)) {
out.push('\n' + ' '.repeat(depth) + tk);
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);
out.push("\n" + " ".repeat(depth) + tk);
} else if (/^</.test(tk)) {
out.push('\n' + ' '.repeat(depth) + tk);
out.push("\n" + " ".repeat(depth) + tk);
} else {
const t = tk.replace(/\s+/g, ' ');
const t = tk.replace(/\s+/g, " ");
if (t.trim()) out.push(t);
}
}
return out.join('').replace(/^\n/, '').replace(/\n{3,}/g, '\n\n');
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 = '') {
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')) {
} else if (entry.endsWith(".html")) {
found.push(rel);
}
}
@@ -116,23 +217,30 @@ function walk(dir, base = '') {
const pages = walk(root)
// skip Next internals
.filter((rel) => !basename(rel).startsWith('_') && !basename(rel).startsWith('404'))
.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(', '));
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');
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, '');
html = html.replace(/<script[\s\S]*?<\/script>/g, "");
html = html.replace(/<link[^>]+rel="preload"[^>]+as="script"[^>]*>/g, "");
// inline compiled css instead of linking /_next/...
html = html.replace(
/<link[^>]+rel="stylesheet"[^>]*>/g,
() => '<style>\n' + prettyCss + '\n</style>',
);
html = 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);
@@ -140,5 +248,5 @@ for (const [src, out] of pages) {
const file = join(dest, out);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, html);
console.log('written:', file);
console.log("written:", file);
}
+33353 -996
View File
File diff suppressed because it is too large Load Diff
+5263 -183
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
{
"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
@@ -0,0 +1,47 @@
# 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)
_расхождений нет_
+50
View File
@@ -0,0 +1,50 @@
// Shared constants for the design-tokens ESLint plugin.
// Single source for property lists and regexes so each rule cooks from the
// same vocabulary.
// =====================================================================
// Properties that accept a COLOR.
// =====================================================================
export const COLOR_PROPS =
/^(color|background|background-color|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
// =====================================================================
// Properties that accept a SIZE (px/rem/em).
// z-index is handled separately (it's an integer, not a length).
// =====================================================================
export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|border-spacing)$/;
// =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations.
// =====================================================================
export const DURATION_PROPS =
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
// =====================================================================
// A hardcoded COLOR literal: hex / color functions / named colors.
// Regex legend:
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
// - names: common CSS color names on a word boundary
// =====================================================================
export const COLOR_LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
// =====================================================================
// A hardcoded SIZE literal (px/rem/em), fractional allowed: 0.5rem.
// =====================================================================
export const FORBIDDEN_SIZE_TOKEN = /\d+(?:\.\d+)?(?:px|rem|em)\b/g;
// =====================================================================
// A hardcoded DURATION literal (ms/s): 200ms, 0.3s.
// =====================================================================
export const FORBIDDEN_DURATION_TOKEN = /\d+(?:\.\d+)?(?:ms|s)\b/g;
// =====================================================================
// Token PREFIXES by category (tests run against the var() name, e.g.
// --color-bg, --brand-main, --space-1, --z-header).
// =====================================================================
export const COLOR_TOKEN = /^--(?:color|brand)-/;
export const SIZE_TOKEN = /^--(?:space|size|text|radius|bp|z)-/;
export const DURATION_TOKEN = /^--(?:duration|ease|motion)-/;
@@ -0,0 +1,81 @@
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
// A size property (padding, gap, font-size ...) must use a SIZE token
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
// (color, background, border-color ...) must use a COLOR token
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused.
import { COLOR_PROPS, COLOR_TOKEN, SIZE_PROPS, SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Collect the token names referenced by var() in a value.
const collectVars = (value) =>
Array.from(value.matchAll(/var\(\s*(--[\w-]+)/g), (m) => m[1]);
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban using a color token in size properties and a size token in color properties; each property must use tokens of its own category.",
category: "Design tokens",
recommended: true,
},
messages: {
categoryMismatch:
"Token '{{token}}' in '{{prop}}' belongs to the {{category}} category; expected a {{expected}} token.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
const isSizeProp = SIZE_PROPS.test(prop);
const isColorProp = COLOR_PROPS.test(prop);
if (!isSizeProp && !isColorProp) return;
for (const token of collectVars(value)) {
// A size property that holds a COLOR token.
if (isSizeProp && COLOR_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "color",
expected: "size",
});
}
// A color property that holds a SIZE token (z-radius/space/text...).
if (isColorProp && SIZE_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "size",
expected: "color",
});
}
}
});
},
};
},
};
@@ -0,0 +1,156 @@
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
// Everything visual in a Svelte <style> block must come from design tokens
// (var(--...)); direct literals are banned. color-mix() is banned too — its
// result must be tokenized in the design CSS file.
import {
COLOR_LITERAL,
COLOR_PROPS,
DURATION_PROPS,
FORBIDDEN_DURATION_TOKEN,
FORBIDDEN_SIZE_TOKEN,
SIZE_PROPS,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban hardcoded colors, sizes, durations and z-index in Svelte <style> blocks; use CSS variables (design tokens).",
category: "Design tokens",
recommended: true,
},
messages: {
hardcodedColor:
"Hardcoded color '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedSize:
"Hardcoded size '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedDuration:
"Hardcoded duration '{{value}}' in '{{prop}}'. Use a var(--duration-...) token.",
hardcodedZIndex:
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
hardcodedBreakpoint:
"Hardcoded breakpoint '{{value}}' in @media. Use a var(--bp-...) token.",
colorMix:
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// =====================================================================
// 3) NO HARDCODED BREAKPOINTS IN @media
// @media conditions must use --bp-* tokens, not raw lengths.
// Regex: (max|min)-width followed by a NON-var(() number.
// =====================================================================
const BREAKPOINT_RE =
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
// Sub-rule 6: color-mix() is banned anywhere in a component style.
const COLOR_MIX_RE = /color-mix\s*\(/gi;
// --- @media breakpoints --- //
root.walkAtRules("media", (atRule) => {
const params = atRule.params ?? "";
let m;
BREAKPOINT_RE.lastIndex = 0;
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
report(atRule, "hardcodedBreakpoint", { value: m[1] });
}
});
// --- declarations --- //
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Sub-rule 6: color-mix in any property.
if (COLOR_MIX_RE.test(value)) {
report(decl, "colorMix", { value: value.trim() });
}
// Sub-rule 4: durations in transition/animation props.
if (DURATION_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const m = withoutVars.match(FORBIDDEN_DURATION_TOKEN);
if (m) {
report(decl, "hardcodedDuration", { prop, value: m[0] });
}
}
// Sub-rule 7: z-index must come from a var(--z-...).
if (prop === "z-index") {
const withoutVars = stripVars(value).trim();
if (/^-?\d+$/.test(withoutVars)) {
report(decl, "hardcodedZIndex", { value: withoutVars });
}
}
// Only inspect known layout properties from here on.
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
// Sub-rule 2: sizes.
// Fire only on a real remaining px/rem/em token — percentages
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
// Only legal absolute length is a single 1px border line.
if (SIZE_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const match = withoutVars.match(FORBIDDEN_SIZE_TOKEN);
if (match) {
const shown = match[0];
if (shown !== "1px" && shown !== "0px") {
report(decl, "hardcodedSize", { prop, value: shown });
}
}
}
// Sub-rule 1: colors.
// Remaining value must be only allowed keywords
// (currentColor/transparent/inherit/none/0); otherwise a color
// literal is reported.
if (COLOR_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const withoutKeywords = withoutVars
.replace(
/\b(?:currentcolor|transparent|inherit|none|initial|unset|revert)\b/gi,
"",
)
.trim();
if (withoutKeywords !== "" && withoutKeywords !== "0") {
const match = value.match(COLOR_LITERAL);
if (match) {
report(decl, "hardcodedColor", {
prop,
value: match[0].trim(),
});
}
}
}
});
},
};
},
};
@@ -0,0 +1,82 @@
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
// A custom property may be DEFINED inside a component's <style> block, but its
// value must not introduce a design primitive: a raw color literal (hex/rgb/
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
// (colors/sizes) a single source of truth in preview.css, while still allowing
// local DERIVED variables built from tokens: var(--...), calc(), unitless
// ratios (--ratio: 1.5) — those are legitimate component-local state.
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban defining a custom property whose value introduces a raw color or size primitive in a Svelte <style> block; primitives belong in the design CSS file. Derived var()/calc()/unitless values are allowed.",
category: "Design tokens",
recommended: true,
},
messages: {
tokenPrimitive:
"Custom property '{{prop}}' defines a primitive '{{value}}' in a component. Move it to preview.css or derive it from tokens via var()/calc().",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// Only inspect custom property DEFINITIONS (--foo: value).
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
if (!prop.startsWith("--")) return;
const value = decl.value ?? "";
if (value.trim() === "") return;
const primitive = stripVars(value);
// A color literal in the definition → primitive.
const colorHit = primitive.match(COLOR_LITERAL);
if (colorHit) {
report(decl, "tokenPrimitive", {
prop,
value: colorHit[0].trim(),
});
return;
}
// An absolute size (px/rem/em) in the definition → primitive.
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
if (sizeHit) {
report(decl, "tokenPrimitive", {
prop,
value: sizeHit[0],
});
}
});
},
};
},
};
@@ -0,0 +1,91 @@
// Rule: NO UNDEFINED CSS-TOKEN USAGE IN COMPONENTS.
// A var(--...) referenced in a Svelte <style> block must exist in the design
// token file (src/preview.css) or be a local override defined in the same
// component. Catches typos and drop-in tokens that were never added to the
// "single source of truth".
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// A custom property DEFINITION: "--x:" followed by a colon.
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
// A var() REFERENCE (primary argument only — fallbacks are optional overrides).
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
const definedCache = new Map();
function getDefinedTokens(cwd) {
const file = resolve(cwd, "src/preview.css");
if (!existsSync(file)) return null;
if (definedCache.has(file)) return definedCache.get(file);
const content = readFileSync(file, "utf-8");
const tokens = new Set();
for (const match of content.matchAll(DEFINED_RE)) {
tokens.add(match[0]);
}
definedCache.set(file, tokens);
return tokens;
}
export default {
meta: {
type: "problem",
docs: {
description:
"Disallow referencing a CSS variable that is not defined in preview.css (design token file) and not defined locally in the component.",
category: "Design tokens",
recommended: true,
},
messages: {
undefinedToken:
"CSS variable '{{token}}' is not defined in preview.css and not locally in this component.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
const globalTokens = getDefinedTokens(context.cwd);
if (!globalTokens) return {};
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// Custom properties defined INSIDE this component count as defined.
const localTokens = new Set();
root.walkDecls((decl) => {
if (decl.prop?.startsWith("--")) localTokens.add(decl.prop);
});
const checkValue = (node, text) => {
if (!text) return;
let match;
VAR_REF_RE.lastIndex = 0;
while ((match = VAR_REF_RE.exec(text)) !== null) {
const token = match[1];
if (globalTokens.has(token) || localTokens.has(token)) continue;
report(node, "undefinedToken", { token });
}
};
root.walkDecls((decl) => checkValue(decl, decl.value ?? ""));
root.walkAtRules((atRule) => checkValue(atRule, atRule.params ?? ""));
},
};
},
};
@@ -0,0 +1,19 @@
// Shared helpers to talk to the postcss AST of Svelte <style> blocks that
// svelte-eslint-parser exposes. All rules use the same plumbing.
// Returns the postcss Root of the <style> block, or null when the file is not
// a Svelte component (or its style failed to parse).
export function getStyleRoot(context) {
const parserServices = context.sourceCode?.parserServices;
if (!parserServices || typeof parserServices.getStyleContext !== "function") {
return null;
}
const styleContext = parserServices.getStyleContext();
return styleContext?.status === "success" ? styleContext.sourceAst : null;
}
// Returns a converter from a postcss node to an ESLint loc, or null if the
// parser doesn't expose it.
export function getStyleNodeLoc(context) {
return context.sourceCode?.parserServices?.styleNodeLoc ?? null;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Local ESLint plugin "design-tokens".
*
* Goal: a single source of truth for design. In Svelte components, hardcoded
* colors and sizes are banned; everything must come from CSS variables (tokens
* in preview.css). Rules inspect the postcss AST of Svelte <style> blocks
* exposed by svelte-eslint-parser.
*/
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
import noTokenDefinitionInSvelte from "./design-tokens/no-token-definition-in-svelte.js";
import noUndefinedInSvelte from "./design-tokens/no-undefined-in-svelte.js";
export default {
meta: {
name: "design-tokens",
version: "0.2.0",
},
rules: {
"no-hardcoded-in-svelte": noHardcodedInSvelte,
"no-category-mismatch": noCategoryMismatch,
"no-token-definition-in-svelte": noTokenDefinitionInSvelte,
"no-undefined-in-svelte": noUndefinedInSvelte,
},
};
+19
View File
@@ -1,6 +1,7 @@
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import designTokens from "./eslint-plugins/index.js";
import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
@@ -79,6 +80,24 @@ export default tseslint.config(
parser: tseslint.parser,
},
},
plugins: {
"design-tokens": designTokens,
},
},
// Правило дизайн-токенов: запрет хардкода цветов/размеров в style-блоках.
// Применяется к новому коду редизайна (см. newCode выше). Когда старый дизайн
// удалят, расширить glob на весь код, исключив (old)/.
{
files: svelteFiles,
plugins: {
"design-tokens": designTokens,
},
rules: {
"design-tokens/no-hardcoded-in-svelte": "error",
"design-tokens/no-category-mismatch": "error",
"design-tokens/no-token-definition-in-svelte": "error",
"design-tokens/no-undefined-in-svelte": "error",
},
},
// Полные recommended-наборы — только на новый код.
...[
+14 -2
View File
@@ -11,22 +11,34 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"format": "prettier --write .",
"format": "prettier --write . --log-level warn",
"lint": "eslint .",
"audit": "node scripts/audit.mjs"
"lint:css": "stylelint \"./src/**/*.css\"",
"lint:all": "node scripts/lint-all.mjs",
"lint:tokens": "node scripts/check-tokens.mjs",
"refs-cdp-audit": "node scripts/audit-cdp.mjs",
"refs-cdp-audit:large": "node scripts/audit-cdp.mjs --viewport 2560x1440",
"refs-cdp-audit:responsive": "node scripts/audit-cdp-responsive.mjs",
"refs-css-audit": "node scripts/audit-css.mjs",
"refs-dom-audit": "node scripts/audit-dom.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@panmdaa/colors": "^0.4.2",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"diff": "^9.0.0",
"eslint": "^10.9.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"playwright": "^1.62.1",
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"stylelint": "^17.14.1",
"stylelint-config-standard": "^40.0.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"svelte-eslint-parser": "^1.8.1",
+881 -50
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import postcssHct from "./scripts/postcss-hct.mjs";
export default {
plugins: [postcssHct],
};
+30
View File
@@ -0,0 +1,30 @@
// 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
@@ -0,0 +1,523 @@
// Фаза 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
@@ -0,0 +1,415 @@
// Фаза 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);
});
-306
View File
@@ -1,306 +0,0 @@
// Стиль-аудит: снимает design-токены (light/dark) и дерево вычисленных стилей
// нашей preview-страницы и refs-html страницы, сравнивает и пишет отчёт.
// Запуск: pnpm audit (опции: --ours <url> --ref <file> --no-serve --route <path>)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
const args = (() => {
const a = {};
for (let i = 0; i < process.argv.length; i++) {
const v = process.argv[i];
if (v === "--no-serve") a.noServe = true;
else if (v === "--ours") a.ours = process.argv[++i];
else if (v === "--ref") a.ref = process.argv[++i];
else if (v === "--route") a.route = process.argv[++i];
}
return a;
})();
const WEB = process.cwd();
const PORT = 5179;
const ROUTE = args.route || "/preview/demo";
const OURS = args.ours || `http://127.0.0.1:${PORT}${ROUTE}`;
const REF = args.ref || resolve(WEB, "../refs-html/demo.html");
const FIELDS = [
"fontFamily",
"fontSize",
"fontWeight",
"color",
"backgroundColor",
"border",
"padding",
"margin",
"letterSpacing",
"lineHeight",
"borderRadius",
"display",
"gap",
];
const waitFor = async (url, ms = 60000) => {
const t = Date.now();
while (Date.now() - t < ms) {
try {
const r = await fetch(url);
if (r.ok) return;
} catch {}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("dev server not up: " + url);
};
const snapshot = (page, url) =>
page.evaluate((url) => {
const readTokens = () => {
const s = getComputedStyle(document.documentElement);
const out = {};
for (const k of s)
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
return out;
};
const tokensLight = readTokens();
const prev = document.documentElement.getAttribute("data-theme");
document.documentElement.setAttribute("data-theme", "dark");
const tokensDark = readTokens();
if (prev) document.documentElement.setAttribute("data-theme", prev);
else document.documentElement.removeAttribute("data-theme");
const sig = (el) => {
const s = getComputedStyle(el);
const o = {};
for (const f of [
"fontFamily",
"fontSize",
"fontWeight",
"color",
"backgroundColor",
"borderTopWidth",
"borderTopColor",
"padding",
"margin",
"letterSpacing",
"lineHeight",
"borderRadius",
"display",
"gap",
])
o[f] = s[f];
return o;
};
const rect = (el) => {
const r = el.getBoundingClientRect();
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
};
};
const walk = (el, path) => {
if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(el.tagName)) return [];
const out = [];
const kids = [...el.children];
out.push({
path,
tag: el.tagName.toLowerCase(),
text:
el.children.length === 0 ? el.textContent.trim().slice(0, 40) : "",
rect: rect(el),
style: sig(el),
});
for (let i = 0; i < kids.length; i++)
out.push(
...walk(
kids[i],
`${path}>${kids[i].tagName.toLowerCase()}:nth-child(${i + 1})`,
),
);
return out;
};
return { tokensLight, tokensDark, tree: walk(document.body, "body") };
}, url);
async function main() {
let server;
if (!args.noServe) {
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
cwd: WEB,
stdio: "ignore",
shell: true,
detached: process.platform !== "win32",
});
await waitFor(OURS);
}
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
try {
await page.goto(OURS, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(400);
const ours = await snapshot(page, OURS);
await page.goto(pathToFileURL(REF).href, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(400);
const ref = await snapshot(page, pathToFileURL(REF).href);
// token diff (игнорируем служебный мусор Tailwind/lightningcss — он
// ref-only и не относится к нашим смысловым токенам)
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
const tokDiff = (a, b) => {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
const rows = [];
for (const k of keys) {
if (TOK_NOISE.test(k)) continue;
const av = a[k] ?? "—";
const bv = b[k] ?? "—";
if (av !== bv) rows.push({ key: k, ours: av, ref: bv });
}
return rows;
};
const tokenDiffs = {
light: tokDiff(ours.tokensLight, ref.tokensLight),
dark: tokDiff(ours.tokensDark, ref.tokensDark),
};
// element diff: совпадающие по тексту элементы (структуры разные,
// поэтому nth-child-пути не совпадают — матчим по видимому тексту)
const byText = (tree) => {
const m = new Map();
for (const e of tree) if (e.text) if (!m.has(e.text)) m.set(e.text, e);
return m;
};
const txtO = byText(ours.tree);
const txtR = byText(ref.tree);
const onlyOurs = [];
const onlyRef = [];
const elementDiffs = [];
for (const [k, o] of txtO) {
const r = txtR.get(k);
if (!r) {
onlyOurs.push(k);
continue;
}
const deltas = {};
for (const f of FIELDS) {
const ov = (o.style[f] ?? "").toString();
const rv = (r.style[f] ?? "").toString();
if (ov !== rv) deltas[f] = { ours: ov, ref: rv };
}
const dr = o.rect;
const rr = r.rect;
if (
Math.abs(dr.x - rr.x) > 2 ||
Math.abs(dr.y - rr.y) > 2 ||
Math.abs(dr.w - rr.w) > 2 ||
Math.abs(dr.h - rr.h) > 2
)
deltas.rect = { ours: dr, ref: rr };
if (Object.keys(deltas).length)
elementDiffs.push({ path: o.path, tag: o.tag, text: k, deltas });
}
for (const [k] of txtR) if (!txtO.has(k)) onlyRef.push(k);
const report = {
generatedAt: new Date().toISOString(),
ours: OURS,
ref: REF,
tokenDiffs,
elementDiffs,
onlyOurs: onlyOurs.slice(0, 60),
onlyRef: onlyRef.slice(0, 60),
counts: {
tokenLight: tokenDiffs.light.length,
tokenDark: tokenDiffs.dark.length,
elements: elementDiffs.length,
onlyOurs: onlyOurs.length,
onlyRef: onlyRef.length,
},
};
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(
resolve(WEB, "audit/audit-report.json"),
JSON.stringify(report, null, 2),
);
writeFileSync(
resolve(WEB, "audit/audit-report.md"),
toMarkdown(report, ours, ref),
);
console.log(
`audit done: tokens light/dark=${tokenDiffs.light.length}/${tokenDiffs.dark.length}, ` +
`elements=${elementDiffs.length}, onlyOurs=${onlyOurs.length}, onlyRef=${onlyRef.length}`,
);
console.log(`report: web/audit/audit-report.md`);
} finally {
await browser.close();
if (server) {
try {
if (process.platform === "win32")
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
stdio: "ignore",
});
else server.kill("SIGTERM");
} catch {}
}
}
}
function toMarkdown(report, ours, ref) {
const tokTable = (rows, a, b) =>
rows.length
? `| token | ours | ref |\n|---|---|---|\n` +
rows.map((r) => `| \`${r.key}\` | ${r.ours} | ${r.ref} |`).join("\n")
: "_все совпадают_";
const sorted = [...report.elementDiffs].sort(
(x, y) => Object.keys(y.deltas).length - Object.keys(x.deltas).length,
);
const elRows = sorted
.slice(0, 100)
.map((e) => {
const d = Object.entries(e.deltas)
.map(([k, v]) => {
if (k === "rect") {
const f = (r) => `${r.x},${r.y} ${r.w}x${r.h}`;
return ` - **rect**: \`${f(v.ours)}\`\`${f(v.ref)}\``;
}
return ` - **${k}**: \`${v.ours}\`\`${v.ref}\``;
})
.join("\n");
return `### \`${e.path}\`${e.text ? ` — "${e.text}"` : ""}\n${d}`;
})
.join("\n\n");
return (
`# Style audit: ${report.ours}\n\nvs ${report.ref}\n\n_${report.generatedAt}_\n\n` +
`## Сводка\n\n- Токены light: **${report.counts.tokenLight}** расх.\n` +
`- Токены dark: **${report.counts.tokenDark}** расх.\n` +
`- Элементы (стиль/геометрия): **${report.counts.elements}** расх.\n` +
`- Только у нас: ${report.counts.onlyOurs}, только в рефе: ${report.counts.onlyRef}\n\n` +
`## Токены — Light\n\n${tokTable(report.tokenDiffs.light)}\n\n` +
`## Токены — Dark\n\n${tokTable(report.tokenDiffs.dark)}\n\n` +
`## Расхождения элементов (топ ${Math.min(100, sorted.length)})\n\n${elRows}\n\n` +
`## Только у нас (структурно)\n\n` +
(report.onlyOurs.length
? report.onlyOurs.map((p) => `- \`${p}\``).join("\n")
: "_—_") +
`\n\n## Только в рефе (структурно)\n\n` +
(report.onlyRef.length
? report.onlyRef.map((p) => `- \`${p}\``).join("\n")
: "_—_") +
`\n`
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+51
View File
@@ -0,0 +1,51 @@
// Design-token audit, one command: theme parity, hct-only color authorship and
// unused-token warnings over src/preview.css.
//
// Usage: pnpm --dir web exec node scripts/check-tokens.mjs
// Exit code 1 when a failing check (parity or color authorship) reports.
import { FILE, parsePreview } from "./token-audit/helpers.mjs";
import { checkParity } from "./token-audit/parity.mjs";
import { checkColorAuthorship } from "./token-audit/colors.mjs";
import { checkUnused } from "./token-audit/unused.mjs";
async function main() {
const { root, light, dark } = await parsePreview();
let failed = false;
const parityErrors = checkParity({ light, dark });
if (parityErrors.length > 0) {
console.log(
`Token parity violations in ${FILE}:\n${parityErrors.join("\n")}`,
);
failed = true;
} else {
console.log(
"Token parity: OK — all color tokens have both light/dark forms.",
);
}
const colorErrors = checkColorAuthorship(root);
if (colorErrors.length > 0) {
console.log(`Non-hct color values in ${FILE}:\n${colorErrors.join("\n")}`);
failed = true;
} else {
console.log("All colors are authored as hct().");
}
const unused = checkUnused(root);
if (unused.length > 0) {
console.log("\nUnused tokens (defined in preview.css, never used):");
for (const token of unused) console.log(` ${token}`);
} else {
console.log("All preview.css tokens are used somewhere.");
}
if (failed) process.exitCode = 1;
}
main().catch((error) => {
console.log(error);
process.exitCode = 1;
});
+61
View File
@@ -0,0 +1,61 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const WEB_DIR = fileURLToPath(new URL("../", import.meta.url));
const node = process.execPath;
// Every lint layer of the redesign, in one command. Each step runs even if a
// previous one fails; the exit code is nonzero if any did.
//
// Stream wiring: children run with stdio ["ignore", 1, 1] — both stdout and
// stderr are bound to OUR stdout file descriptor (shell `2>&1`). It is real FD
// inheritance, no pipes or buffering, so `> css-lint.txt` catches every byte.
// Color decisions are then the tool's own against the actual destination:
// a terminal gets colors, a redirected file gets plain text.
//
// One quirk: stylelint prints its report to stderr and paints it even when the
// destination is not a TTY. So for a non-TTY destination we pass its official
// --no-color explicitly.
const colorFlag = process.stdout.isTTY ? [] : ["--no-color"];
const bins = {
eslint: fileURLToPath(
new URL("../node_modules/eslint/bin/eslint.js", import.meta.url),
),
stylelint: fileURLToPath(
new URL("../node_modules/stylelint/bin/stylelint.mjs", import.meta.url),
),
checkTokens: fileURLToPath(new URL("check-tokens.mjs", import.meta.url)),
};
const steps = [
{
name: "ESLint (svelte) + design-tokens rules",
args: [bins.eslint, ...colorFlag, "."],
},
{
name: "Stylelint (css) — design-token style",
args: [bins.stylelint, ...colorFlag, "./src/**/*.css"],
},
{
name: "Token audit (parity + colors + unused)",
args: [bins.checkTokens],
},
];
let failed = false;
for (const [index, step] of steps.entries()) {
console.log(`\n[${index + 1}/${steps.length}] ${step.name}`);
const result = spawnSync(node, step.args, {
cwd: WEB_DIR,
stdio: ["ignore", 1, 1],
});
if (result.status !== 0) failed = true;
}
console.log(
failed
? "\nlint-all: FAILED — fix the reported problems and re-run."
: "\nlint-all: all checks passed.",
);
process.exit(failed ? 1 : 0);
+430
View File
@@ -0,0 +1,430 @@
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct() and
// emits plain sRGB hex colors.
//
// Supported syntax (Material Design 3 HCT; hue 0360, chroma 0~120, tone 0100):
//
// --color-success: hct(155 40 55); // literal
// --color-muted: hct(from var(--brand-main) h 6 97); // take hue from seed
// --brand-alt: hct(from var(--brand-main) h c calc(t + 36)); // channel math
//
// `from` seeds are resolved from sibling custom properties in the same
// stylesheet (:root tokens plus the current rule's own tokens). Seeds may be
// #hex, oklch(), rgb() or another hct(). Anything the plugin cannot resolve is
// left as-is. The emitter is sRGB hex; HCT stays the single source of truth.
import { Color } from "@panmdaa/colors";
const HCT_RE = /\bhct\(/;
const VAR_RE = /^var\(\s*(--[\w-]+)\s*\)$/;
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function wrapHue(degrees) {
return ((degrees % 360) + 360) % 360;
}
// --- postcss plugin entry -------------------------------------------------
const postcssHct = {
postcssPlugin: "postcss-hct",
Once(root, { result }) {
// Fallback environment: tokens from :root (highest-level definitions).
const rootEnv = collectTokensFromSelector(root, ":root");
root.walkRules((rule) => {
const env = new Map(rootEnv);
const decls = [];
rule.nodes?.forEach((node) => {
if (node.type === "decl" && node.prop.startsWith("--")) {
env.set(node.prop, node.value);
decls.push(node);
}
});
for (const decl of decls) {
if (HCT_RE.test(decl.value)) {
const next = replaceHctValues(decl, decl.value, env, result);
if (next !== decl.value) {
decl.value = next;
}
}
}
});
},
};
function collectTokensFromSelector(root, selector) {
const tokens = new Map();
root.walkRules((rule) => {
if (
typeof rule.selector === "string" &&
rule.selector
.split(",")
.map((s) => s.trim())
.includes(selector)
) {
rule.walkDecls((decl) => {
if (decl.prop.startsWith("--")) tokens.set(decl.prop, decl.value);
});
}
});
return tokens;
}
// --- value scanning -------------------------------------------------------
function findBalancedClosing(text, openParenIndex) {
let depth = 0;
for (let i = openParenIndex; i < text.length; i++) {
if (text[i] === "(") depth++;
else if (text[i] === ")") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
function replaceHctValues(node, value, env, result) {
let out = "";
let cursor = 0;
for (;;) {
const start = value.indexOf("hct(", cursor);
if (start === -1) {
out += value.slice(cursor);
break;
}
const end = findBalancedClosing(value, start + 3);
if (end === -1) {
out += value.slice(cursor);
break;
}
out += value.slice(cursor, start);
const inner = value.slice(start + 4, end);
const hct = resolveHctBody(inner, env, new Set());
if (hct) {
out += Color.fromHct(hct.h, hct.c, hct.t).toHexColor();
} else {
result.warn(`postcss-hct: could not resolve hct(${inner})`, { node });
out += value.slice(start, end + 1);
}
cursor = end + 1;
}
return out;
}
// --- hct() body parsing ---------------------------------------------------
// Resolve the body of one hct(...) call to { h, c, t } or null.
function resolveHctBody(inner, env, seen) {
const text = inner.trim();
if (!text) return null;
if (text.startsWith("from ")) {
const rest = text.slice(5).trimStart();
if (!rest) return null;
const scan = readSourceToken(rest);
if (!scan) return null;
const { source, remainder } = scan;
const seed = resolveSeed(source, env, seen);
if (!seed) return null;
const parts = splitTopLevel(remainder, " ");
if (parts.length !== 3) return null;
const h = safeNumber(evalComponent(parts[0], seed));
const c = safeNumber(evalComponent(parts[1], seed));
const t = safeNumber(evalComponent(parts[2], seed));
if (h === null || c === null || t === null) return null;
return {
h: wrapHue(h),
c: Math.max(0, c),
t: clamp(t, 0, 100),
};
}
// Literal: hct(h c t)
const parts = splitTopLevel(text, " ");
if (parts.length !== 3) return null;
const nums = parts.map((p) => Number.parseFloat(p));
if (nums.some((n) => !Number.isFinite(n))) return null;
return {
h: wrapHue(nums[0]),
c: Math.max(0, nums[1]),
t: clamp(nums[2], 0, 100),
};
}
function safeNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
// Read "var(--x)", "hct(...)", a color function or a bare token off the front.
function readSourceToken(text) {
const first = text[0];
if (first === "v") {
const match = text.match(/^var\(\s*--[\w-]+\s*\)/);
if (!match) return null;
return {
source: match[0],
remainder: text.slice(match[0].length).trimStart(),
};
}
if (first === "(" || first === "#" || /[a-z]/i.test(first)) {
const match = text.match(/^[a-z]+\(/i);
if (match) {
const end = findBalancedClosing(text, text.indexOf("("));
if (end === -1) return null;
return {
source: text.slice(0, end + 1),
remainder: text.slice(end + 1).trimStart(),
};
}
const bare = text.match(/^(#[\w.]+|-?[\d.]+)/i);
if (!bare) return null;
return {
source: bare[1],
remainder: text.slice(bare[1].length).trimStart(),
};
}
return null;
}
// Split on a delimiter, ignoring delimiters inside balanced parentheses.
function splitTopLevel(text, delimiter) {
const parts = [];
let depth = 0;
let current = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === delimiter && depth === 0) {
if (current.trim()) parts.push(current.trim());
current = "";
} else {
current += ch;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
}
// --- seed resolution ------------------------------------------------------
function resolveSeed(source, env, seen) {
if (VAR_RE.test(source.trim())) {
const name = source.trim().match(VAR_RE)[1];
const raw = env.get(name);
if (raw === undefined) return null;
if (seen.has(name)) return null; // circular reference
seen.add(name);
return resolveRawValue(raw, env, seen);
}
return parseColorValue(source.trim());
}
function resolveRawValue(raw, env, seen) {
const text = raw.trim();
if (text.startsWith("hct(") && text.endsWith(")")) {
const inner = text.slice(4, -1);
return resolveHctBody(inner, env, seen);
}
return parseColorValue(text);
}
// Parse a concrete color into HCT channels, or null.
function parseColorValue(text) {
const value = text.trim();
if (/^#[0-9a-f]{3}$/i.test(value)) {
return channelsFromHex(expandShortHex(value));
}
if (/^#[0-9a-f]{6}$/i.test(value)) {
return channelsFromHex(value);
}
// Split on whitespace; parseFloat drops "%"/"deg" units.
const oklch = value.match(/^oklch\(([^)]+)\)/i);
if (oklch) {
const channels = oklch[1]
.trim()
.split(/\s+/)
.map((ch) => Number.parseFloat(ch));
if (channels.length !== 3 || channels.some((n) => !Number.isFinite(n)))
return null;
const [L, C, h] = channels;
const hex = oklchToHex(L > 1 ? L / 100 : L, C, h);
if (hex) return channelsFromHex(hex);
}
const rgb = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
if (rgb) {
const [r, g, b] = rgb.slice(1).map(Number);
return channelsFromHex(hexFromRgb(r, g, b));
}
return null;
}
function channelsFromHex(hex) {
try {
const color = Color.from(hex);
return { h: color.hue, c: color.chroma, t: color.tone };
} catch {
return null;
}
}
function expandShortHex(hex) {
return (
"#" +
hex
.slice(1)
.split("")
.map((ch) => ch + ch)
.join("")
);
}
function oklchToHex(L, C, hDeg) {
const hr = (hDeg * Math.PI) / 180;
const a = C * Math.cos(hr);
const b = C * Math.sin(hr);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return hexFromRgb(r * 255, g * 255, bl * 255);
}
function hexFromRgb(r, g, b) {
const toByte = (v) =>
clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0");
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
}
// --- channel arithmetic ---------------------------------------------------
// Evaluate one hct() component: a channel ref (h/c/t), a number, or a calc()
// expression over the seed channels.
function evalComponent(text, seed) {
const value = text.trim();
if (value === "h" || value === "c" || value === "t") {
return seed[value];
}
if (/^[-+]?[\d.]/.test(value)) {
// unit-bearing number like "36deg"
return Number.parseFloat(value);
}
return evaluateExpr(value, seed);
}
// --- tiny math evaluator for calc()/min()/max()/clamp() ---------------------
const MATH_FUNCS = new Set(["calc", "min", "max", "clamp"]);
// Lex and evaluate a calc() expression. Supported: numbers, + - * /,
// parentheses, channel refs (h/c/t) and the calc/min/max/clamp functions.
function evaluateExpr(text, seed) {
return new ExprParser(text, seed).parse();
}
function tokenizeExpression(text) {
const tokens = [];
const re = /(\d+(?:\.\d+)?|\.\d+|[+\-*/(),]|[a-z]+)/g;
let match;
while ((match = re.exec(text)) !== null) {
tokens.push(match[1]);
}
return tokens;
}
class ExprParser {
constructor(text, seed) {
this.tokens = tokenizeExpression(text);
this.pos = 0;
this.seed = seed;
}
peek() {
return this.tokens[this.pos];
}
next() {
return this.tokens[this.pos++];
}
parse() {
const value = this.parseAdditive();
return this.pos === this.tokens.length ? value : NaN;
}
parseAdditive() {
let value = this.parseMulDiv();
for (;;) {
const op = this.peek();
if (op !== "+" && op !== "-") break;
this.pos++;
const rhs = this.parseMulDiv();
value = op === "+" ? value + rhs : value - rhs;
}
return value;
}
parseMulDiv() {
let value = this.parseAtom();
for (;;) {
const op = this.peek();
if (op !== "*" && op !== "/") break;
this.pos++;
const rhs = this.parseAtom();
value = op === "*" ? value * rhs : value / rhs;
}
return value;
}
parseAtom() {
const token = this.next();
if (token === undefined) return NaN;
if (token === "h" || token === "c" || token === "t") {
return this.seed[token];
}
if (/^\d/.test(token)) return Number.parseFloat(token);
if (token === "-") return -this.parseAtom();
if (token === "+") return this.parseAtom();
if (token === "(") {
const value = this.parseAdditive();
if (this.next() !== ")") return NaN;
return value;
}
if (MATH_FUNCS.has(token)) {
return this.parseFunction(token);
}
return NaN;
}
parseFunction(name) {
if (this.next() !== "(") return NaN;
const args = [this.parseAdditive()];
while (this.peek() === ",") {
this.pos++;
args.push(this.parseAdditive());
}
if (this.next() !== ")") return NaN;
if (name === "calc") return args[0];
if (name === "min") return Math.min(...args);
if (name === "max") return Math.max(...args);
if (name === "clamp") {
const [min, value, max] = args;
return clamp(value, min, max);
}
return NaN;
}
}
export default postcssHct;
+27
View File
@@ -0,0 +1,27 @@
// Color authorship: every color value in preview.css must be authored as hct()
// — literals AND derived forms (hct(from var(...) h c t) with channel math) —
// so the whole palette is computed through HCT channels and output as sRGB by
// the postcss-hct plugin. Exceptions: the seed tokens --brand-main/--brand-alt
// (any format allowed) and color-mix(...) (the only sanctioned way to blend
// two tokens).
import { isColorValue } from "./helpers.mjs";
const BRAND_RE = /^--brand-(main|alt)$/;
const HCT_RE = /^hct\s*\(/i;
const COLOR_MIX_RE = /^color-mix\s*\(/i;
export function checkColorAuthorship(root) {
const errors = [];
root.walkDecls((decl) => {
if (!decl.prop.startsWith("--")) return;
if (BRAND_RE.test(decl.prop)) return;
if (!isColorValue(decl.value)) return;
if (HCT_RE.test(decl.value) || COLOR_MIX_RE.test(decl.value)) return;
const where = decl.parent?.selector || decl.parent?.name || "";
errors.push(
` ${decl.prop} (${where}): ${decl.value} — colors must be hct(); only --brand-main/--brand-alt may use other formats`,
);
});
return errors;
}
+77
View File
@@ -0,0 +1,77 @@
// Shared plumbing for the design-token audits: reading preview.css, scanning
// for var() usages across src, and the color/form predicates the checks use.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import postcss from "postcss";
export const FILE = new URL("../../src/preview.css", import.meta.url);
const SRC_DIR = fileURLToPath(new URL("../../src/", import.meta.url));
// A var(--x) REFERENCE anywhere in the app (primary argument only).
const USAGE_RE = /var\(\s*(--[\w-]+)/g;
const SCAN_EXTS = new Set([".svelte", ".css", ".ts", ".js", ".mjs"]);
// Recursively list source files; no glob dependency needed.
function listFiles(dir) {
const files = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
files.push(...listFiles(full));
} else if (SCAN_EXTS.has(name.slice(name.lastIndexOf(".")))) {
files.push(full);
}
}
return files;
}
// Collect every token referenced via var() across all source files.
export function collectUsedTokens() {
const used = new Set();
for (const file of listFiles(SRC_DIR)) {
const content = readFileSync(file, "utf8");
for (const match of content.matchAll(USAGE_RE)) {
used.add(match[1]);
}
}
return used;
}
// A token is "colorful" when its value is a color literal — those must have a
// dark-mate. Non-color tokens (fonts, radii, durations) are exempt.
const COLOR_RE =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab|hct|color-mix)\s*\(/i;
export const isColorValue = (value) => COLOR_RE.test(value);
// A token is "derived" when its value references var(...) — it adapts to the
// theme automatically, so it must NOT have a hardcoded dark twin.
export const isDerived = (value) => value.includes("var(");
// Collect { token: value } custom properties defined inside a given selector.
export function collectTokens(root, selector) {
const map = new Map();
root.walkRules((rule) => {
if (rule.selector === selector) {
rule.walkDecls((decl) => {
if (decl.prop.startsWith("--")) map.set(decl.prop, decl.value);
});
}
});
return map;
}
// Parse preview.css once and hand out the postcss root plus both theme maps.
export async function parsePreview() {
const css = await readFile(FILE, "utf8");
const root = postcss.parse(css);
return {
root,
light: collectTokens(root, ":root"),
dark: collectTokens(root, '[data-theme="dark"]'),
};
}
+27
View File
@@ -0,0 +1,27 @@
// Theme parity: every color token in `:root` must also exist in
// `[data-theme="dark"]` and vice versa. A color defined in only one theme
// silently breaks dark mode. Derived tokens (values containing var()) adapt
// to the theme automatically and are exempt.
import { isColorValue, isDerived } from "./helpers.mjs";
export function checkParity({ light, dark }) {
const errors = [];
// Every colorful light token must have a dark-mate (derived excluded).
for (const [token, value] of light) {
if (!isColorValue(value) || isDerived(value)) continue;
if (!dark.has(token)) {
errors.push(` ${token} in :root has no [data-theme="dark"] override`);
}
}
// Every dark token must exist in :root (a dark-only token is orphaned).
for (const [token] of dark) {
if (!light.has(token)) {
errors.push(` ${token} in [data-theme="dark"] is missing in :root`);
}
}
return errors;
}
+14
View File
@@ -0,0 +1,14 @@
// Unused tokens: defined in preview.css but never referenced via var()
// anywhere in src. A dead token is not the single source of truth — it's dust.
// Reported as a warning; it does not fail the run.
import { collectUsedTokens } from "./helpers.mjs";
export function checkUnused(root) {
const defined = new Set();
root.walkDecls((decl) => {
if (decl.prop.startsWith("--")) defined.add(decl.prop);
});
const used = collectUsedTokens();
return [...defined].filter((token) => !used.has(token));
}
-281
View File
@@ -1,281 +0,0 @@
:root {
color-scheme: light;
--bg: #f6f7f9;
--surface: #ffffff;
--text: #1b1e24;
--text-muted: #5c6470;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-contrast: #ffffff;
--link: var(--accent);
--border: #dfe2e8;
--danger: #e5484d;
--danger-strong: #b3261e;
--check-a: #eceff3;
--check-b: #ffffff;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--radius-s: 6px;
--radius-m: 10px;
--text-xs: 0.8rem;
--text-s: 0.85rem;
--text-m: 0.95rem;
--text-l: 1.1rem;
--text-xl: 1.25rem;
--text-xxl: 1.75rem;
--leading-tight: 1.25;
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
--transition-fast: 120ms ease;
--control-height: 2.4rem;
--control-max-width: 16rem;
--font-sans:
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
--font-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #12151a;
--surface: #1b2027;
--text: #e6e9ee;
--text-muted: #98a1ad;
--accent-hover: #4c85f0;
--link: #6ea0ff;
--border: #303842;
--danger: #ff7479;
--danger-strong: #ff8589;
--check-a: #232830;
--check-b: #171b21;
--shadow-card: 0 4px 14px rgb(0 0 0 / 45%);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-family: var(--font-sans);
color: var(--text);
background: var(--bg);
line-height: 1.55;
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100dvh;
}
h1 {
font-size: var(--text-xxl);
line-height: var(--leading-tight);
letter-spacing: -0.01em;
}
h2 {
font-size: var(--text-xl);
line-height: var(--leading-tight);
}
p,
label {
font-size: var(--text-m);
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
select {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-s);
}
.text-muted {
color: var(--text-muted);
}
.text-caption {
font-size: var(--text-s);
}
.heading-section {
font-size: var(--text-l);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-m);
}
.control {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
max-width: var(--control-max-width);
}
input.control[type="number"] {
font-family: var(--font-mono);
width: 100%;
}
.error-banner {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--danger);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger-strong);
font-size: var(--text-s);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.tool-stage {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
align-items: start;
width: 100%;
}
.tool-stage.single {
grid-template-columns: 1fr;
}
.tool-stage .cell {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tool-stage .cell + .cell {
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
.cell-media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 14rem;
position: relative;
width: 100%;
}
.chain-stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-4);
}
.edge-legend {
position: absolute;
top: -0.65em;
padding: 0 var(--space-2);
background: var(--surface);
color: var(--text-muted);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.3;
white-space: nowrap;
z-index: 1;
}
.params-sep {
position: relative;
border-top: 1px solid var(--border);
margin: 0 var(--space-4);
padding-bottom: var(--space-3);
}
/* Панель этапа для инлайн-раскладки: исходник | параметры | результат */
.pane {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
}
.pane-legend {
top: -0.65em;
left: 50%;
transform: translateX(-50%);
}
.actions-row {
display: flex;
gap: var(--space-1);
align-items: stretch;
width: 100%;
}
.actions-row > button {
flex: 1;
min-width: 0;
}
@media (max-width: 48rem) {
.tool-stage {
grid-template-columns: 1fr;
}
.tool-stage .cell + .cell {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
}
+1 -1
View File
@@ -23,6 +23,6 @@
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
%sveltekit.body%
</body>
</html>
@@ -1,27 +0,0 @@
<script lang="ts">
import BlueprintGrid from "./BlueprintGrid.svelte";
let { children } = $props();
</script>
<div class="app-shell">
<BlueprintGrid />
<div class="app-content">
{@render children()}
</div>
</div>
<style>
.app-shell {
position: relative;
min-height: 100dvh;
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
.app-content {
position: relative;
z-index: 1;
}
</style>
-43
View File
@@ -1,43 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Tone = "default" | "accent" | "success" | "amber";
interface Props {
tone?: Tone;
children: Snippet;
}
let { tone = "default", children }: Props = $props();
</script>
<span class="badge badge--{tone}">
{#if children}{@render children()}{/if}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.15rem 0.45rem;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--muted);
}
.badge--accent {
color: var(--blue);
border-color: color-mix(in srgb, var(--blue) 40%, var(--line));
}
.badge--success {
color: var(--success);
border-color: color-mix(in srgb, var(--success) 40%, var(--line));
}
.badge--amber {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 40%, var(--line));
}
</style>
@@ -1,22 +0,0 @@
<div class="blueprint-grid" aria-hidden="true"></div>
<style>
.blueprint-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(
to right,
color-mix(in srgb, var(--line) 45%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--line) 45%, transparent) 1px,
transparent 1px
);
background-size: 32px 32px;
opacity: 0.55;
}
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
label: string;
count: number;
children: Snippet;
}
let { label, count, children }: Props = $props();
</script>
<section class="catalog-group">
<div class="group-title">
<span>{label}</span>
<i>{count.toString().padStart(2, "0")} <!-- --> TOOLS</i>
</div>
<div class="tool-cards">
{@render children()}
</div>
</section>
<style>
.catalog-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.group-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--line);
}
.group-title span {
font: 12px var(--font-mono);
letter-spacing: 0.14em;
color: var(--foreground);
}
.group-title i {
font: 10px var(--font-mono);
font-style: normal;
letter-spacing: 0.08em;
color: var(--muted);
}
.tool-cards {
display: grid;
gap: 10px;
}
</style>
@@ -0,0 +1,66 @@
<script lang="ts">
interface Props {
total: number;
}
let { total }: Props = $props();
</script>
<div class="catalog-head">
<div>
<div class="eyebrow">EASY-PNG-TOOLS / CATALOG</div>
<h1>Tool catalog</h1>
<p>
Focused utilities for working with PNG. Inspect, transform, and export —
locally in your browser.
</p>
</div>
<div class="catalog-total">
<b>{total}</b>
<span>TOOLS<br /> AVAILABLE</span>
</div>
</div>
<style>
.catalog-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1.5rem;
flex-wrap: wrap;
}
.eyebrow {
font: 12px var(--font-mono);
letter-spacing: 0.18em;
color: var(--muted);
margin-bottom: 0.5rem;
}
h1 {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: var(--foreground);
}
p {
margin: 0.5rem 0 0;
max-width: 56ch;
color: var(--muted);
font-size: 0.95rem;
}
.catalog-total {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1;
}
.catalog-total b {
font: 700 2rem var(--font-mono);
color: var(--foreground);
}
.catalog-total span {
font: 10px var(--font-mono);
letter-spacing: 0.08em;
color: var(--muted);
text-align: right;
margin-top: 0.35rem;
}
</style>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Search, Filter } from "@lucide/svelte";
interface Props {
query: string;
category: string;
}
let { query = $bindable(), category = $bindable() }: Props = $props();
const FILTERS = [
{ value: "all", label: "ALL" },
{ value: "convert", label: "CONVERT" },
{ value: "alpha", label: "TRANSPARENCY" },
{ value: "color", label: "COLOR" },
{ value: "geometry", label: "GEOMETRY" },
{ value: "filters", label: "FILTERS" },
{ value: "analyze", label: "ANALYZE" },
];
</script>
<div class="catalog-toolbar">
<label class="catalog-search">
<Search size={16} />
<input
aria-label="Search tools"
placeholder="Search tools..."
bind:value={query}
/>
</label>
<div class="catalog-filters">
<Filter size={15} />
{#each FILTERS as f (f.value)}
<button
type="button"
class:active={category === f.value}
onclick={() => (category = f.value)}>{f.label}</button
>
{/each}
</div>
</div>
<style>
.catalog-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 1.25rem 0;
}
.catalog-search {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
color: var(--muted);
}
.catalog-search input {
border: none;
background: transparent;
outline: none;
color: var(--foreground);
font: 12px var(--font-mono);
}
.catalog-search input::placeholder {
color: var(--muted);
}
.catalog-filters {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
color: var(--muted);
}
.catalog-filters button {
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: 10px var(--font-mono);
letter-spacing: 0.06em;
padding: 0.4rem 0.6rem;
border-radius: var(--radius);
cursor: pointer;
}
.catalog-filters button.active {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
</style>
+48 -24
View File
@@ -1,12 +1,20 @@
<script lang="ts">
import Field from "./Field.svelte";
import { ChevronDown } from "@lucide/svelte";
interface Props {
label: string;
value: string;
dark?: boolean;
chevron?: boolean;
oninput?: (value: string) => void;
}
let { label, value = $bindable(), oninput }: Props = $props();
let {
label,
value = $bindable(),
dark = false,
chevron = false,
oninput,
}: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
@@ -14,43 +22,59 @@
}
</script>
<Field {label}>
<div class="control-block">
<label>{label}</label>
<div class="color-field">
<input type="color" class="color-swatch" {value} oninput={handle} />
<span class="swatch" class:dark style="background:{value}"></span>
<input
type="text"
class="color-hex"
aria-label={label}
{value}
oninput={handle}
spellcheck="false"
/>
{#if chevron}<ChevronDown class="icon" size={14} />{/if}
</div>
</Field>
</div>
<style>
.control-block :global(.icon) {
color: var(--foreground);
}
label {
display: flex;
justify-content: space-between;
font: 10px var(--font-mono);
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 8px;
}
.color-field {
display: flex;
align-items: center;
gap: 0.4rem;
}
.color-swatch {
width: 2rem;
height: 1.6rem;
padding: 0;
gap: 8px;
padding-left: 8px;
padding-right: 8px;
height: 32px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
cursor: pointer;
}
.color-hex {
flex: 1;
font-family: var(--font-mono);
font-size: 11px;
.swatch {
width: 14px;
height: 14px;
border: 1px solid var(--line);
flex: none;
}
.color-field input {
min-width: 0;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.3rem 0.5rem;
text-transform: uppercase;
font: 11px var(--font-mono);
background: 0 0;
border: 0;
outline: 0;
flex: 1;
}
.color-field :global(svg) {
color: var(--muted);
flex: none;
}
</style>
@@ -1,23 +1,35 @@
<script lang="ts">
import { Download } from "@lucide/svelte";
import Button from "./Button.svelte";
import { ArrowDownToLine, Download } from "@lucide/svelte";
interface Props {
label: string;
size?: string;
onclick?: () => void;
}
let { label, size, onclick }: Props = $props();
let { label, onclick }: Props = $props();
</script>
<Button icon={Download} variant="primary" onclick={() => onclick?.()}>
{label}{#if size}<span class="dl-size">{size}</span>{/if}
</Button>
<button class="download-btn" type="button" onclick={() => onclick?.()}>
<Download size={16} />
{label}
<ArrowDownToLine size={14} />
</button>
<style>
.dl-size {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.8;
.download-btn {
background: var(--blue);
color: #fff;
width: 100%;
height: 42px;
font: 11px var(--font-mono);
border: 0;
justify-content: center;
align-items: center;
gap: 9px;
margin-top: 18px;
display: flex;
white-space: nowrap;
}
.download-btn:hover {
background: color-mix(in srgb, var(--blue) 88%, #000);
}
</style>
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
compact?: boolean;
children: Snippet;
}
let { compact = false, children }: Props = $props();
</script>
<div class="controls" class:compact>
{@render children()}
</div>
<style>
.controls {
display: grid;
grid-template-columns: 1.1fr 1fr 1.2fr;
gap: 16px;
}
.controls.compact {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 800px) {
.controls,
.controls.compact {
grid-auto-flow: row;
grid-auto-columns: auto;
}
}
</style>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Component } from "svelte";
import { Upload } from "@lucide/svelte";
interface Props {
name: string;
size?: string;
icon?: Component<{ size?: number }>;
}
let { name, size, icon = Upload }: Props = $props();
const Cmp = $derived(icon);
</script>
<div class="file-chip">
<Cmp size={15} />
<span>{name}</span>
{#if size}<b>{size}</b>{/if}
</div>
<style>
.file-chip {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
border: 1px solid var(--line);
background: var(--panel);
font: 11px var(--font-mono);
color: var(--muted);
padding: 10px 12px;
}
.file-chip b {
color: var(--blue);
font-weight: 500;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<footer class="preview-footer">
<span>easy-png-tools <b class="version">v2.4.0</b></span>
<span>© 2024</span>
</footer>
<style>
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px clamp(20px, 4vw, 72px);
border-top: 1px solid var(--line);
font: 10px var(--font-mono);
color: var(--muted);
& span {
display: flex;
align-items: center;
gap: 7px;
}
}
.preview-footer .version {
font-weight: 400;
color: var(--blue);
}
</style>
+16 -6
View File
@@ -33,16 +33,21 @@
<style>
.icon-btn {
display: inline-flex;
display: grid;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: inherit;
line-height: inherit;
border: 0;
background: 0 0;
border: 0;
place-items: center;
padding: 6px;
display: grid;
}
.icon-btn:hover:not(:disabled) {
color: var(--foreground);
@@ -59,8 +64,6 @@
border-color: var(--cyan);
}
.icon-btn--bare {
width: auto;
height: auto;
padding: 6px;
border: 0;
border-radius: 0;
@@ -74,4 +77,11 @@
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn--ghost {
padding: 6px;
border: 0;
border-radius: 0;
color: var(--muted);
}
</style>
+5 -3
View File
@@ -15,8 +15,10 @@
<style>
.meta-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
border-top: 1px solid var(--line);
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding-top: 14px;
display: grid;
}
</style>
+8 -11
View File
@@ -8,27 +8,24 @@
<div class="meta-row">
<span class="meta-caption">{caption}</span>
<span class="meta-value">{value}</span>
<b class="meta-value">{value}</b>
</div>
<style>
.meta-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0;
gap: 5px;
display: grid;
}
.meta-caption {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 9px;
color: var(--muted);
line-height: normal;
}
.meta-value {
font-family: var(--font-mono);
font-size: 11px;
margin-top: 5px;
line-height: normal;
font: 11px var(--font-mono);
color: var(--foreground);
}
</style>
+3 -4
View File
@@ -19,11 +19,10 @@
.mono-label {
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.2;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 400;
letter-spacing: 0.12em;
color: var(--muted);
line-height: normal;
}
.accent {
@@ -0,0 +1,44 @@
<script lang="ts">
import { RotateCcw } from "@lucide/svelte";
import StatusDot from "./StatusDot.svelte";
interface Props {
onreset?: () => void;
note?: string;
}
let { onreset, note = "changes are applied automatically" }: Props = $props();
</script>
<div class="pipeline-footer">
<button class="reset-btn" onclick={() => onreset?.()}>
<RotateCcw size={14} /> Reset pipeline
</button>
<span class="auto-note"><StatusDot /> {note}</span>
</div>
<style>
.pipeline-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 18px;
}
.reset-btn {
display: flex;
align-items: center;
gap: 7px;
color: var(--blue);
background: none;
border: 0;
cursor: pointer;
font-size: 12px;
}
.auto-note {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font: 11px var(--font-mono);
}
</style>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
pad?: string;
children: Snippet;
}
let { pad = "0", children }: Props = $props();
</script>
<div class="preview-stack" style="padding:{pad};">
{@render children()}
</div>
<style>
.preview-stack {
grid-template-columns: 1fr 1fr;
gap: 12px;
width: 100%;
min-width: 0;
display: grid;
}
@media (max-width: 1100px) {
.preview-stack {
grid-template-columns: repeat(3, minmax(220px, 1fr));
}
}
</style>
+53 -16
View File
@@ -3,35 +3,72 @@
interface Props {
label: string;
caption: string;
active: boolean;
children: Snippet;
}
let { label, children }: Props = $props();
let { label, caption, active, children }: Props = $props();
</script>
<div class="preview-tile">
<span class="tile-label">{label}</span>
<div class="preview-tile" class:active>
<div class="tile-canvas">{@render children()}</div>
<div class="tile-label">
<span>{label}</span>
<b>{caption}</b>
</div>
</div>
<style>
.preview-tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.4rem;
background: var(--panel);
background: var(--background);
&.active {
border-color: var(--blue);
}
.tile-label {
font: 10px var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--blue);
}
.tile-canvas {
border-radius: var(--radius);
width: 100%;
min-width: 0;
min-height: 260px;
min-height: clamp(300px, 22vw, 430px);
overflow: hidden;
min-height: 120px;
aspect-ratio: 1.35;
background-color: var(--checker-background);
background-image:
linear-gradient(45deg, var(--checker-background-cross) 25%, #0000 25%),
linear-gradient(-45deg, var(--checker-background-cross) 25%, #0000 25%),
linear-gradient(45deg, #0000 75%, var(--checker-background-cross) 75%),
linear-gradient(-45deg, #0000 75%, var(--checker-background-cross) 75%);
background-position:
0 0,
0 7px,
7px -7px,
-7px 0;
background-size: 14px 14px;
place-items: center;
padding: 16px;
display: grid;
}
.tile-label {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 8px;
border-top: 1px solid var(--line);
font: 9px var(--font-mono);
color: var(--blue);
}
.tile-label span {
font: 9px var(--font-mono);
color: var(--blue);
}
.tile-label b {
font: 400 9px var(--font-mono);
color: var(--muted);
text-align: right;
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
title: string;
meta?: string;
actions?: Snippet;
unwrapActions?: boolean;
}
let { label, title, meta, actions, unwrapActions = false }: Props = $props();
</script>
<div class="section-label">
<div class="section-text">
<MonoLabel>{label}</MonoLabel>
<strong
>{title}{#if meta}<em>{meta}</em>{/if}</strong
>
</div>
{#if actions}
{#if unwrapActions}
{@render actions()}
{:else}
<div class="section-actions">
{@render actions()}
</div>
{/if}
{/if}
</div>
<style>
.section-label {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.section-text strong {
display: block;
margin-top: 5px;
font: 600 14px var(--font-mono);
color: var(--foreground);
}
.section-text em {
color: #25a96a;
font-style: normal;
font-size: 10px;
}
.section-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
</style>

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