Compare commits

...
10 Commits
37 changed files with 1653 additions and 207 deletions
+3
View File
@@ -1,6 +1,9 @@
name: Quality name: Quality
on: on:
push:
branches:
- main
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
+1
View File
@@ -10,6 +10,7 @@ refs/public/placeholder-logo.png
refs/public/placeholder.svg refs/public/placeholder.svg
web/audit/cdp-audit*.txt web/audit/cdp-audit*.txt
css-lint.txt css-lint.txt
web/coverage
# Локальные планы с приватной информацией (выбранное имя домена) — не коммитить # Локальные планы с приватной информацией (выбранное имя домена) — не коммитить
docs/plan-domain.local.md docs/plan-domain.local.md
+50 -11
View File
@@ -9,7 +9,8 @@
корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать корне: `pnpm build`, `pnpm dev` (это `pnpm --dir web ...`). Не использовать
npm. npm.
- Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web check`, тесты: - Сборка: `pnpm --dir web build`, проверка типов: `pnpm --dir web check`, тесты:
`pnpm --dir web test` (Vitest), тесты lint-правил: `pnpm --dir web test` (Vitest), тесты с coverage-гейтом:
`pnpm --dir web test:coverage`, тесты lint-правил:
`pnpm --dir web test:rules`, e2e: `pnpm --dir web test:e2e` (Playwright, `pnpm --dir web test:rules`, e2e: `pnpm --dir web test:e2e` (Playwright,
`web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт `web/e2e/`, свой webServer на `pnpm build` + `scripts/serve-static.mjs`, порт
4173). 4173).
@@ -18,12 +19,11 @@
(docs) + `pnpm --dir web exec prettier --check .` (код). (docs) + `pnpm --dir web exec prettier --check .` (код).
- Полный быстрый gate: `pnpm verify`; подробная матрица команд и CI — - Полный быстрый gate: `pnpm verify`; подробная матрица команд и CI —
`docs/quality-gates.md`. `docs/quality-gates.md`.
- После пуша результат CI проверяется явно: смотреть и blocking `verify`, и - После пуша результат CI проверяется явно: смотреть статусы отдельных job, а не
non-blocking `e2e` — если тот job вообще запускался. `Quality` workflow живёт общий conclusion прогона. У `e2e` стоит `continue-on-error`, поэтому прогон с
на `pull_request` и `workflow_dispatch`, поэтому при прямом пуше в `main` упавшим e2e помечается как `success`. `Quality` workflow запускается на каждом
выполняется только `Deploy to GitHub Pages` со своим `verify`, а browser пуше в `main` и на `pull_request`, поэтому browser matrix проверяется сама;
matrix проверяется на PR или ручным запуском. Красный `e2e` не блокирует PR, ручной запуск нужен только для старого коммита.
но остаётся падением — сообщать его фактический статус.
- Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок: - Линтинг: `pnpm --dir web lint` (ESLint). Полный прогон дизайн-проверок:
`pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг `pnpm --dir web lint:all` (ESLint + stylelint + токен-аудит). Каждый шаг
выполняется, даже если предыдущий упал. Новые изменения не должны добавлять выполняется, даже если предыдущий упал. Новые изменения не должны добавлять
@@ -61,10 +61,46 @@
нескольких местах и запутывает. (Синтаксис самих определений — под линтером: нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
строка `conventions/no-string-union-alias` и т.д.) строка `conventions/no-string-union-alias` и т.д.)
### Границы изменений
- Production — только текущие маршруты и `web/src/lib/components/**`. Старый
UI (`/v1/**`) удалён и не возвращается: без redirects и совместимого режима;
исторические упоминания живят только в `docs/archive/`. Будущий API-контракт
`POST /v1/process` из `docs/plan-platform.md` не связан с удалённым UI-v1.
- `core/` и `theme/` — общие модули: их изменение проверяется unit-тестами и
smoke-сценариями production-потребителей.
### i18n
- Новый пользовательский текст — только через словари и `t()`. Исключения:
бренд, форматы и имена файлов, технические значения, registry source strings и
`/kit`. Проверяется warn-правилом `i18n/no-hardcoded-user-text`, детали и
список исключений — в `web/eslint-plugins/README.md`.
- Новые ключи добавляются во **все** локали сразу: паритет ключей и
плейсхолдеров проверяет `i18n/dict-consistency`.
### Комментарии
- Комментарий объясняет «почему», а не пересказывает код.
- `TODO`/`FIXME` — только зарегистрированные задачи: ссылка на `docs/backlog.md`
или issue и критерий закрытия. В коде незарегистрированных пометок не
оставляем.
### Новые lint-правила
Новое правило сначала появляется в report-only/warn-only режиме, получает
fixtures и тесты (`test:rules`), и только после очистки baseline может стать
blocking. Учитывай: `lint:all` гоняет ESLint с `--max-warnings=0`, поэтому
warn-правило обязано приземлиться без срабатываний на текущем коде.
### Дизайн ### Дизайн
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила, Источник правды по визуальному языку — сам код: `web/src/app.css` (дизайн-токены
которые линтер не проверяет: и глобальные стили), `web/src/lib/components/**` (компоненты и локальные стили)
и витрина `/kit`. Приёмка — `docs/checklist-manual-testing.md`, ограничения
линтера — `web/eslint-plugins/README.md`. Общие правила, которые линтер не
проверяет:
- Новый дизайн живёт в `web/src/app.css` и применяется ко всем - Новый дизайн живёт в `web/src/app.css` и применяется ко всем
production-маршрутам. production-маршрутам.
@@ -72,14 +108,17 @@
`web/src/lib/components/`, даже «просто div с двумя стилями». `web/src/lib/components/`, даже «просто div с двумя стилями».
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из - Дизайн-токены (цвета, размеры, длительности, z-index) — только из
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`. CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
- Новый токен появляется вместе с потребителем: `pnpm --dir web lint:tokens`
сообщает о неиспользуемых токенах, и это не блокирует CI только потому, что
проверка оставлена предупреждением.
## Документация ## Документация
- Здесь держать только обязательные инварианты, команды и ссылки на подробные - Здесь держать только обязательные инварианты, команды и ссылки на подробные
документы; не превращать `AGENTS.md` в полный справочник. документы; не превращать `AGENTS.md` в полный справочник.
- Индекс активных документов: `docs/README.md`; подробный workflow: - Индекс активных документов: `docs/README.md`; подробный workflow:
`docs/agent-workflow.md`; quality gates: `docs/quality-gates.md`; текущий `docs/agent-workflow.md`; quality gates: `docs/quality-gates.md`; тестовая
план: `docs/plan-codebase-quality.md`. архитектура: `docs/testing-strategy.md`.
- Новые подробные правила, процедуры и таблицы хранить в `docs/`, а детали - Новые подробные правила, процедуры и таблицы хранить в `docs/`, а детали
конкретных lint-правил — в `web/eslint-plugins/README.md`. конкретных lint-правил — в `web/eslint-plugins/README.md`.
- Завершённые или устаревшие документы переносить в `docs/archive/`, указывать - Завершённые или устаревшие документы переносить в `docs/archive/`, указывать
+24 -14
View File
@@ -8,23 +8,19 @@
1. `AGENTS.md` — команды и обязательные инварианты. 1. `AGENTS.md` — команды и обязательные инварианты.
2. Этот файл — выбрать актуальный документ по теме. 2. Этот файл — выбрать актуальный документ по теме.
3. `docs/plan-codebase-quality.md` — текущий план приведения качества в порядок 3. Соответствующий план или checklist темы.
и последовательность следующих шагов. 4. Исходники, конфигурация и тесты рядом с изменяемым кодом.
4. Соответствующий план или checklist темы.
5. Исходники, конфигурация и тесты рядом с изменяемым кодом.
## Активные документы ## Активные документы
| Документ | Роль | Статус | | Документ | Роль | Статус |
| ---------------------------------- | ---------------------------------------------------- | ------ | | ---------------------------------- | ---------------------------------------------------- | ------ |
| `AGENTS.md` | Краткий обязательный контракт для агента | active | | `AGENTS.md` | Краткий обязательный контракт для агента | active |
| `docs/plan-codebase-quality.md` | План quality gates, тестов и lint-правил | draft |
| `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active | | `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active |
| `docs/testing-strategy.md` | Locator, fixture, i18n и fixme-policy для e2e | active | | `docs/testing-strategy.md` | Locator, fixture, i18n и fixme-policy для e2e | active |
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active | | `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active | | `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
| `docs/roadmap.md` | Общие направления развития | active | | `docs/roadmap.md` | Общие направления развития | active |
| `docs/plan-redesign.md` | Новый дизайн production UI | active |
| `docs/plan-seo.md` | SEO/GEO-план | active | | `docs/plan-seo.md` | SEO/GEO-план | active |
| `docs/plan-platform.md` | PWA/API/WASM-план | active | | `docs/plan-platform.md` | PWA/API/WASM-план | active |
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active | | `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
@@ -47,26 +43,40 @@
`web/eslint-plugins/README.md` и исходниках правил. `web/eslint-plugins/README.md` и исходниках правил.
- Продуктовые решения и ручная приёмка — в backlog, плане и checklist. - Продуктовые решения и ручная приёмка — в backlog, плане и checklist.
У документа должен быть один источник истины: остальные файлы ссылаются на него,
а не пересказывают. Подробные материалы хранятся в `docs/`, чтобы не засорять
корень репозитория. Не создавать отдельные `docs/commands.md`,
`docs/known-issues.md` или копию backlog: команды принадлежат `quality-gates`,
известные проблемы — backlog и checklist. `README.md` в корне остаётся кратким
обзором продукта, а не вторым руководством агента.
Новый документ создаётся только когда темы нет в существующем файле; иначе
обновляется источник истины. Активный документ имеет статус (`draft`, `active`,
`completed`) и понятную область применения.
Подробные материалы хранятся в `docs/`, а не в корне. Новый файл создаётся Подробные материалы хранятся в `docs/`, а не в корне. Новый файл создаётся
только если темы нет в существующем документе; иначе обновляется источник только если темы нет в существующем документе; иначе обновляется источник
истины. истины.
## Текущий baseline Q6f.3 ## Текущий baseline
На момент фиксации Q6f.3:
- production registry содержит 122 инструмента; - production registry содержит 122 инструмента;
- `pnpm --dir web check` проходит без ошибок и предупреждений; - `pnpm --dir web check` проходит без ошибок и предупреждений;
- `pnpm --dir web test` — 43 test files / 465 tests; - `pnpm --dir web test` — 46 test files / 534 tests, включая тесты token audit,
- `pnpm --dir web test:rules` — 4 test files / 74 tests; PostCSS HCT-плагина и правил линтинга;
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused - `pnpm --dir web test:rules` — 5 test files / 104 tests;
design tokens от token audit; - `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens
от token audit;
- coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%,
lines 92.53% при порогах 90 / 82 / 72 / 91;
- `pnpm verify` проходит; - `pnpm verify` проходит;
- `pnpm --dir web test:e2e` — 276 тестов в четырёх проектах: 276 passed, 0 - `pnpm --dir web test:e2e` — 276 тестов в четырёх проектах: 276 passed, 0
skipped, 0 failed. Отказ декодера битого PNG зафиксирован per-engine в skipped, 0 failed. Отказ декодера битого PNG зафиксирован per-engine в
`web/e2e/png-fixtures.spec.ts`. `web/e2e/png-fixtures.spec.ts`.
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. Состав проверок и их семантика описаны в `docs/quality-gates.md`, тестовая
архитектура — в `docs/testing-strategy.md`. Baseline обновляется здесь при
изменении любого из гейтов.
## Правила актуальности ## Правила актуальности
+3 -3
View File
@@ -123,8 +123,8 @@ E2E запускается отдельно от быстрых проверок
Коммит и пуш делает разработчик. После пуша агент проверяет результат CI и Коммит и пуш делает разработчик. После пуша агент проверяет результат CI и
сообщает его вместе с локальными проверками, включая случай, когда нужный job не сообщает его вместе с локальными проверками, включая случай, когда нужный job не
запускался: `Quality` workflow с browser matrix живёт на `pull_request` и запускался: статусы смотрятся по отдельным job, потому что общий conclusion
`workflow_dispatch`, поэтому прямой пуш в `main` проверяет только быстрый gate. прогона с `continue-on-error` у `e2e` остаётся зелёным. Процедура описана в
Процедура описана в `docs/quality-gates.md`. `docs/quality-gates.md`.
Следующий шаг начинается только после ревью текущего изменения. Следующий шаг начинается только после ревью текущего изменения.
@@ -1,10 +1,17 @@
# План: линтеры, тесты и качество кодовой базы # План: линтеры, тесты и качество кодовой базы
> Статус: **draft — Q0–Q5 и Q6a–Q6f.3 готовы к ревью; Q6f.4 — следующий этап**. > Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
> Причина: все этапы Q0–Q8 выполнены или закрыты решениями; рабочие части
> перенесены в `docs/quality-gates.md`, `docs/testing-strategy.md`,
> `docs/agent-workflow.md`, `AGENTS.md`, `web/eslint-plugins/README.md` и
> `docs/backlog.md`. Использование: только историческая справка
>
> Выполнены Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3; открытых шагов не осталось.
> >
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6f.3 выполнены; Q6f.4–Q8 выполняются > инфраструктурных скриптов. Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3 выполнены.
> отдельными атомарными шагами. > Решения, важные за пределами плана, живут в `docs/quality-gates.md`,
> `docs/testing-strategy.md` и `AGENTS.md`.
> >
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
> проверок, стабилизировать тестовые контракты и только после этого расширять > проверок, стабилизировать тестовые контракты и только после этого расширять
@@ -43,8 +50,8 @@
- `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование, - `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование,
docs и production build; deploy выполняет его перед сборкой. docs и production build; deploy выполняет его перед сборкой.
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt — - `i18n/dict-consistency` даёт 0 warnings на текущем baseline; после Q7 вычищены
4 unused design tokens от token audit. 4 неиспользуемых design token, token audit тоже чист.
- E2E-контракт использует semantic locators и общий source-aware schema - E2E-контракт использует semantic locators и общий source-aware schema
resolver; активных known-issue `test.fixme` нет. resolver; активных known-issue `test.fixme` нет.
- `ToolError` хранится как key/vars и переводится только при выводе; смена - `ToolError` хранится как key/vars и переводится только при выводе; смена
@@ -112,7 +119,8 @@ pnpm --dir web test:e2e
- Добавить отдельный workflow для pull request: typecheck, unit, rule-тесты, - Добавить отдельный workflow для pull request: typecheck, unit, rule-тесты,
lint, format и build. lint, format и build.
- E2E сначала запускать отдельным job; сделать его blocking после закрытия - E2E сначала запускать отдельным job; сделать его blocking после закрытия
известных ошибок и стабилизации baseline. известных ошибок и стабилизации baseline. Условия перехода и текущая политика
описаны в `docs/quality-gates.md`, раздел «Политика e2e в CI».
**Готово, когда:** одна команда воспроизводит все быстрые проверки, а deploy не **Готово, когда:** одна команда воспроизводит все быстрые проверки, а deploy не
может пройти при type/unit/lint/build-ошибке. может пройти при type/unit/lint/build-ошибке.
@@ -132,8 +140,8 @@ pnpm --dir web test:e2e
option-группы из `ru.ts`. option-группы из `ru.ts`.
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается - Исправлен tie-break placeholder parity: при равенстве голосов выбирается
`BASE_LOCALE`. `BASE_LOCALE`.
- `lint-all` запускает ESLint с `--max-warnings=0`; token audit пока оставляет 4 - `lint-all` запускает ESLint с `--max-warnings=0`; unused-token сообщение
unused-token warnings отдельным debt. оставалось неблокирующим и закрыто в Q7.
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим - Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
этапом. этапом.
@@ -330,61 +338,213 @@ Firefox и WebKit для browser-critical спецификаций и mobile Chr
- Локальный прогон: 276 passed, 0 skipped, 0 failed. Visual snapshots и coverage - Локальный прогон: 276 passed, 0 skipped, 0 failed. Visual snapshots и coverage
thresholds не добавлялись — это Q6f.4. thresholds не добавлялись — это Q6f.4.
#### Q6f.4. Оставшиеся направления #### Q6f.4. Coverage thresholds
- visual snapshots для kit/критичных экранов; **Статус:** выполнен для чистой логики. Vitest считает покрытие только по
- coverage thresholds для `web/src/lib/`; `src/lib`, с явным списком исключений, и `pnpm verify` падает при падении ниже
- при необходимости отдельное решение по DOM-тестам `SchemaToolView`/controls. порога.
- `web/vitest.config.ts`: provider `v8`, `reporter: text-summary`, пороги
statements 90, branches 82, functions 72, lines 91.
- Пороги поставлены на 1.5–2.5 п.п. ниже измеренного baseline (91.77 / 83.3 /
74.31 / 92.53), поэтому gate ловит регрессию, а не текущий долг.
- Scope — логика, которую node-Vitest вообще может исполнить. Исключены
браузерные модули `core/domText.ts` и `core/io.ts` (canvas,
`createImageBitmap`), data-модули `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts`,
rune-файлы `*.svelte.ts`, entry worker-а и type-only `components/define.ts`.
Причина каждого исключения зафиксирована в конфиге и в
`docs/testing-strategy.md`.
- `registry/*` (47–81% statements, 25–47% functions) в scope остаётся: это
обычная непокрытая логика, а не ограничение окружения. Покрывается e2e-smoke
Q6e, но unit-порог её не скрывает.
- `verify` теперь выполняет `test:coverage` вместо `test`: набор тестов тот же,
двойного прогона нет.
- Порог functions самый слабый (74.31% против 91.77%): добавление инструмента с
непокрытым `run` быстрее всего опускает именно его. Это ожидаемо, порог держит
запас.
- DOM-тесты `SchemaToolView`/controls сознательно не начаты: node-Vitest не
имеет DOM, а e2e уже покрывает поведение. Задача живёт в `docs/backlog.md`.
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
file-result-инструментам. file-result-инструментам.
#### Q6f.5. Visual snapshots
**Статус:** решено не делать. Обоснование, чем закрыта потребность сейчас и при
каком условии стоит вернуться — в `docs/testing-strategy.md`, раздел 8.
### Q7. Укрепление lint-инфраструктуры ### Q7. Укрепление lint-инфраструктуры
**Оценка:** M. **Зависимости:** Q1, Q2, Q3. **Оценка:** M. **Зависимости:** Q1, Q2, Q3.
- Очистить или использовать unused design tokens; после очистки решить, должен #### Q7a. Очистка неиспользуемых design tokens
ли unused audit быть fail-режимом.
- Добавить тесты для token audit и PostCSS HCT-плагина. **Статус:** выполнен. Token audit чист: `All app.css tokens are used somewhere`.
- Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов.
- Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и - Удалены `--color-text-accent-muted` (light и dark, иначе ломается parity),
hardcoded colors наравне со Svelte styles. `--space-text-s: 0`, `--size-content-max: 880px` и `--duration-m: 0.3s`.
- Новые правила подключать только после fixtures, `test:rules` и baseline. - Проверено, что мёртвые токены — это пыль, а не признак хардкода: дублирующих
значений (`max-width`, `0.3s`, muted-цвет) в `src` нет. Потребители
`space-text-m/xl/2xl`, `size-content-max-wide` и `size-workspace-min`
используются, поэтому шкалы не тронуты.
- Шкалы остались неполными намеренно (`duration-s/l` без `m`, `space-text` без
`s`): мёртвый элемент шкалы хуже, чем «дырявая» шкала, а вернуть его можно
вместе с потребителем.
- Режим unused-аудита решён в Q7d: он остаётся предупреждением.
#### Q7b. Тесты token audit и PostCSS HCT-плагина
**Статус:** выполнен. У обоих не было ни одного теста, хотя HCT-плагин
(`web/scripts/postcss-hct.mjs`, 430 строк) пересчитывает всю палитру.
- `web/scripts/__tests__/postcss-hct.test.mjs` — 17 тестов: literal и
производный `hct()`, seed из `:root` и из hex, цепочка hct-токенов, refs
`h/c/t`, `calc/min/max/clamp`, обёртка hue, клампы tone/chroma, несколько
вызовов в одном объявлении, а также warn-and-keep для неизвестного seed,
циклической ссылки и битого тела `hct()`.
- `web/scripts/__tests__/token-audit.test.mjs` — 15 тестов на parity
(light-only, dark-only, derived, не-цвет), authorship (hct, color-mix,
seed-токены, хардкод с локацией, игнор не-цветов и обычных свойств), unused и
предикаты `isColorValue`/`isDerived`.
- Ожидания не зашивают вывод плагина, а сравнивают его с эквивалентной
литеральной записью: `hct(from var(--seed) h c calc(t + 10))` должен совпасть
с `hct(120 40 60)`, если seed — `hct(120 40 50)`. Иначе тест просто
зафиксировал бы текущее поведение.
- Единственный seam: `checkUnused(root, used = collectUsedTokens())` —
зависимость от чтения `src` стала инъецируемой, production-вызов не изменился.
- `vitest.config.ts` подхватывает `scripts/**/*.test.mjs`, поэтому скрипты
покрываются обычным прогоном и `verify`; `test:rules` не трогали.
- Проверка мутацией выявила дыру: первая версия тестов не ловила кламп tone в
ветке `from` (смена `clamp(t, 0, 100)` на `0, 90` проходила зелёной). Добавлен
отдельный тест на производный кламп; после этого мутация падает.
#### Q7c. Базовый JS recommended для инфраструктуры линтинга
**Статус:** выполнен. `eslint-plugins/**` и `scripts/**` больше не вне линта.
- `web/eslint.config.js`:
`toolingFiles = ["**/eslint-plugins/**/*.js", "**/scripts/**/*.mjs"]` получают
`js.configs.recommended` плюс `languageOptions` с `globals.node` (иначе
`no-undef` ругается на `console`, `process` и `URL` в Node-скриптах).
- Полные TS/svelte-наборы туда не подключаются: это `.mjs` и конфиги, а не
продуктовый код.
- Масштаб оказался небольшим: 28 проблем, из них 26 — `no-undef` на
Node-глобалах и только 2 настоящие: мёртвые импорты `existsSync` и `resolve` в
`scripts/serve-static.mjs`. Линтер сразу нашёл пыль в инфраструктуре, для
которой его раньше не было.
- `eslint-plugins/**` дал ноль проблем, а новые тесты `scripts/__tests__` тоже
попали под правило.
- Проверено пробой: после включения неиспользуемая переменная и вызов
неопределённого глобала в `scripts/` дают ошибки, то есть правило применилось,
а не молча пропустило файлы.
#### Q7d. Решения по оставшимся вопросам
**Статус:** закрыт тремя решениями.
1. **Обычный CSS под те же токен-правила, что Svelte styles — узко.** В `src`
всего один CSS-файл, и он же словарь токенов, поэтому переносить туда
`no-hardcoded-in-svelte` и подобные нечего: stylelint уже проверяет
`src/**/*.css` (префиксы, запрет `!important`, цвет только у brand-токенов),
а `check-tokens.mjs` дублирует проверку цвета. Реальный пробел был один —
`var()` может ссылаться на несуществующий токен, — его закрыл новый модуль
`web/scripts/token-audit/refs.mjs` (см. Q7e). Широкую унификацию механизмов
решено не делать: это лишний слой поверх дублирующихся проверок.
2. **Конфиги под рекомендации — только JS.** `*.config.js`
(`eslint`/`postcss`/`stylelint`) добавлены в `toolingFiles`; по измерению они
дают 0 проблем, поэтому это бесплатно. TS-конфиги (`vite`, `vitest`,
`playwright`) не подключены: им потребовался бы `tseslint`-парсер и запись
`vite.config.ts` в `allowDefaultProject`, а выигрыш близок к нулю. Попутно
удалена мёртвая запись `svelte.config.js` в `allowDefaultProject` — такого
файла нет.
3. **Unused-аудит остаётся предупреждением.** Правило «новый токен — вместе с
потребителем» закреплено в `AGENTS.md`. Причина: `verify` входит в
deploy-workflow, поэтому стилевой долг блокировал бы деплой сайта; кроме
того, чтение токена из JS (`getPropertyValue`) аудит не видит и дал бы ложное
срабатывание. Baseline чистый, пыль вычищена в Q7a.
#### Q7e. Проверка резолва `var()` в `app.css`
**Статус:** выполнен. Аудит падает, если `var(--x)` в `app.css` указывает на
токен, которого нет в этом же файле.
- `web/scripts/token-audit/refs.mjs` + вызов в `check-tokens.mjs`; режим —
ошибка, а не предупреждение, потому что это дефект (объявление молча
отбрасывается), а не стилистический долг.
- `var(--x, fallback)` не считается ошибкой: fallback легален без определения
токена.
- 7 тестов в `scripts/__tests__/token-audit.test.mjs`: резолв в обеих темах,
опечатка с локацией, fallback, дедуп повторов, ссылка внутри определения
токена, `var()` в обычном свойстве.
- Проверено на живом файле: временная опечатка `var(--color-bordr)` дала
`Unresolved var() references` и код выхода 1; `app.css` восстановлен байт в
байт.
### Q8. Новые автоматические правила ### Q8. Новые автоматические правила
**Оценка:** L. **Зависимости:** Q4, Q7. **Оценка:** L. **Зависимости:** Q4, Q7. **Разбивка:** три атомарных шага, каждый
заканчивается либо работающим warn-правилом с нулевым baseline, либо измерением
с записью вывода. Механически важно: `lint-all` запускает ESLint с
`--max-warnings=0`, поэтому warn-правило обязано приземлиться без срабатываний
на текущем коде.
#### Дублирование HTML/Svelte-разметки #### Q8.1. Пользовательские тексты вне i18n
Сначала провести report-only аудит новых `components/**` и `routes/**`. **Статус:** выполнен. Пилот `i18n/no-hardcoded-user-text` (warn) с нулевым
Определить: baseline; 2 реальных нарушения исправлены.
- минимальный размер клона; - Измерение до правила: 42 строковых атрибута пользовательского текста, все — в
- нормализацию Svelte blocks и динамических классов; `/kit`; вне кита текстовые узлы дали 2 находки. Итог после исключений: 2
- список допустимых fixture/allowlist-случаев. настоящих бага и 2 бренда, которые план требовал исключить.
- `web/eslint-plugins/i18n/no-hardcoded-user-text.js`: узлы `SvelteText` плюс
строковые литералы пользовательских атрибутов; `<style>`/`<script>`
пропускаются.
- Исключения из плана реализованы списками, а не эвристиками: форматы/единицы/
имена файлов/версии/размеры, ALL CAPS идентификаторы (registry source
strings), `/kit` через scope, бренд через опцию `allowWords` (в конфиге, а не
в коде правила).
- Исправлены 2 бага: `ColorPairControl.svelte` показывал английские `From`/`To`.
Ключи `ui.from`/`ui.to` уже существовали в обоих словарях — компонент их
просто не использовал, поэтому новые ключи не понадобились.
- 30 тестов в `__tests__/no-hardcoded-user-text.test.ts`; мутацией проверено,
что правило не декоративное: с пустым `allowWords` бренд начинает репортиться.
- Известные слепые зоны, зафиксированные как границы пилота: текст, собранный в
скрипте и переданный выражением, и текст вне Svelte-шаблонов (например, в
атрибутах, приходящих из registry). Расширение поверхности — отдельная работа,
а не молчаливое расширение правила.
Только после оценки false positives выбирать между готовым detector и #### Q8.2. Дублирование HTML/Svelte-разметки
собственным AST-правилом. Не включать detector в blocking gate сразу.
#### Пользовательские тексты вне i18n **Статус:** измерено, решение — правило не делать.
Сделать warn-only пилот с исключениями для: Одноразовый аудит (скрипт после измерения удалён) по 65 `.svelte` в
`components/**` и `routes/**`: при пороге 5 нормализованных строк — **одна**
пара, при пороге 3 строки — 17 пар, и все они шум (`</label>`, `{/each}`,
`{#if children}`). Единственная осмысленная находка — побайтово идентичные
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte`; она заведена в
`docs/backlog.md` (идея 16).
- бренда и версии; Почему автоматика отменена:
- форматов и имён файлов;
- `X`/`Y` и технических значений;
- registry source strings;
- `/kit`;
- тестовых данных и внутренних сообщений.
Blanket-запрет всех string literals не использовать. - построчное сравнение Svelte-разметки принципиально ненадёжно: одинаковый
контейнер с разным содержимым даёт ложное срабатывание, а намеренно общий
класс — ложное отсутствие сигнала;
- чтобы не ловить шум, порог пришлось бы поднять до 12+ строк, и тогда правило
срабатывает ровно один раз — на паре страниц, которые по замыслу должны
различаться; дальше нужна либо allowlist-запись, либо принудительный
рефакторинг;
- контроль остаётся на ревью: расхождение видно в диффе, а поведение ловит e2e
(`navigation.spec.ts` открывает все маршруты, `catalog.spec.ts` проверяет
каталог), а извлечение общей разметки уже закреплено конвенцией в `AGENTS.md`.
#### Согласованность комментариев #### Q8.3. Согласованность комментариев
Автоматический lint не делать. Это review/process policy: комментарий должен **Статус:** решение — автоматический lint не делать, это process policy.
объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue
и иметь критерий закрытия. Описания инструментов различаются осознанно, и ловить это текстовым правилом
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
`TODO`/`FIXME` из кода перенесены в `docs/backlog.md` (7 записей), в коде не
осталось ни одного незарегистрированного. Правило-детектор не добавлялось.
## 4. Что описать в `AGENTS.md` ## 4. Что описать в `AGENTS.md`
@@ -482,19 +642,6 @@ checklist. `README.md` в корне остаётся кратким обзор
Каждый PR должен быть атомарным и небольшим; не объединять `SchemaPreview` Каждый PR должен быть атомарным и небольшим; не объединять `SchemaPreview`
refactor, новые i18n-правила и исправление продуктовых багов в одну работу. refactor, новые i18n-правила и исправление продуктовых багов в одну работу.
## 6. Отдельные направления и что не начинать автоматически Продуктовые направления, которые план не начинает (платформа PWA/API/WASM,
plural-формы, batch/архивы, region-инструменты), живут в `docs/backlog.md` и
- полноценный browser/visual/coverage matrix; `docs/roadmap.md`; границы этого плана — в разделе 1.
- полноценная PWA/API/WASM-платформа;
- `SchemaPreview` state/view-model refactor;
- plural-формы до модели словаря;
- универсальный линтер одинаковых комментариев;
- batch/архивы, region-инструменты и остальные продуктовые волны из backlog.
## 7. Решения, которые нужно принять на ревью
- Какие executor/worker/fallback сценарии войдут в первый Q6 coverage PR?
- Нужны ли browser/mobile matrix и visual snapshots до coverage thresholds?
- Какие unused design tokens можно удалить, а какие должны остаться?
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
отдельным ночным прогоном?
+1 -1
View File
@@ -7,7 +7,7 @@
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI, > Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2 > локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и > реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
> ограничения — в `docs/plan-codebase-quality.md`. > ограничения — в `docs/testing-strategy.md` и `docs/backlog.md`.
## 1. Что получается ## 1. Что получается
@@ -1,11 +1,16 @@
# План: переезд на новый дизайн (refs) — параллельная сборка # План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: C17 **выполнен** (`docs/archive/plan-c17.md`), C18 **выполнен**, Q3 > Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
> (удаление старого UI) **выполнен**. Остался C20 (шаг 6, проверка нового > Причина: переезд на новый дизайн завершён (C17/C18/Q3), C20 и C21 выполнены,
> дизайна) — **следующий этап**. C21 (удалить refs) остаётся отдельной уборкой. > пункты полировки разобраны и разложены по `docs/backlog.md`. Источник правды
> Общий tech-debt baseline и следующие quality-задачи зафиксированы в > по визуальному языку — `web/src/app.css`, `web/src/lib/components/**`, витрина
> `docs/plan-codebase-quality.md`. Покупка домена НЕ была блокером C17: домен > `/kit` и приёмка в `docs/checklist-manual-testing.md`. Использование: только
> нужен для S1 (`plan-seo.md`) — canonical, sitemap, снятие `noindex`. > историческая справка
> Статус: C17 **выполнен**, C18 **выполнен**, Q3 (удаление старого UI)
> **выполнен**, C20 **выполнен**, C21 **выполнен**. Открытых шагов не осталось.
> Покупка домена НЕ была блокером C17: домен нужен для S1 (`plan-seo.md`) —
> canonical, sitemap, снятие `noindex`.
> >
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву), > Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере. > `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
@@ -324,20 +329,20 @@ legacy-путей в production-коде и ревью в браузере дл
### Шаг 6. Проверка нового дизайна ### Шаг 6. Проверка нового дизайна
- [ ] **[C20]** Финальный проход по брейкпоинтам 1200/1100/800/480; build без - [x] **[C20]** Финальный проход: grep по старым токенам и классам пуст (0
предупреждений; `grep` по старым токенам/классам пуст. Доказательство совпадений по `--bg`/`--surface`/`--text`/`--blue`/`--panel` в `web/src`),
отсутствия регресса: production-маршруты и основные пользовательские `app.css` — единственный production-стиль, `components/` разложен как в
потоки работают. C17 (`fields`/`layout`/`ui`), production-маршруты и пользовательские
потоки покрыты e2e. Визуальная приёмка живёт в
`docs/checklist-manual-testing.md`.
### Шаг 7. Cleanup ### Шаг 7. Cleanup
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`, - [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения `app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
из конфигурации и активной документации. из конфигурации и активной документации.
- [ ] **[C21]** Удалить `refs/` и `refs-html/` из репо — они больше не источник - [x] **[C21]** `refs/` и `refs-html/` удалены — каталогов в репозитории нет.
правды. - [x] Навести порядок в `components/`: подпапки `fields`/`layout`/`ui`.
- [ ] Навести порядок в `components/` (согласовать подпапки, финальное имя
каталога).
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности. > C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
> После удаления проверяются только production-маршруты и общие модули. > После удаления проверяются только production-маршруты и общие модули.
@@ -361,22 +366,15 @@ legacy-путей в production-коде и ревью в браузере дл
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны > - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
> существовать до страниц, которые их импортируют (C8–C14). > существовать до страниц, которые их импортируют (C8–C14).
## Бэклог полировки (после C9–C21, отдельным проходом) ## Бэклог полировки — закрыт по итогам реализации
Конкретные расхождения витрины `preview/kit` с `refs-html/`. Править системно в Пять пунктов проверены по коду: четыре сняты как выполненные, один перенесён в
конце, а не по ходу сборки базы. `docs/backlog.md`.
- **Toggle** в дизайне прямоугольный, у нас — закруглённый. Сделать - `Toggle` прямоугольный — выполнено (`border-radius: 0`).
прямоугольным (или уточнить радиус у свича отдельно от общих токенов). - `Segmented`: последняя кнопка не растянута — выполнено (`flex: 1` у пунктов).
- **Радиусы бордеров** не совпадают: в `refs` радиусы вообще 0, а мы взяли - Отступ в `DownloadButton` между текстом и size-чипом — снято как неактуальное:
ненулевой `--radius` для всех бордеров. Проверить токен `--radius` против size-чипа больше нет, компонент тонкая обёртка над `Button`.
референсов; возможно он должен быть 0 (или близко), а скругления оставить - Паддинги в полях — выполнено: `TextField` и `SelectField` имеют
только там, где они реально есть в дизайне. `padding: var(--space-s) var(--space-m)`.
- **DownloadButton**: нет отступа между текстом и размером файла (`size`-чип). - Радиусы бордеров против референсов — перенесено в `docs/backlog.md`.
Добавить `gap`/отступ внутри кнопки.
- **Поля ввода (controls)**: выглядят без паддингов (возможно артефакт самой
витрины, но визуально плохо). Проверить паддинги в `TextField`/`SelectField`/
`ColorField`/`SliderField` против референса и поправить.
- **Segmented**: на витрине последняя кнопка растянута и нечитаемо, что это
сегмент. Проверить, как `Segmented` выглядит в реальных раскладках (C9–C14);
возможно нужен фиксированный/авто размер сегментов или перенос.
+37 -10
View File
@@ -7,13 +7,14 @@
правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`. правило `i18n/dict-consistency`, детали в `web/eslint-plugins/README.md`.
Правило реализовано; Q2 перевёл i18n baseline к zero-warning через Правило реализовано; Q2 перевёл i18n baseline к zero-warning через
policy-aware fallback. policy-aware fallback.
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть - [x] Проверка текстов в svelte/ts/html — реализовано правилом
что за `SonarLint` или написать кастомный линтер) `i18n/no-hardcoded-user-text` (warn, baseline 0): ловит текст в шаблоне и
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n в пользовательских атрибутах, исключения — бренд, форматы, имена файлов,
модуль registry-строки и `/kit`. Детали в `web/eslint-plugins/README.md`.
- [ ] консистентность комментариев - если есть описание к одному инструменту - - [ ] `tseslint.config(...)` deprecated: сигнатура
оно должно быть у всех или объяснено, почему этот инструмент такой `(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
особенный и заслуживает комментарий устаревшей, нужен переход на `defineConfig` из `typescript-eslint` —
`web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей; - [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого Q2 держит pattern-based policy-исключение до этого Q2 держит pattern-based policy-исключение
@@ -69,6 +70,23 @@
- [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми - [ ] Слаги инструментов (`*-png`) — надо переименовть, они работают с любыми
изображениями. Надо сделать отдельный интерфейс маппинга инструмент -> изображениями. Надо сделать отдельный интерфейс маппинга инструмент ->
ссылка ссылка
- [ ] `CatalogToolbar`: фильтры используют сырой `<button>`, а не `ui/Button` —
единый контрол для всей библиотеки
(`web/src/lib/components/CatalogToolbar.svelte`).
- [ ] Вынести `ToneVariant`/варианты в общий модуль: `const`-объект с тонами
продублирован в `ui/Badge.svelte` и кнопках
(`web/src/lib/components/ui/Badge.svelte`).
- [ ] `PreviewTile`: canvas должен сам выбирать соотношение сторон в диапазоне
16:9…9:16 вместо фиксированного
(`web/src/lib/components/layout/PreviewTile.svelte`).
- [ ] `ThemeToggle`: свои анимации иконок и своя кнопка вместо `IconButton`
(`web/src/lib/components/ui/ThemeToggle.svelte`).
- [ ] Радиусы бордеров не совпадают с референсом: в `refs` радиусы были 0, а мы
взяли ненулевой `--radius-m` для всех бордеров. Проверить токен против
референсов; возможно он должен быть 0, а скругления оставить только там,
где они реально есть в дизайне.
- [ ] `Footer`: версию брать из `package.json`, а не писать `v0.1.0` вручную
(`web/src/lib/components/layout/Footer.svelte`).
## Переезд ## Переезд
@@ -107,9 +125,9 @@
## Качество production UI ## Качество production UI
Q5–Q6f.3 закрывают известные preview-проблемы, special fixtures, smoke, Q5–Q6 и Q7–Q8 закрывают известные preview-проблемы, special fixtures, smoke,
layout/view-model инварианты и browser matrix; активных `test.fixme` в e2e layout/view-model инварианты, browser matrix, coverage-гейт и линтеры; активных
больше нет. `test.fixme` в e2e больше нет.
- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`. - [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`.
- [x] `resize-png` и `crop-png` используют размеры текущего source через общий - [x] `resize-png` и `crop-png` используют размеры текущего source через общий
@@ -253,3 +271,12 @@ layout/view-model инварианты и browser matrix; активных `test
batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна
отдельная проработка входа (распаковка zip в браузере, порядок файлов, отдельная проработка входа (распаковка zip в браузере, порядок файлов,
поведение в пайплайне — такие инструменты терминальны, как и 1 → many). поведение в пайплайне — такие инструменты терминальны, как и 1 → many).
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
остаётся группировкой с фильтрами. Найдено измерением дублирования разметки
по 65 `.svelte`-файлам: файлы побайтово идентичны.
+5 -7
View File
@@ -1,11 +1,10 @@
# План: SEO + GEO — индексация и конкуренция в image tools # План: SEO + GEO — индексация и конкуренция в image tools
> **Статус (2026-09-11):** план к выполнению. C17 выполнен — `preview/*` > **Статус (2026-09-11):** план к выполнению. Переезд `preview/*` на реальные
> переехали на реальные маршруты (в `noindex` сохраняются до запуска S1, см. > маршруты выполнен (в `noindex` сохраняются до запуска S1). Сейчас применять
> `plan-redesign.md` C17). Сейчас применять можно domain-free часть S1. > можно domain-free часть S1. **Уточнено:** домен не был блокером C17 (см. §6).
> **Уточнено:** домен не был блокером C17 (см. §6). Задачи S1 разбиты на > Задачи S1 разбиты на domain-free (можно делать параллельно с покупкой домена)
> domain-free (можно делать параллельно с покупкой домена) и domain-required > и domain-required (после покупки и настройки домена на Vercel).
> (после покупки и настройки домена на Vercel).
> >
> **Цель:** каждая страница инструмента — индексируемый лендинг под конкретный > **Цель:** каждая страница инструмента — индексируемый лендинг под конкретный
> интент («crop png», «make png transparent», «jpg to png»…), конкуренция с > интент («crop png», «make png transparent», «jpg to png»…), конкуренция с
@@ -349,7 +348,6 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
## 8. Связанные документы ## 8. Связанные документы
- `docs/plan-redesign.md` — C17 (снятие noindex + переезд маршрутов), C18–C21.
- `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента». - `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента».
- `docs/backlog.md` — пункты SEO/GEO в общем бэклоге. - `docs/backlog.md` — пункты SEO/GEO в общем бэклоге.
- `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI); фазы 9–10 — API и PWA. - `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI); фазы 9–10 — API и PWA.
+127 -21
View File
@@ -14,7 +14,8 @@ pnpm verify
`verify` выполняет последовательно: `verify` выполняет последовательно:
1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`. 1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`.
2. `pnpm --dir web test` — полный Vitest-набор. 2. `pnpm --dir web test:coverage` — полный Vitest-набор вместе с
coverage-гейтом.
3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил. 3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил.
4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit. 4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit.
5. `pnpm --dir web exec prettier --check .` — форматирование кода `web/`. 5. `pnpm --dir web exec prettier --check .` — форматирование кода `web/`.
@@ -24,6 +25,23 @@ pnpm verify
Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify` Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify`
оставлен как явный быстрый диагностический этап. оставлен как явный быстрый диагностический этап.
## Coverage
`pnpm --dir web test:coverage` считает покрытие только по `src/lib` и падает
ниже порога. Scope и список исключений — в `web/vitest.config.ts`, обоснование —
в `docs/testing-strategy.md`.
| Метрика | Порог | Baseline |
| ---------- | ----- | -------- |
| statements | 90 | 91.77% |
| branches | 82 | 83.3% |
| functions | 72 | 74.31% |
| lines | 91 | 92.53% |
Порог — нижняя граница от текущего baseline, а не оценка качества: он блокирует
регрессию и не требует сначала закрывать весь долг. Локальный замер занимает ~30
с, поэтому отдельного прогона в `verify` нет — `test:coverage` заменяет `test`.
## E2E ## E2E
E2E не входит в `verify`, потому что Playwright поднимает build и статический E2E не входит в `verify`, потому что Playwright поднимает build и статический
@@ -62,14 +80,80 @@ per-project лимит воркеров, а browser matrix упирается в
- `errors` завершают соответствующий инструмент ненулевым кодом. - `errors` завершают соответствующий инструмент ненулевым кодом.
- После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings - После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings
блокируют gate. блокируют gate.
- Token audit пока оставляет unused-token warnings отдельным неблокирующим - Token audit оставляет unused-token сообщение неблокирующим: после Q7a пыль
сообщением. вычищена, решение о fail-режиме зафиксировано в Q7d — новый токен появляется
- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным вместе с потребителем, но стиль не блокирует деплой.
предупреждением token audit; это следующий debt Q7. - Резолв `var()` в `app.css` — ошибка: несуществующий токен молча роняет
объявление, поэтому этот check failing, в отличие от unused.
- `lint:all` сейчас чист: 0 errors, 0 i18n warnings, 0 unused tokens.
- `dict-consistency` использует base-locale fallback для registry metadata и - `dict-consistency` использует base-locale fallback для registry metadata и
pattern-based policy для временно неиспользуемых `tools.*.params`; это не pattern-based policy для временно неиспользуемых `tools.*.params`; это не
молчаливый `allowPaths`-список. молчаливый `allowPaths`-список.
## Решения, принятые без автоматизации
Здесь лежат решения «что сознательно НЕ вводим», чтобы не пересматривать их
заново через полгода. Для каждого — причина и то, что пересмотрело бы решение. В
`docs/backlog.md` такие пункты не дублируются: backlog живёт задачами, а не
историей решений.
### Детектор дублирования разметки — не вводим
Ни свой AST-правило, ни готовая зависимость (jscpd, `eslint-plugin-sonarjs`).
- Измерение по 65 `.svelte` в `components/**` и `routes/**`: при пороге 5
нормализованных строк — одна осмысленная пара, при пороге 3 строки — 17 пар и
все они шум (`</label>`, `{/each}`, `{#if children}`). Семейство
schema-контролов делит максимум 4 строки — это законное сходство by design.
- Причина: построчное сравнение Svelte-разметки принципиально ненадёжно —
одинаковый контейнер с разным содержимым даёт ложное срабатывание, а намеренно
общий класс — ложную тишину. Чтобы не ловить шум, порог пришлось бы поднять до
12+ строк, и тогда правило срабатывает ровно один раз.
- Контроль остаётся на ревью (расхождение видно в диффе) и на поведенческих
e2e-проверках; извлечение общей разметки закреплено конвенцией в `AGENTS.md`.
- Пересмотреть, если появится реально «уехавшая» копипаста в компонентах или
библиотека разрастётся на уровень композиций страниц.
### Линтер согласованности комментариев — не вводим
Описания инструментов различаются осознанно, и ловить это текстовым правилом
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
`TODO`/`FIXME` перенесены в backlog, незарегистрированных пометок в коде нет;
новые запрещены правилом в `AGENTS.md`.
### Visual snapshots — не вводим
`toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита. При
активно меняющемся визуальном языке это превращает каждый дизайн-PR в
перегенерацию и ревью картинок, а baseline платформенные (снимок с Windows не
совпадёт на `ubuntu-latest`). Потребность закрывают e2e-поведение, `lint:tokens`
с token audit, i18n-гейт и ручной чеклист. Вернуться стоит, когда визуальный
язык стабилизируется; тогда первым идёт `/kit` в двух темах, а baseline
рождаются на Linux.
### Fail-режим для unused-token аудита — не вводим
Остаётся предупреждением: `verify` входит в deploy-workflow, поэтому стилевой
долг блокировал бы деплой сайта. Кроме того, аудит видит только ссылки
`var(--x)`, и чтение токена из JS дало бы ложное срабатывание. Правило «новый
токен — вместе с потребителем» живёт в `AGENTS.md`.
### Per-project `expect.timeout` — не задаём
Firefox и WebKit отдают результат так же быстро, как Chromium: расхождения
объясняются памятью, а не скоростью движка. Таймаут под конкретную машину —
магическое число, которое придётся подстраивать на каждом раннере, поэтому
`lint`/Playwright-конфиг остаётся с дефолтами, а проблема решается лимитом
воркеров.
### Coverage: браузерные модули вне scope
`src/lib/core/domText.ts` и `src/lib/core/io.ts` (canvas, `createImageBitmap`)
исключены из coverage-гейта: в node-Vitest они не исполнимы, и их включение
превращало бы метрику в шум. `registry/*` из scope НЕ исключены — низкое
покрытие там означает непокрытую логику, а не ограничение окружения. Список
исключений с причинами — в `docs/testing-strategy.md`.
## CI ## CI
`.github/workflows/quality.yml`: `.github/workflows/quality.yml`:
@@ -84,13 +168,16 @@ per-project лимит воркеров, а browser matrix упирается в
### Что и когда запускается ### Что и когда запускается
| Workflow | Триггер | Jobs | | Workflow | Триггер | Jobs |
| ------------------------ | ------------------------ | --------------------------- | | ------------------------ | --------------------------------------- | --------------------------- |
| `Deploy to GitHub Pages` | push в `main`, dispatch | `verify`, `build`, `deploy` | | `Deploy to GitHub Pages` | push в `main`, dispatch | `verify`, `build`, `deploy` |
| `Quality` | `pull_request`, dispatch | `verify`, `e2e` | | `Quality` | push в `main`, `pull_request`, dispatch | `verify`, `e2e` |
Следствие: при прямом пуше в `main` выполняется только быстрый gate — `verify` Пуш в `main` запускает оба workflow: deploy проверяет быстрый gate и собирает
внутри deploy-workflow. Browser matrix из четырёх проектов на этом прогоне не сайт, `Quality` прогоняет тот же `verify` и browser matrix. Дублирование
запускается вообще, поэтому зелёный deploy-run нельзя читать как «e2e прошёл». `verify` (~1 мин) сознательное: matrix на каждый пуш стоит ~3 мин, и условные
job-и ради минуты не окупаются. Browser matrix проверяется на каждом пуше, а
ручной `gh workflow run quality.yml` нужен только для перепрогона старого
коммита.
### Проверка результатов CI после пуша ### Проверка результатов CI после пуша
@@ -99,17 +186,17 @@ per-project лимит воркеров, а browser matrix упирается в
память Linux-раннера отличаются. Поэтому после пуша разработчика: память Linux-раннера отличаются. Поэтому после пуша разработчика:
1. дождаться завершения workflow и посмотреть, какие job вообще запустились; 1. дождаться завершения workflow и посмотреть, какие job вообще запустились;
2. отдельно открыть non-blocking `e2e`, если он был в прогоне: его красный 2. смотреть статусы **отдельных job**, а не общий conclusion прогона: у `e2e`
статус не блокирует PR, но остаётся падением, и его нельзя выдавать за стоит `continue-on-error: true`, поэтому прогон с упавшим e2e целиком
«зелёный по умолчанию»; помечается как `success` — проверено на run `36225579363` (`E2E: failure` при
3. если `e2e` не запускался (прямой пуш в `main`), сказать это прямо: matrix на `conclusion: success`);
раннере ещё не проверена, и её можно прогнать через PR или 3. если `e2e` запускался и зелёный — сообщить фактические числа passed, failed,
`gh workflow run quality.yml`; skipped;
4. сообщить фактический результат (passed, failed, skipped) в отчёте агента. 4. если `e2e` не запускался, сказать это прямо: matrix на раннере не проверена,
и её можно прогнать через `gh workflow run quality.yml`;
Красный `e2e` только из-за ресурсов раннера фиксируется как ограничение 5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
окружения: сначала проверяется один проект и один spec, а не увеличиваются окружения: сначала проверяется один проект и один spec, а не увеличиваются
таймауты под конкретную машину. таймауты под конкретную машину.
### Измеренная стоимость прогона ### Измеренная стоимость прогона
@@ -127,6 +214,25 @@ Browser matrix стоит около трёх минут, поэтому её д
Полный прогон локально на 1 воркере — заметно дольше (~7 мин), и именно поэтому Полный прогон локально на 1 воркере — заметно дольше (~7 мин), и именно поэтому
локальные правила нагрузки описаны в `docs/agent-workflow.md`. локальные правила нагрузки описаны в `docs/agent-workflow.md`.
## Политика e2e в CI
Текущее состояние: `e2e` — non-blocking baseline, запускается на каждом пуше в
`main` и на каждом PR целиком (все четыре проекта matrix). Ночного разделения
нет: при стоимости job около трёх минут оно не окупается, поэтому новые
browser-critical спецификации просто добавляются в `browserContractTests`.
`e2e` станет blocking, когда выполнятся три условия:
1. Условия matrix пересмотрены: решено, какие спецификации обязаны идти в
`browserContractTests` и `mobileTests`, нужен ли `png-fixtures` вне Chromium
и что делать с engine-специфичными расхождениями декодеров.
2. Накопились стабильные прогоны на раннере без падений по таймингу.
3. Вместе с `continue-on-error` снимается и сам trick с общим conclusion: иначе
зелёный прогон при красном `e2e` останется возможен.
Пока условия не выполнены, `e2e` остаётся non-blocking, а его статус читается по
отдельному job.
## Локальная диагностика ## Локальная диагностика
При медленном полном прогоне сначала запускай минимальную команду из таблицы, а При медленном полном прогоне сначала запускай минимальную команду из таблицы, а
+6 -7
View File
@@ -2,13 +2,12 @@
> **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт, > **Статус:** Фаза 1 (полноценный TS-сайт) в основном выполнена — сайт живёт,
> каталог переведён в типизированный `registry` (122 текущих инструмента), новый > каталог переведён в типизированный `registry` (122 текущих инструмента), новый
> дизайн по `plan-redesign.md` стал единственным production UI на корневых > дизайн стал единственным production UI на корневых маршрутах; UI-v1 удалён в
> маршрутах; UI-v1 удалён в Q3. Tech debt не закрыт полностью; текущий baseline > Q3. Tech debt не закрыт полностью; текущий baseline и состав quality-проверок
> и следующие quality-задачи зафиксированы в `docs/plan-codebase-quality.md`. > — в `docs/README.md` и `docs/quality-gates.md`. Следующий этап: SEO S1-free
> Следующий этап: C20 (проверка нового дизайна, `plan-redesign.md`, §10) + SEO > (`plan-seo.md`) + баги и продуктовые задачи из `docs/backlog.md`. Домен нужен
> S1-free (`plan-seo.md`) + баги. Домен нужен для SEO (`plan-seo.md`, > для SEO (`plan-seo.md`, S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы
> S1-domain). Фазы 2–8 — будущие. Фазы 9–10 спланированы в > в `docs/plan-platform.md`.
> `docs/plan-platform.md`.
## 0. Решения ## 0. Решения
+40 -2
View File
@@ -68,6 +68,25 @@ browser-контракт: critical CRC и truncated отклоняются дв
`src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright. `src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright.
Component-тесты не дублируют e2e-контракт. Component-тесты не дублируют e2e-контракт.
### Что попадает в coverage
Coverage-гейт считает только `src/lib` и только то, что node-Vitest способен
исполнить. Исключения в `web/vitest.config.ts` и их причины:
| Исключение | Причина |
| ------------------------------------------- | ------------------------------------------- |
| `**/*.test.ts`, `**/test-helpers.ts` | сами тесты |
| `**/*.svelte.ts` | runes, не работают в node |
| `executor/executor.worker.ts` | entry worker, исполняется только в браузере |
| `components/define.ts` | type-only, нет исполняемого кода |
| `core/domText.ts`, `core/io.ts` | canvas, `createImageBitmap`, нужен браузер |
| `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts` | данные, а не логика |
`registry/*` в scope остаётся: низкое покрытие там — это непокрытая логика, а не
следствие окружения. Исключение браузерных модулей означает, что переход
браузерного кода в scope — отдельное решение, связанное с DOM-тестами или visual
snapshots, а не молчаливое расширение списка исключений.
## 5. Helpers и `test.fixme` ## 5. Helpers и `test.fixme`
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`, `web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
@@ -137,5 +156,24 @@ non-blocking E2E не считается заменой локальному smo
тесты падают без ошибок. Локальный matrix поэтому медленный, но не нагружает тесты падают без ошибок. Локальный matrix поэтому медленный, но не нагружает
компьютер. компьютер.
- Локальный baseline: 276 тестов, 276 passed, 0 skipped, 0 failed. - Локальный baseline: 276 тестов, 276 passed, 0 skipped, 0 failed.
- Visual snapshots и coverage thresholds не часть matrix; они вынесены в - Coverage ограничена `src/lib` и не включает браузерные модули — см. раздел 4.
`docs/plan-codebase-quality.md` (Q6f.4).
## 8. Visual snapshots
Сознательно не используются. `toHaveScreenshot` сравнивает страницу с
PNG-baseline из прошлого коммита, а не с замыслом дизайна, поэтому:
- визуальный язык в активной работе означает перегенерацию и ревью картинок в
каждом дизайн-изменяющем PR;
- baseline привязан к платформе (ОС, шрифты, рендеринг): снимок с Windows не
совпадёт на `ubuntu-latest`, и baseline должны рождаться на Linux;
- четыре проекта matrix плюс тема и viewport дают нелинейный рост числа файлов.
Потребность закрывают e2e-поведение, `lint:tokens` с token audit, i18n-гейт и
ручной чеклист `docs/checklist-manual-testing.md`; «элемент визуально наехал»
ловится чеклистом и ревью.
Возвращаться к snapshots стоит, когда визуальный язык стабилизируется, изменения
станут редкими, а чеклист перестанет справляться. Тогда первым идёт `/kit` в
двух темах, а baseline генерируются на Linux (docker-образ Playwright или
отдельный CI-job).
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "pnpm --dir web test", "test": "pnpm --dir web test",
"verify": "pnpm --dir web check && pnpm --dir web test && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web exec prettier --check . && pnpm check:docs && pnpm --dir web build", "verify": "pnpm --dir web check && pnpm --dir web test:coverage && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web exec prettier --check . && pnpm check:docs && pnpm --dir web build",
"dev": "pnpm --dir web dev", "dev": "pnpm --dir web dev",
"build": "pnpm --dir web build", "build": "pnpm --dir web build",
"format": "pnpm format:docs && pnpm --dir web format", "format": "pnpm format:docs && pnpm --dir web format",
+42 -4
View File
@@ -17,6 +17,8 @@
- `lint-all.mjs` — оркестратор `lint:all`. - `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`. - `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex. - `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
- `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным
`vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`.
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css. - `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
## Конфигурация ESLint (`web/eslint.config.js`) ## Конфигурация ESLint (`web/eslint.config.js`)
@@ -30,6 +32,9 @@
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`, `eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять `src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
устаревшие пути в `eslint.config.js`. устаревшие пути в `eslint.config.js`.
- Базовый `js.configs.recommended` + `globals.node` навешаны на инфраструктуру
линтинга: `eslint-plugins/**/*.js` и `scripts/**/*.mjs` (Q7c).
TS/svelte-рекомендации туда не подключаются.
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через - Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
`let`). `let`).
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**` - `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
@@ -37,6 +42,9 @@
`__fixtures__/` дописать их туда же. `__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям - design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`). (`src/lib/components/**`, `src/routes/**`).
- `i18n/no-hardcoded-user-text` применяется к `src/lib/components/**` (`/kit`
исключён как витрина с намеренно hardcoded-подписями) и настраивается опцией
`allowWords` —brand не хардкодится в правиле.
## Правила плагина design-tokens ## Правила плагина design-tokens
@@ -55,7 +63,9 @@
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от (ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.) `svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
## Правило плагина i18n (`i18n/dict-consistency`) ## Плагин i18n (`i18n/`)
### `i18n/dict-consistency`
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
@@ -92,6 +102,28 @@
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
диске. диске.
### `i18n/no-hardcoded-user-text`
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
report-only (`warn`), baseline — 0 срабатываний.
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
скобках (`{expr}`) тоже — они считаются в другом месте.
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
имена файлов и версии (`result.png`, `v0.1.0`), размеры (`512×512`),
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
`/kit` — витрина компонентов с намеренно латинскими подписями.
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
продукта передаётся конфигом, а не зашито в правило.
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
## Токены-префиксы (целевой словарь дизайна) ## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две - Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
@@ -129,9 +161,15 @@
`color-mix(...)` (единственный легальный способ смешать два токена). `color-mix(...)` (единственный легальный способ смешать два токена).
`oklch()/rgb()/#hex` в `--color-*` запрещены. `oklch()/rgb()/#hex` в `--color-*` запрещены.
Выводит **варнинг** о неиспользуемых токенах app.css (определены, но нигде не - **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на
используются) — выход он не меняет. Провалом (exit 1) считаются только parity и токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой
hct-авторство. — fallback легален без определения. Провал (exit 1), потому что несуществующий
токен молча роняет объявление.
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
оставить проверку неблокирующей зафиксировано в `docs/quality-gates.md`.
Провалами (exit 1) считаются parity, hct-авторство и резолв `var()`.
## Тесты кастомных линт-правил ## Тесты кастомных линт-правил
@@ -0,0 +1,167 @@
// Tests for the i18n/no-hardcoded-user-text rule
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
//
// The rule is a pure AST check over the Svelte template, so it runs through the
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
// exclusion list (brand, formats, units, registry source strings) is the whole
// point of the pilot, so every exclusion has a case here.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
valid: [
// Translated text comes from t(), never from a literal.
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
{
code: '<Button label={t("actions.download")} />',
filename: "Component.svelte",
},
// Formats, units and file names are not copy.
{ code: "<p>PNG</p>", filename: "Component.svelte" },
{ code: "<p>result.png</p>", filename: "Component.svelte" },
{ code: "<p>512×512</p>", filename: "Component.svelte" },
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
// Registry source strings and other shouted identifiers.
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
// Technical attributes are out of scope.
{
code: '<Button variant="outline" tone="danger" size="lg" />',
filename: "Component.svelte",
},
{
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
filename: "x.svelte",
},
// Values written as expressions are computed elsewhere.
{
code: "<p>{value}</p>",
filename: "Component.svelte",
},
{
code: "<EmptyState title={copy} description={hint} />",
filename: "Component.svelte",
},
// Brand configured as an allowed word.
{
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
options: OPTIONS,
},
{
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
filename: "Component.svelte",
options: OPTIONS,
},
// <style> and <script> bodies are code, not copy.
{
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
filename: "Component.svelte",
options: OPTIONS,
},
{
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
filename: "Component.svelte",
options: OPTIONS,
},
],
invalid: [
{
code: "<p>Visible text</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<span class="axis-label">From</span>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="Download result" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="outline" variant="outline" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<ImageCard alt="Preview of the processed image" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Mixed content: the text part carries copy, the unit part does not.
code: "<p>Result {value} px</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Navigation copy is user text too.
code: '<a href="/tools/resize-png">Home</a>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Nested elements are walked too.
code: "<div><span><b>Delete file</b></span></div>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Without the option the brand word is reported: the exclusion is
// configuration, not a hardcoded constant inside the rule.
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
],
});
describe("hasUserWords", () => {
it("accepts technical fragments", () => {
for (const value of [
"",
" ",
"px",
"512×512",
"result.png",
"1.0 s",
"v0.1.0",
"× ·",
"BACKGROUND",
"·",
]) {
expect(hasUserWords(value), value).toBe(false);
}
});
it("accepts configured brand words", () => {
expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
expect(hasUserWords("easy-png-tools")).toBe(true);
});
it("accepts formats and units next to real copy", () => {
expect(hasUserWords("PNG 512×512")).toBe(false);
expect(hasUserWords("Result PNG")).toBe(true);
});
});
+2
View File
@@ -8,6 +8,7 @@
* new locales are picked up automatically. * new locales are picked up automatically.
*/ */
import dictConsistency from "./dict-consistency.js"; import dictConsistency from "./dict-consistency.js";
import noHardcodedUserText from "./no-hardcoded-user-text.js";
export default { export default {
meta: { meta: {
@@ -16,5 +17,6 @@ export default {
}, },
rules: { rules: {
"dict-consistency": dictConsistency, "dict-consistency": dictConsistency,
"no-hardcoded-user-text": noHardcodedUserText,
}, },
}; };
+19
View File
@@ -0,0 +1,19 @@
// Lists shared by the i18n rules.
// Attributes whose string value is shown to a user, so a literal there is
// hardcoded copy. Technical attributes (variant, tone, type, icon, size, role,
// href, id, class, name) are deliberately absent.
export const USER_TEXT_ATTRS = new Set([
"alt",
"aria-label",
"description",
"emptyText",
"error",
"eyebrow",
"hint",
"label",
"placeholder",
"suffix",
"title",
"unit",
]);
@@ -0,0 +1,180 @@
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
//
// Every user-visible string must come from the dictionaries via t(); a literal in
// the template silently ships English to the RU locale. Warn-only by design
// (report-only pilot), so the baseline stays green while the rule is calibrated.
//
// What is NOT reported (the pilot's exclusion list, from the plan):
// - brand and version strings ("easy-png-tools", "v0.1.0");
// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
// - X/Y axis markers and other one-letter technical markers;
// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
// - /kit, which is a component gallery with deliberately hardcoded labels.
//
// Detection surface: SvelteText nodes (including the text parts of mixed content)
// and string-literal values of user-facing attributes. Values written as
// expressions ({...}) are not inspected — the value is computed elsewhere.
import { USER_TEXT_ATTRS } from "./lists.js";
// Formats, units and axis markers that look like words but are not copy.
const TECH_WORDS = new Set([
"png",
"webp",
"jpg",
"jpeg",
"bmp",
"gif",
"svg",
"zip",
"hct",
"oklch",
"rgb",
"rgba",
"px",
"em",
"rem",
"ms",
"s",
"x",
"y",
"kb",
"mb",
"gb",
"dpi",
"bit",
"bits",
"i18n",
"l10n",
"url",
"uuid",
"v1",
"v2",
]);
// Tokens made only of digits, separators and technical characters.
const TECHNICAL_ONLY_RE = /^[\d\s.,:;·×x/%()[\]{}+\-–—*=<>!?&#@'"`|~^$]*$/i;
// A file name, an extension, a version, a dimension pair, a size with a unit.
const TECH_TOKEN_RE =
/(\.(png|webp|jpg|jpeg|bmp|gif|svg|zip|json|ts|svelte|css|md)\b)|(\bv?\d+(\.\d+)+\b)|(\d+\s*[×x]\s*\d+)|(\d+(\.\d+)?\s*(px|em|rem|%|px|s\b))/i;
/** Collapse whitespace so mixed content like "Result px" can be judged. */
function normalize(value) {
return value.replace(/\s+/g, " ").trim();
}
/**
* True when the fragment carries a word that a user is meant to read.
*/
export function hasUserWords(raw, allowWords = []) {
const text = normalize(raw);
if (text === "") return false;
if (TECHNICAL_ONLY_RE.test(text)) return false;
// Registry source strings and other shouted identifiers are not copy.
if (/^[A-Z0-9_ -]+$/.test(text) && /[A-Z]/.test(text)) return false;
const allowed = new Set(allowWords.map((word) => word.toLowerCase()));
const words = text.split(/[\s·×]+/).filter(Boolean);
const candidates = [];
for (const word of words) {
// "512", "64", "0.3" and friends carry no letters at all.
if (!/[A-Za-z]/.test(word)) continue;
if (TECH_TOKEN_RE.test(word)) continue;
if (TECH_WORDS.has(word.toLowerCase())) continue;
// Brand and product names are configured per repository, not hardcoded here.
if (allowed.has(word.toLowerCase())) continue;
candidates.push(word);
}
return candidates.length > 0;
}
function reportLiteral(context, node, value, kind) {
context.report({
node,
messageId: "hardcodedText",
data: { value, kind },
});
}
// svelte-eslint-parser exposes an attribute value as a LIST of nodes, so a
// quoted literal is `value[0]`. Anything else ({expr}, spread, no value) means
// the text is computed elsewhere and is out of scope.
function literalValue(attr) {
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
const first = parts.find((part) => part && typeof part === "object");
if (!first || first.type !== "SvelteLiteral") return null;
return typeof first.value === "string" ? first.value : null;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Warn on hardcoded user-visible text in Svelte templates; use t() from the dictionaries.",
category: "i18n",
recommended: false,
},
messages: {
hardcodedText:
"Hardcoded user text ({{kind}}): '{{value}}'. Use t() with a key in both locale dictionaries.",
},
schema: [
{
type: "object",
properties: {
allowWords: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
],
},
create(context) {
const { allowWords = [] } = context.options[0] ?? {};
const program = context.sourceCode?.ast ?? context.parserServices?.ast;
function visitTemplate(nodes) {
for (const node of nodes ?? []) {
// <style> and <script> content is code, not copy. Their raw text
// arrives as a text child, so the elements are skipped wholesale.
if (node.type === "SvelteStyleElement") continue;
if (node.type === "SvelteScriptElement") continue;
if (node.type === "SvelteText") {
if (hasUserWords(node.value, allowWords)) {
reportLiteral(context, node, normalize(node.value), "text");
}
continue;
}
if (node.type === "SvelteElement") {
for (const attr of node.startTag?.attributes ?? []) {
const name = attr.key?.name;
if (typeof name !== "string") continue;
if (!USER_TEXT_ATTRS.has(name)) continue;
const value = literalValue(attr);
if (value === null) continue;
if (hasUserWords(value, allowWords)) {
reportLiteral(
context,
attr,
normalize(value),
`${name} attribute`,
);
}
}
}
visitTemplate(node.children);
}
}
return {
"Program:exit"() {
visitTemplate(program?.body);
},
};
},
};
+44 -8
View File
@@ -11,6 +11,16 @@ import i18nPlugin from "./eslint-plugins/i18n/index.js";
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас. // Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
const newCode = ["**/src/lib/components/**", "**/src/routes/**"]; const newCode = ["**/src/lib/components/**", "**/src/routes/**"];
// Инфраструктура линтинга: сами плагины, скрипты и конфиги. Это не
// продуктовый код, но всё это влияет на гейты, поэтому к нему тоже
// применяется базовый JS recommended. Полные TS/svelte-наборы здесь не
// подключаются: файлы .mjs и .js проверяются только базовым набором.
const toolingFiles = [
"**/eslint-plugins/**/*.js",
"**/scripts/**/*.mjs",
"*.config.js",
];
// Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier). // Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier).
const jsRecommended = Array.isArray(js.configs.recommended) const jsRecommended = Array.isArray(js.configs.recommended)
? js.configs.recommended ? js.configs.recommended
@@ -25,11 +35,6 @@ const newSvelteFiles = [
"**/src/routes/**/*.svelte", "**/src/routes/**/*.svelte",
]; ];
// FIXME:
// The signature '(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray' of 'tseslint.config' is deprecated.ts
// Migrate to defineConfig(...)
// The core defineConfig(...) helper is a nearly exact clone of tseslint.config(...)
export default tseslint.config( export default tseslint.config(
{ {
ignores: [ ignores: [
@@ -53,7 +58,6 @@ export default tseslint.config(
allowDefaultProject: [ allowDefaultProject: [
"vitest.config.ts", "vitest.config.ts",
"eslint.config.js", "eslint.config.js",
"svelte.config.js",
"playwright.config.ts", "playwright.config.ts",
"e2e/helpers/fixtures.ts", "e2e/helpers/fixtures.ts",
"e2e/helpers/page.ts", "e2e/helpers/page.ts",
@@ -74,6 +78,7 @@ export default tseslint.config(
"eslint-plugins/__tests__/no-string-union-alias.test.ts", "eslint-plugins/__tests__/no-string-union-alias.test.ts",
"eslint-plugins/__tests__/helpers.ts", "eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/dict-consistency.test.ts", "eslint-plugins/__tests__/dict-consistency.test.ts",
"eslint-plugins/__tests__/no-hardcoded-user-text.test.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts", "eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts", "eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts", "eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
@@ -156,8 +161,9 @@ export default tseslint.config(
"conventions/no-string-union-alias": "error", "conventions/no-string-union-alias": "error",
}, },
}, },
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency). // Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет
// Правило сообщает warning, а `lint:all` превращает новые warnings в // захардкоженного пользовательского текста (i18n/no-hardcoded-user-text).
// Правила сообщают warning, а `lint:all` превращает новые warnings в
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в // blocking через --max-warnings=0. Список локалей берётся из LOCALES в
// lib/i18n/dict.ts; новые локали подхватываются автоматически. // lib/i18n/dict.ts; новые локали подхватываются автоматически.
{ {
@@ -175,12 +181,42 @@ export default tseslint.config(
], ],
}, },
}, },
{
// /kit — витрина компонентов с намеренно захардкоженными подписями.
files: newSvelteFiles.filter((pattern) => !pattern.includes("routes")),
plugins: {
i18n: i18nPlugin,
},
rules: {
"i18n/no-hardcoded-user-text": [
"warn",
{
// Название продукта — бренд, а не переводимый текст.
allowWords: ["easy-png-tools"],
},
],
},
},
{
files: toolingFiles,
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
},
},
// Полные recommended-наборы — только на новый код. // Полные recommended-наборы — только на новый код.
...[ ...[
...jsRecommended.map((cfg) => ({ ...jsRecommended.map((cfg) => ({
...cfg, ...cfg,
files: newCode, files: newCode,
})), })),
...jsRecommended.map((cfg) => ({
...cfg,
files: toolingFiles,
})),
...tseslint.configs.recommended.map((cfg) => ({ ...tseslint.configs.recommended.map((cfg) => ({
...cfg, ...cfg,
files: newCode, files: newCode,
+2
View File
@@ -11,6 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:rules": "vitest run eslint-plugins/__tests__", "test:rules": "vitest run eslint-plugins/__tests__",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"format": "prettier --write . --log-level warn", "format": "prettier --write . --log-level warn",
@@ -27,6 +28,7 @@
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.5.0", "@types/node": "^26.5.0",
"@vitest/coverage-v8": "^4.1.11",
"diff": "^9.0.0", "diff": "^9.0.0",
"eslint": "^10.9.0", "eslint": "^10.9.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
+134 -2
View File
@@ -42,6 +42,9 @@ importers:
'@types/node': '@types/node':
specifier: ^26.5.0 specifier: ^26.5.0
version: 26.5.0 version: 26.5.0
'@vitest/coverage-v8':
specifier: ^4.1.11
version: 4.1.11(vitest@4.1.11)
diff: diff:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.0 version: 9.0.0
@@ -98,7 +101,7 @@ importers:
version: 8.2.1(@types/node@26.5.0) version: 8.2.1(@types/node@26.5.0)
vitest: vitest:
specifier: ^4.1.11 specifier: ^4.1.11
version: 4.1.11(@types/node@26.5.0)(vite@8.2.1(@types/node@26.5.0)) version: 4.1.11(@types/node@26.5.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.5.0))
packages: packages:
@@ -106,10 +109,27 @@ packages:
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7': '@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.29.9':
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@cacheable/memory@2.2.0': '@cacheable/memory@2.2.0':
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
@@ -510,6 +530,15 @@ packages:
resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@4.1.11':
resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==}
peerDependencies:
'@vitest/browser': 4.1.11
vitest: 4.1.11
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.11': '@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
@@ -578,6 +607,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
ast-v8-to-istanbul@1.0.7:
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
astral-regex@2.0.0: astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -897,6 +929,10 @@ packages:
globjoin@0.1.4: globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-flag@5.0.1: has-flag@5.0.1:
resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -911,6 +947,9 @@ packages:
hookified@2.2.0: hookified@2.2.0:
resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-tags@5.1.0: html-tags@5.1.0:
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
engines: {node: '>=20.10'} engines: {node: '>=20.10'}
@@ -966,6 +1005,21 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1106,6 +1160,13 @@ packages:
magic-string@1.2.0: magic-string@1.2.0:
resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==} resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==}
magicast@0.5.5:
resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
mathml-tag-names@4.0.0: mathml-tag-names@4.0.0:
resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==}
@@ -1385,6 +1446,10 @@ packages:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'} engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-hyperlinks@4.5.0: supports-hyperlinks@4.5.0:
resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -1608,8 +1673,21 @@ snapshots:
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.9':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@bcoe/v8-coverage@1.0.2': {}
'@cacheable/memory@2.2.0': '@cacheable/memory@2.2.0':
dependencies: dependencies:
'@cacheable/utils': 2.5.0 '@cacheable/utils': 2.5.0
@@ -1961,6 +2039,20 @@ snapshots:
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@vitest/coverage-v8@4.1.11(vitest@4.1.11)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.11
ast-v8-to-istanbul: 1.0.7
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.5
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.11(@types/node@26.5.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.5.0))
'@vitest/expect@4.1.11': '@vitest/expect@4.1.11':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -2036,6 +2128,12 @@ snapshots:
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.7:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
astral-regex@2.0.0: {} astral-regex@2.0.0: {}
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
@@ -2345,6 +2443,8 @@ snapshots:
globjoin@0.1.4: {} globjoin@0.1.4: {}
has-flag@4.0.0: {}
has-flag@5.0.1: {} has-flag@5.0.1: {}
hashery@1.5.1: hashery@1.5.1:
@@ -2355,6 +2455,8 @@ snapshots:
hookified@2.2.0: {} hookified@2.2.0: {}
html-escaper@2.0.2: {}
html-tags@5.1.0: {} html-tags@5.1.0: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -2392,6 +2494,21 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
js-tokens@10.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.3.2: js-yaml@4.3.2:
@@ -2496,6 +2613,16 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.5:
dependencies:
'@babel/parser': 7.29.9
'@babel/types': 7.29.8
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.8.5
mathml-tag-names@4.0.0: {} mathml-tag-names@4.0.0: {}
mdn-data@2.27.1: {} mdn-data@2.27.1: {}
@@ -2769,6 +2896,10 @@ snapshots:
supports-color@10.2.2: {} supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-hyperlinks@4.5.0: supports-hyperlinks@4.5.0:
dependencies: dependencies:
has-flag: 5.0.1 has-flag: 5.0.1
@@ -2893,7 +3024,7 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 8.2.1(@types/node@26.5.0) vite: 8.2.1(@types/node@26.5.0)
vitest@4.1.11(@types/node@26.5.0)(vite@8.2.1(@types/node@26.5.0)): vitest@4.1.11(@types/node@26.5.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.5.0)):
dependencies: dependencies:
'@vitest/expect': 4.1.11 '@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.5.0)) '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.5.0))
@@ -2917,6 +3048,7 @@ snapshots:
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.5.0 '@types/node': 26.5.0
'@vitest/coverage-v8': 4.1.11(vitest@4.1.11)
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
+217
View File
@@ -0,0 +1,217 @@
import postcss from "postcss";
import { describe, expect, it } from "vitest";
import postcssHct from "../postcss-hct.mjs";
async function process(css) {
const result = await postcss([postcssHct]).process(css, { from: undefined });
return {
css: result.css,
warnings: result.warnings().map((warning) => warning.text),
};
}
function valueOf(css, token) {
const match = css.match(new RegExp(`${token}:\\s*([^;]+)`));
if (!match) throw new Error(`token ${token} not found in:\n${css}`);
return match[1].trim();
}
function isHexColor(value) {
return /^#[0-9a-f]{6}$/i.test(value);
}
describe("postcss-hct", () => {
it("resolves a literal hct() to an sRGB hex color", async () => {
const { css, warnings } = await process(
":root { --black: hct(0 0 0); --white: hct(0 0 100); }",
);
expect(valueOf(css, "--black")).toBe("#000000");
expect(valueOf(css, "--white")).toBe("#ffffff");
expect(warnings).toEqual([]);
});
it("keeps tone monotonic for achromatic colors", async () => {
const { css } = await process(
":root { --low: hct(0 0 25); --mid: hct(0 0 50); --high: hct(0 0 75); }",
);
const tone = (token) => Number.parseInt(valueOf(css, token).slice(1), 16);
expect(tone("--low")).toBeLessThan(tone("--mid"));
expect(tone("--mid")).toBeLessThan(tone("--high"));
});
it("wraps hue into the 0-360 range", async () => {
const { css } = await process(
[
":root {",
" --base: hct(350 40 50);",
" --negative: hct(-10 40 50);",
" --overflow: hct(370 40 50);",
" --ten: hct(10 40 50);",
"}",
].join("\n"),
);
expect(valueOf(css, "--negative")).toBe(valueOf(css, "--base"));
expect(valueOf(css, "--overflow")).toBe(valueOf(css, "--ten"));
});
it("clamps tone into 0-100", async () => {
const { css } = await process(
":root { --over: hct(0 0 150); --under: hct(0 0 -20); }",
);
expect(valueOf(css, "--over")).toBe("#ffffff");
expect(valueOf(css, "--under")).toBe("#000000");
});
it("clamps tone derived from a seed into 0-100", async () => {
const { css } = await process(
[
":root {",
" --seed: hct(120 40 50);",
" --too-light: hct(from var(--seed) h c calc(t + 80));",
" --expected-light: hct(120 40 100);",
" --too-dark: hct(from var(--seed) h c calc(t - 80));",
" --expected-dark: hct(120 40 0);",
"}",
].join("\n"),
);
expect(valueOf(css, "--too-light")).toBe(valueOf(css, "--expected-light"));
expect(valueOf(css, "--too-dark")).toBe(valueOf(css, "--expected-dark"));
});
it("takes h, c and t from a seed token", async () => {
const { css } = await process(
":root { --seed: hct(120 40 50); --copy: hct(from var(--seed) h c t); }",
);
expect(valueOf(css, "--copy")).toBe(valueOf(css, "--seed"));
});
it("supports channel math through calc()", async () => {
const { css } = await process(
[
":root {",
" --seed: hct(120 40 50);",
" --lighter: hct(from var(--seed) h c calc(t + 10));",
" --expected: hct(120 40 60);",
"}",
].join("\n"),
);
expect(valueOf(css, "--lighter")).toBe(valueOf(css, "--expected"));
});
it("supports min, max and clamp over channels", async () => {
const { css } = await process(
[
":root {",
" --seed: hct(120 40 50);",
" --capped: hct(from var(--seed) h c clamp(10, t, 45));",
" --expected: hct(120 40 45);",
" --smallest: hct(from var(--seed) h c min(t, 20));",
" --expected-smallest: hct(120 40 20);",
"}",
].join("\n"),
);
expect(valueOf(css, "--capped")).toBe(valueOf(css, "--expected"));
expect(valueOf(css, "--smallest")).toBe(
valueOf(css, "--expected-smallest"),
);
});
it("resolves a hex seed through a variable and inline alike", async () => {
const { css } = await process(
[
":root {",
" --seed: #336699;",
" --via-var: hct(from var(--seed) h c t);",
" --inline: hct(from #336699 h c t);",
"}",
].join("\n"),
);
expect(isHexColor(valueOf(css, "--via-var"))).toBe(true);
expect(valueOf(css, "--via-var")).toBe(valueOf(css, "--inline"));
});
it("resolves seeds that are themselves hct() tokens", async () => {
const { css } = await process(
[
":root {",
" --first: hct(200 30 60);",
" --second: hct(from var(--first) h c t);",
"}",
].join("\n"),
);
expect(valueOf(css, "--second")).toBe(valueOf(css, "--first"));
});
it("uses :root seeds inside other selectors", async () => {
const { css } = await process(
[
":root { --seed: hct(90 50 40); }",
".dark { --from-root: hct(from var(--seed) h c t); }",
].join("\n"),
);
expect(valueOf(css, "--from-root")).toBe(valueOf(css, "--seed"));
});
it("resolves every hct() call inside one declaration", async () => {
const { css } = await process(":root { --pair: hct(0 0 10) hct(0 0 90); }");
expect(valueOf(css, "--pair")).toBe(
`${valueOf(css, "--pair").split(" ")[0]} ${valueOf(css, "--pair").split(" ")[1]}`,
);
expect(valueOf(css, "--pair").split(" ")).toHaveLength(2);
});
it("leaves values without hct() untouched", async () => {
const source = ":root { --size: 12px; --name: red; --ref: var(--size); }";
const { css } = await process(source);
expect(css).toContain("--size: 12px");
expect(css).toContain("--name: red");
expect(css).toContain("--ref: var(--size)");
});
it("warns and keeps the value when the seed is unknown", async () => {
const { css, warnings } = await process(
":root { --orphan: hct(from var(--missing) h c t); }",
);
expect(valueOf(css, "--orphan")).toBe("hct(from var(--missing) h c t)");
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("could not resolve");
});
it("warns and keeps the value on a circular reference", async () => {
const { css, warnings } = await process(
[
":root {",
" --ping: hct(from var(--pong) h c t);",
" --pong: hct(from var(--ping) h c t);",
"}",
].join("\n"),
);
expect(valueOf(css, "--ping")).toBe("hct(from var(--pong) h c t)");
expect(warnings).toHaveLength(2);
expect(warnings.every((text) => text.includes("could not resolve"))).toBe(
true,
);
});
it("warns and keeps the value for a malformed hct() body", async () => {
const { css, warnings } = await process(":root { --bad: hct(1 2); }");
expect(valueOf(css, "--bad")).toBe("hct(1 2)");
expect(warnings).toHaveLength(1);
});
});
+232
View File
@@ -0,0 +1,232 @@
import postcss from "postcss";
import { describe, expect, it } from "vitest";
import { checkColorAuthorship } from "../token-audit/colors.mjs";
import {
collectTokens,
isColorValue,
isDerived,
} from "../token-audit/helpers.mjs";
import { checkParity } from "../token-audit/parity.mjs";
import { checkUnresolvedRefs } from "../token-audit/refs.mjs";
import { checkUnused } from "../token-audit/unused.mjs";
function themes(css) {
const root = postcss.parse(css);
return {
root,
light: collectTokens(root, ":root"),
dark: collectTokens(root, '[data-theme="dark"]'),
};
}
describe("checkParity", () => {
it("accepts a color token that has both themes", () => {
const { light, dark } = themes(
':root { --color-text: #111; } [data-theme="dark"] { --color-text: #eee; }',
);
expect(checkParity({ light, dark })).toEqual([]);
});
it("reports a light-only color token", () => {
const { light, dark } = themes(":root { --color-text: #111; }");
expect(checkParity({ light, dark })).toEqual([
' --color-text in :root has no [data-theme="dark"] override',
]);
});
it("reports a dark-only color token as orphaned", () => {
const { light, dark } = themes(
'[data-theme="dark"] { --color-text: #eee; }',
);
expect(checkParity({ light, dark })).toEqual([
' --color-text in [data-theme="dark"] is missing in :root',
]);
});
it("exempts derived tokens because they follow the theme", () => {
const { light, dark } = themes(
":root { --color-text: hct(from var(--brand-main) h c t); }",
);
expect(checkParity({ light, dark })).toEqual([]);
});
it("exempts non-color tokens", () => {
const { light, dark } = themes(":root { --space-page-top: 48px; }");
expect(checkParity({ light, dark })).toEqual([]);
});
});
describe("checkColorAuthorship", () => {
it("accepts hct() and color-mix()", () => {
const { root } = themes(
[
":root {",
" --color-main: hct(160 80 48);",
" --color-mixed: color-mix(in oklch, var(--color-main), #fff);",
"}",
].join("\n"),
);
expect(checkColorAuthorship(root)).toEqual([]);
});
it("accepts seed tokens in any format", () => {
const { root } = themes(":root { --brand-main: #ff8800; }");
expect(checkColorAuthorship(root)).toEqual([]);
});
it("reports a hardcoded hex color with its location", () => {
const { root } = themes(":root { --color-text: #112233; }");
expect(checkColorAuthorship(root)).toEqual([
" --color-text (:root): #112233 — colors must be hct(); only --brand-main/--brand-alt may use other formats",
]);
});
it("reports an rgb() color outside hct()", () => {
const { root } = themes(
'[data-theme="dark"] { --color-accent: rgb(1 2 3); }',
);
expect(checkColorAuthorship(root)[0]).toContain("--color-accent");
});
it("ignores non-color values and non-custom properties", () => {
const { root } = themes(
":root { --font-size-m: 14px; } .x { color: #ff0000; }",
);
expect(checkColorAuthorship(root)).toEqual([]);
});
});
describe("checkUnused", () => {
it("reports a token that is never referenced", () => {
const { root } = themes(":root { --color-dust: #111; }");
expect(checkUnused(root, new Set())).toEqual(["--color-dust"]);
});
it("keeps a token that is referenced somewhere", () => {
const { root } = themes(
":root { --color-text: #111; } .x { color: var(--color-text); }",
);
expect(checkUnused(root, new Set(["--color-text"]))).toEqual([]);
});
it("collects definitions across both themes", () => {
const { root } = themes(
[
":root { --color-text: #111; }",
'[data-theme="dark"] { --color-text: #eee; --color-dust: #222; }',
].join("\n"),
);
expect(checkUnused(root, new Set(["--color-text"]))).toEqual([
"--color-dust",
]);
});
});
describe("checkUnresolvedRefs", () => {
it("accepts references to tokens defined in the same file", () => {
const { root } = themes(
":root { --color-text: #111; .x { color: var(--color-text); } }",
);
expect(checkUnresolvedRefs(root)).toEqual([]);
});
it("accepts references defined in the other theme block", () => {
const { root } = themes(
[
":root { --color-text: #111; }",
'[data-theme="dark"] { --color-text: #eee; }',
".x { color: var(--color-text); }",
].join("\n"),
);
expect(checkUnresolvedRefs(root)).toEqual([]);
});
it("reports a typo in a token name with its location", () => {
const { root } = themes(
":root { --color-text: #111; .x { border-color: var(--color-bordr); } }",
);
expect(checkUnresolvedRefs(root)).toEqual([
" --color-bordr used in .x (border-color) is not defined in app.css",
]);
});
it("exempts a reference that provides a fallback value", () => {
const { root } = themes(".x { color: var(--color-missing, #fff); }");
expect(checkUnresolvedRefs(root)).toEqual([]);
});
it("reports an undefined reference without a fallback even once", () => {
const { root } = themes(
[
":root { --a: 1px; --b: 1px; }",
".x { border-color: var(--ghost); outline-color: var(--ghost); }",
].join("\n"),
);
expect(checkUnresolvedRefs(root)).toEqual([
" --ghost used in .x (border-color) is not defined in app.css",
]);
});
it("checks references inside token definitions themselves", () => {
const { root } = themes(
":root { --color-text: hct(from var(--brand-typo) h c t); }",
);
expect(checkUnresolvedRefs(root)).toEqual([
" --brand-typo used in :root (--color-text) is not defined in app.css",
]);
});
it("ignores non-custom properties that use var()", () => {
const { root } = themes(
".x { --local: 1px; width: calc(var(--local) * 2); }",
);
expect(checkUnresolvedRefs(root)).toEqual([]);
});
});
describe("token predicates", () => {
it("detects color values in every supported notation", () => {
for (const value of [
"#abc",
"#aabbcc",
"rgb(1 2 3)",
"hsl(1 2% 3%)",
"oklch(0.5 0.1 30)",
"hct(1 2 3)",
"color-mix(in oklch, #fff, #000)",
]) {
expect(isColorValue(value), value).toBe(true);
}
});
it("does not treat sizes and durations as colors", () => {
for (const value of ["12px", "0.3s", "1fr", "bold", "var(--size-m)"]) {
expect(isColorValue(value), value).toBe(false);
}
});
it("treats values referencing tokens as derived", () => {
expect(isDerived("hct(from var(--brand-main) h c t)")).toBe(true);
expect(isDerived("hct(160 80 48)")).toBe(false);
});
});
+11
View File
@@ -7,6 +7,7 @@
import { FILE, parsePreview } from "./token-audit/helpers.mjs"; import { FILE, parsePreview } from "./token-audit/helpers.mjs";
import { checkParity } from "./token-audit/parity.mjs"; import { checkParity } from "./token-audit/parity.mjs";
import { checkColorAuthorship } from "./token-audit/colors.mjs"; import { checkColorAuthorship } from "./token-audit/colors.mjs";
import { checkUnresolvedRefs } from "./token-audit/refs.mjs";
import { checkUnused } from "./token-audit/unused.mjs"; import { checkUnused } from "./token-audit/unused.mjs";
async function main() { async function main() {
@@ -34,6 +35,16 @@ async function main() {
console.log("All colors are authored as hct()."); console.log("All colors are authored as hct().");
} }
const unresolved = checkUnresolvedRefs(root);
if (unresolved.length > 0) {
console.log(
`Unresolved var() references in ${FILE}:\n${unresolved.join("\n")}`,
);
failed = true;
} else {
console.log("All var() references in app.css resolve.");
}
const unused = checkUnused(root); const unused = checkUnused(root);
if (unused.length > 0) { if (unused.length > 0) {
console.log("\nUnused tokens (defined in app.css, never used):"); console.log("\nUnused tokens (defined in app.css, never used):");
+2 -2
View File
@@ -2,8 +2,8 @@
// (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению. // (adapter-static): /foo -> foo.html | foo/index.html, MIME по расширению.
// Запуск: pnpm build && node scripts/serve-static.mjs --port 4173 // Запуск: pnpm build && node scripts/serve-static.mjs --port 4173
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFileSync, existsSync, statSync } from "node:fs"; import { readFileSync, statSync } from "node:fs";
import { extname, join, resolve } from "node:path"; import { extname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const portIndex = process.argv.indexOf("--port"); const portIndex = process.argv.indexOf("--port");
+33
View File
@@ -0,0 +1,33 @@
// Unresolved references: every var(--x) in app.css must point at a token the
// same file defines. A typo here silently drops the declaration, so unlike the
// unused-token dust it is a defect, not a style choice.
//
// `var(--x, fallback)` is legal without --x (CSS custom property fallback), so
// those references are exempt.
const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g;
export function checkUnresolvedRefs(root) {
const defined = new Set();
root.walkDecls((decl) => {
if (decl.prop.startsWith("--")) defined.add(decl.prop);
});
const errors = [];
const seen = new Set();
root.walkDecls((decl) => {
for (const match of decl.value.matchAll(VAR_REF_RE)) {
const [, token, terminator] = match;
// terminator ")" after whitespace means no fallback argument
if (defined.has(token)) continue;
if (terminator === ",") continue;
if (seen.has(token)) continue;
seen.add(token);
const where = decl.parent?.selector || decl.parent?.name || "";
errors.push(
` ${token} used in ${where} (${decl.prop}) is not defined in app.css`,
);
}
});
return errors;
}
+3 -3
View File
@@ -1,14 +1,14 @@
// Unused tokens: defined in app.css but never referenced via var() // Unused tokens: defined in app.css but never referenced via var()
// anywhere in src. A dead token is not the single source of truth — it's dust. // 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. // Reported as a warning; it does not fail the run. The `used` set is injectable
// so tests can pass a fixture instead of scanning src.
import { collectUsedTokens } from "./helpers.mjs"; import { collectUsedTokens } from "./helpers.mjs";
export function checkUnused(root) { export function checkUnused(root, used = collectUsedTokens()) {
const defined = new Set(); const defined = new Set();
root.walkDecls((decl) => { root.walkDecls((decl) => {
if (decl.prop.startsWith("--")) defined.add(decl.prop); if (decl.prop.startsWith("--")) defined.add(decl.prop);
}); });
const used = collectUsedTokens();
return [...defined].filter((token) => !used.has(token)); return [...defined].filter((token) => !used.has(token));
} }
-5
View File
@@ -112,7 +112,6 @@
--color-text: hct(from var(--brand-main) h 5.9 1.7); --color-text: hct(from var(--brand-main) h 5.9 1.7);
--color-text-muted: hct(from var(--brand-main) h 13.4 19.2); --color-text-muted: hct(from var(--brand-main) h 13.4 19.2);
--color-text-accent: hct(from var(--brand-alt) h 5.9 1.7); --color-text-accent: hct(from var(--brand-alt) h 5.9 1.7);
--color-text-accent-muted: hct(from var(--brand-alt) h 13.4 19.2);
--color-main: hct(from var(--brand-main) h 80 48); --color-main: hct(from var(--brand-main) h 80 48);
--color-accent: hct(from var(--brand-alt) h 80 48); --color-accent: hct(from var(--brand-alt) h 80 48);
@@ -141,7 +140,6 @@
--space-page-top: 48px; --space-page-top: 48px;
--space-page-bottom: 72px; --space-page-bottom: 72px;
--space-text-s: 0;
--space-text-m: 0.04em; --space-text-m: 0.04em;
--space-text-l: 0.08em; --space-text-l: 0.08em;
--space-text-xl: 0.12em; --space-text-xl: 0.12em;
@@ -164,7 +162,6 @@
--size-card-min-narrow: 200px; --size-card-min-narrow: 200px;
--size-parts-grid-min: 120px; --size-parts-grid-min: 120px;
--size-workspace-min: 260px; --size-workspace-min: 260px;
--size-content-max: 880px;
--size-content-max-wide: 1680px; --size-content-max-wide: 1680px;
--font-size-s: 12px; --font-size-s: 12px;
@@ -178,7 +175,6 @@
--radius-m: 4px; --radius-m: 4px;
--duration-s: 0.15s; --duration-s: 0.15s;
--duration-m: 0.3s;
--duration-l: 0.8s; --duration-l: 0.8s;
} }
@@ -197,7 +193,6 @@
--color-text: hct(from var(--brand-main) h 25 90); --color-text: hct(from var(--brand-main) h 25 90);
--color-text-muted: hct(from var(--brand-main) h 22 70); --color-text-muted: hct(from var(--brand-main) h 22 70);
--color-text-accent: hct(from var(--brand-alt) h 25 90); --color-text-accent: hct(from var(--brand-alt) h 25 90);
--color-text-accent-muted: hct(from var(--brand-alt) h 22 70);
--color-main: hct(from var(--brand-main) h 60 56); --color-main: hct(from var(--brand-main) h 60 56);
--color-accent: hct(from var(--brand-alt) h 60 56); --color-accent: hct(from var(--brand-alt) h 60 56);
@@ -19,8 +19,6 @@
{ value: "analyze", label: t("categories.analyze") }, { value: "analyze", label: t("categories.analyze") },
{ value: "generate", label: t("categories.generate") }, { value: "generate", label: t("categories.generate") },
]; ];
// TODO: Button component
</script> </script>
<div class="catalog-toolbar"> <div class="catalog-toolbar">
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
import type { import type {
ColorPair, ColorPair,
ColorPairSpec, ColorPairSpec,
@@ -37,7 +38,7 @@
value={current.from} value={current.from}
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)} oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
/> />
<span class="axis-label">From</span> <span class="axis-label">{t("ui.from")}</span>
</label> </label>
<label class="pair-axis"> <label class="pair-axis">
<span class="swatch" style="background:{current.to}"></span> <span class="swatch" style="background:{current.to}"></span>
@@ -46,7 +47,7 @@
value={current.to} value={current.to}
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)} oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
/> />
<span class="axis-label">To</span> <span class="axis-label">{t("ui.to")}</span>
</label> </label>
</div> </div>
</div> </div>
@@ -1,7 +1,3 @@
<script lang="ts">
// TODO: insert version from package.json
</script>
<footer class="preview-footer"> <footer class="preview-footer">
<span>easy-png-tools <b class="version">v0.1.0</b></span> <span>easy-png-tools <b class="version">v0.1.0</b></span>
<span>© 2026</span> <span>© 2026</span>
@@ -18,8 +18,6 @@
loading = false, loading = false,
parts = undefined, parts = undefined,
}: Props = $props(); }: Props = $props();
// TODO: make canvas aspect ratio auto, from 16:9 to 9:16 max
</script> </script>
<figure class="tile"> <figure class="tile">
-1
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
// TODO: сделать эти типы общими для кнопок \ бейджей \ етц???
const ToneVariantDefine = { const ToneVariantDefine = {
DEFAULT: "default", DEFAULT: "default",
ACCENT: "accent", ACCENT: "accent",
@@ -9,8 +9,6 @@
} }
let { theme, ontoggle }: Props = $props(); let { theme, ontoggle }: Props = $props();
// TODO: change icon animations - custom button instead of IconButton
</script> </script>
<IconButton <IconButton
-2
View File
@@ -24,8 +24,6 @@
), ),
})).filter((g) => g.tools.length > 0), })).filter((g) => g.tools.length > 0),
); );
// TODO: make main page different from catalog - larger and simpler search, no categories
</script> </script>
<div class="catalog-page"> <div class="catalog-page">
+29 -1
View File
@@ -4,7 +4,35 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
test: { test: {
include: ["src/**/*.test.ts", "eslint-plugins/**/*.test.ts"], include: [
"src/**/*.test.ts",
"eslint-plugins/**/*.test.ts",
"scripts/**/*.test.mjs",
],
environment: "node", environment: "node",
coverage: {
provider: "v8",
reporter: ["text-summary"],
reportsDirectory: "./coverage",
include: ["src/lib/**/*.ts"],
exclude: [
"src/lib/**/*.test.ts",
"src/lib/**/test-helpers.ts",
"src/lib/**/*.svelte.ts",
"src/lib/executor/executor.worker.ts",
"src/lib/components/define.ts",
"src/lib/core/domText.ts",
"src/lib/core/io.ts",
"src/lib/i18n/en.ts",
"src/lib/i18n/ru.ts",
"src/lib/tool-icons.ts",
],
thresholds: {
statements: 90,
branches: 82,
functions: 72,
lines: 91,
},
},
}, },
}); });