textmachine/frontend/docs/FRONTEND_PLAN.md

624 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# План фронта — как пишется интерфейс TextMachine
> Как пишется фронт: пины, карта `src/`, правила в проверяемой форме, протокол скриншот-цикла,
> замеры референса. Правят фронт-сессии; при конфликте с `STACK_DECISIONS.md` побеждает
> STACK_DECISIONS (там ратифицированы пины), при конфликте с промтом сессии — промт (там
> ратифицирован продукт). Статус этапов — `frontend-PROGRESS.md`, очередь этапов — `BACKLOG.md` Ф-1.
## 0. Границы
Зона записи — только `frontend/`. `backend/`, `platform/`, `docs/`, `eval/` — read-only.
### 0.1. Канон: что читать перед чем
Фронт живёт в проекте с ратифицированным контрактом, и половина ответов на вопросы «как правильно»
уже написана — не здесь. Таблица заведена после того, как два дефекта зоны пришли ровно
из непрочитанного канона (разбор — `frontend-PROGRESS.md`, запись про карту канона).
Ссылки, а не пересказ — пересказ протухает:
| Читать | Зачем фронту | Когда |
|---|---|---|
| [../../CLAUDE.md](../../CLAUDE.md) | зоны, гардрейлы, git-протокол (коммит только pathspec-формой) | первым делом |
| [../../docs/README.md](../../docs/README.md) | карта: где что лежит и что чем перекрыто | первым делом |
| [../../docs/product-requirements.md](../../docs/product-requirements.md) | реестр ПТ-1..ПТ-34; **ПТ-33 и ПТ-34 — жёсткие инварианты интерфейса**, ПТ-21 задаёт якорь чтения | до первого экрана |
| [../../docs/research/23-engine-platform-seam.md](../../docs/research/23-engine-platform-seam.md) | форма шва движок↔платформа; `docs/README.md` требует читать его **перед любым кодом стыка** | до любого кода данных |
| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет. ⚠ Номера строк из прежних редакций этого плана (95, 99103) закрыты и в бэклоге больше не стоят — искать грепом по слову, а не по номеру | перед планированием этапа |
| [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных |
| [api-contract/openapi.yaml](api-contract/openapi.yaml) + компаньон рядом с каноном (`../../docs/architecture/14-api-contract/README.md`) | **ратифицированный контракт** — форма данных, коды ответов, словари. ⚠ Заменил `API_CONTRACT_INPUT.md`: тот под баннером «исполнено» и живым входом больше не является | до слоя данных и при каждой правке формы |
| [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове |
Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности
в шапке + живая голова с хвоста, корпус D1D38 — grep по D-номеру, целиком не читать.
Фронта касаются **D39.81** (SaaS; движок = процесс-на-прогон, сервер в `backend/` не пишется),
**D39.84** (стек + восемь решений владельца + зонные бэклоги), **D39.85** (шов), **D39.88** (git).
### 0.2. Транспорт: с кем фронт разговаривает
**С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс
на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится
(`research/23` §4, §0). Между фронтом и движком стоит платформа. Что в ней построено и чего ещё
нет — `../../platform/BACKLOG.md` и `../../platform/docs/platform-PROGRESS.md`; копии её состояния
здесь нет намеренно, она отставала на паки.
Проводов два, и их легко перепутать:
| Шов | Формат | Где ратифицировано |
|---|---|---|
| движок → платформа | версионированный **NDJSON**-поток событий (объект на строку, первая строка — version-хендшейк), плюс артефакты границ стадий, плюс `tmctl status --json` для ре-синка | D39.85, `research/23` §2§3 |
| платформа → фронт | **JSON поверх HTTP** для чтений из Postgres read-модели + **SSE** для живого прогресса; события пушит воркер, фронт read-модель не опрашивает; WebSocket отвергнут | D39.84, `STACK_DECISIONS.md` §5 |
Фронту принадлежит только вторая строка. NDJSON в коде фронта появиться не может; если появился —
кто-то полез не на тот уровень. Дисциплину стрима, которую обязан выдержать слой данных, задаёт
`STACK_DECISIONS.md` §5 (строкой выше).
---
## 1. Пины
Все версии сверены с npm-регистри **02.08.2026** командой `npm view <pkg> version`.
Ставятся **точными** версиями, без `^` и `~`. Колонка «дата» — дата публикации именно этой версии.
Строка «= latest» означает: на 02.08.2026 эта версия и есть последняя опубликованная.
Единственное намеренное отставание от latest — TypeScript (см. сноску).
⚠ API `react-resizable-panels` сверен по `.d.ts` в поставке, а не по статьям: `Group`/`Panel`/`Separator`
и `useDefaultLayout` на месте, `onLayoutChange` помечен deprecated в пользу `onLayoutChanged`
берём второй (`STACK_DECISIONS.md` §2).
### Рантайм
| Пакет | Пин | Дата | Зачем именно нам |
|---|---|---|---|
| `react` | `19.2.8` | 2026-07-21 | ядро; = latest, React 20 не существует |
| `react-dom` | `19.2.8` | 2026-07-21 | обязан совпадать с `react` версия-в-версию |
| `react-router` | `8.3.0` | 2026-07-22 | библиотечный (data) режим: маршрут на экран — несущая правила «каждый экран открывается в изоляции» (§3.10). Требует Node ≥ 22.22 |
| `@tanstack/react-query` | `5.101.4` | 2026-07-21 | всё серверное состояние: прогон, книги, банк (мажор и ловушка «v6» — `STACK_DECISIONS.md` §1). Зачем нам: серверное состояние живёт ровно в одном месте, и поток пишет в тот же кэш, что читает экран — иначе прогресс существовал бы в двух копиях. Рефетч по возврату фокуса оставлен ВКЛЮЧЁННЫМ намеренно: это ровно та гонка, ради которой контракт завёл ревизию, и выключить его значило бы спрятать гонку вместо того, чтобы её обработать |
| `zustand` | `5.0.14` | 2026-05-28 | состояние интерфейса (свёрнутость панелей, активные вкладки). Только с селектором или `useShallow` |
| `react-aria-components` | `1.20.0` | 2026-07-31 | ЕДИНСТВЕННАЯ библиотека примитивов: `Table`+`Virtualizer` (банк), `Tree` (главы), `Tabs/Menu/Dialog/Popover/Tooltip` (оболочка), `I18nProvider` (русские служебные строки). Стилей не навязывает. Импорт разрешён только внутри `src/ui/` |
| `react-resizable-panels` | `4.12.2` | 2026-07-12 | три панели Fleet. API v4: `Group`/`Panel`/`Separator` — старые имена `PanelGroup`/`PanelResizeHandle` мертвы |
| `lucide-react` | `1.28.0` | 2026-07-30 | тонкие линейные монохромные иконки как во Fleet: `size 16`, `strokeWidth 1.5`, без `absoluteStrokeWidth` |
| `@fontsource-variable/inter` | `5.3.0` | 2026-07-19 | интерфейсный гротеск; локальный woff2, а не CDN — вид не зависит от сети и одинаков в скриншот-цикле |
| `@fontsource-variable/jetbrains-mono` | `5.3.0` | 2026-07-19 | моноширинный для содержимого |
CJK-шрифт не ставим: иероглифы отдаём системному стеку, `lang` на элементе приходит из данных
языковой пары (`STACK_DECISIONS.md` §2).
### Сборка и типы
| Пакет | Пин | Дата | Зачем именно нам |
|---|---|---|---|
| `vite` | `8.2.0` | 2026-07-30 | внутри Rolldown вместо Rollup+esbuild; = latest |
| `@vitejs/plugin-react` | `6.0.5` | 2026-07-30 | без Babel; старый рецепт подключения React Compiler в нём удалён |
| `typescript` | `6.0.3` | 2026-04-16 | **намеренно не latest** (latest = 7.0.2): у TS 7 нет программного API до 7.1, значит нет `typescript-eslint` и плагинов языкового сервера. Пересмотр — `BACKLOG.md` Ф-3 |
| `@types/react` | `19.2.18` | 2026-07-30 | |
| `@types/react-dom` | `19.2.4` | 2026-07-30 | |
| `@types/node` | `22.20.1` | | мажор типов держим на мажоре рантайма (Node 22), а не на latest 26 — иначе тайпчек разрешит API, которых на стенде нет |
| `lightningcss` | `1.33.0` | 2026-07-20 | минификация CSS в бою; в dev не участвует |
### Контроль качества
| Пакет | Пин | Дата | Зачем именно нам |
|---|---|---|---|
| `eslint` | `10.8.0` | 2026-07-24 | ради `no-restricted-syntax` — на нём стоит половина гейта «одно место для цвета»; в oxlint такого правила нет вовсе |
| `@eslint/js` | `10.0.1` | 2026-02-06 | базовый набор для flat-config |
| `typescript-eslint` | `8.65.0` | 2026-07-20 | единый пакет (парсер+плагин+`config`) |
| `eslint-plugin-react-hooks` | `7.1.1` | 2026-04-17 | держит совместимость с React Compiler, пока сам компилятор выключен (`BACKLOG.md` Ф-2) |
| `eslint-plugin-react-refresh` | `0.5.3` | | ловит экспорты, ломающие HMR |
| `eslint-config-prettier` | `10.1.8` | 2025-07-18 | гасит форматные правила ESLint, чтобы формат был ровно один |
| `stylelint` | `17.14.1` | 2026-07-20 | вторая половина гейта: `declaration-property-value-allowed-list` |
| `stylelint-config-standard` | `40.0.0` | 2026-01-15 | база |
| `prettier` | `3.9.6` | 2026-07-21 | точный пин: разные патчи форматируют по-разному и создают шум в диффах |
| `vitest` | `4.1.10` | 2026-07-06 | тесты на том же конфиге Vite |
| `happy-dom` | `20.11.1` | 2026-07-22 | DOM для контракт-теста токенов: в отличие от jsdom считает каскад CSS-переменных |
| `@testing-library/react` | `16.3.2` | 2026-01-19 | рендер компонентов в тестах |
| `@playwright/test` | `1.62.1` | 2026-07-30 | скриншот-цикл и сверка вычисленных токенов в настоящем Chromium |
| `msw` | `2.15.0` | 2026-07-08 | моки сети, пин ратифицирован при D39.84. Замер, сделанный ДО того, как на нём что-то построили: сервис-воркер MSW перехватывает `EventSource` в живом Chromium и отдаёт потоковое тело кадр за кадром, `lastEventId` заполняется. В тестовом DOM `EventSource` нет вовсе — отсюда развод «разбор кадра отдельно от транспорта» (`src/api/stream.ts`). `public/mockServiceWorker.js` — вывод `msw init`, вендорный файл: исключён из prettier и eslint |
| `@stoplight/spectral-cli` | `6.16.3` | 2026-08-03 | линт контракта шагом `npm run check` (внутри `npm run contract`): битые `$ref`, дубли `operationId`, ответы без описания. Порог строгий (`--fail-severity=warn`) — предупреждение валит команду так же, как ошибка |
| `openapi-typescript` | `7.13.0` | 2026-02-11 | генерит `src/api/schema.ts` из `docs/api-contract/openapi.yaml`. Типы перестают быть авторским пересказом контракта: расходиться им физически нечем. ⚠ Пир `typescript@^5.x` против нашего TS 6 — снят точечным `overrides`, обоснование и проба — `BACKLOG.md` Ф-23 |
| `globals` | `17.8.0` | 2026-07-26 | наборы глобалов для flat-config |
### Стенд
`node -v` = **22.22.3** — проходит нижнюю границу `react-router` (`engines.node: >=22.22.0`),
она самая жёсткая из всех. Проверять первым действием на любой новой машине.
Пакетный менеджер — npm из поставки Node, один `package.json` в `frontend/`, воркспейсов нет.
---
## 2. Карта `src/`
| Папка | Что внутри | Что НЕ внутри |
|---|---|---|
| `tokens/` | `tokens.css` — единственный источник цвета, размера, радиуса, шрифта. `reset.css`. Плюс `measures.ts` — те же числа для кода, который в CSS не смотрит (виртуализатор, размеры панелей); сверяется с токенами тестом | ничего кроме двух глобальных файлов стилей; своих значений `measures.ts` не заводит |
| `ui/` | глупые примитивы на токенах: кнопка, поле, вкладки, строка дерева, таблица, выноска. Единственное место, где разрешён импорт `react-aria-components` | запросы, знание о доменных сущностях |
| `shell/` | оболочка: три панели, верхняя полоса, статус-полоса, вкладки | доменная логика экранов |
| `features/` | `books`, `chapters`, `bank`, `reader`, `settings` — по экрану на папку; запрос живёт здесь, на уровне экрана | цвета и размеры мимо токенов |
| `api/` | единственный вход к данным. С S3: `schema.ts` (генерённый) · `contract.ts` суженные типы и нормализация · `vocabulary.ts` словари и их смыслы, включая ветку неизвестного · `client.ts` HTTP, `X-TM-Client`, следование курсору · `revision.ts` высшая отметка КАДРА потока (отбрасывание устаревшего ЧТЕНИЯ живёт в `queries.ts`: сравнивать надо с тем, что приложение держит, а не с копией последнего ответа — копия протухает в момент, когда поток патчит кэш) · `stream.ts` `EventSource` · `queries.ts` по функции на операцию контракта · `scenarios.ts` выбор мира фикстур. Единственное место, где легальны импорт из `mock/` и любой сетевой вызов | React-компоненты и React вообще: слой запросов живёт на экране, `api/` о нём не знает |
| `mock/` | фикстуры и мок-сеть: `book.ts`/`bank.ts`/`scale.ts` данные · `worlds.ts` мир на сценарий · `handlers.ts` MSW · `events.ts` живой поток · `browser.ts` воркер. В день появления API папка удаляется целиком, экраны не трогаются | логика; фикстуры — данные |
| `i18n/` | строки интерфейса: `ru.ts` — КАТАЛОГ (единственный дом слов, ключ выводится из него типом), `text.ts``useText()`/`text()` поверх `@internationalized/string`, `language.ts` — выбор языка и его локаль для `I18nProvider` и `Intl`, `catalogue.test.ts` — сторож языка кода. Второй язык = второй файл того же вида плюс строка в `language.ts` | логика экранов; фикстурная проза (она данные, живёт в `mock/`) |
| `showcase/` | витрина: маршруты `/showcase` и `/scale` (тот же экран на настоящем масштабе книги), живой каталог для сверки с `references/fleet.png`. С S2 здесь же лежит наполнение трёх панелей — оно переедет в `features/`, когда экраны станут настоящими (S4S7) | продуктовые экраны и их запросы |
Правило зависимостей — сверху вниз, без обратных рёбер:
`features/``ui/` + `api/`; `shell/``ui/`; `ui/``tokens/`. `api/` не знает про React.
`i18n/` не зависит ни от кого и виден всем: слова нужны и примитиву, и экрану, и словарю статусов
на шве `api/` (там лежит КЛЮЧ, а не слово).
### 2.1. Слои каскада
Ловушка `STACK_DECISIONS.md` §7.4 — тихие расхождения из-за порядка каскада между стилями
сторонних примитивов, токенами и CSS Modules. Порядок объявлен один раз, в шапке `reset.css`
(он импортируется первым, а заявление слоёв обязано стоять раньше любого слоя):
```css
@layer reset, vendor;
```
- **сброс** — самый низ, перебивается чем угодно;
- **vendor** — стили сторонних примитивов; подключаются только так:
`@import 'пакет/styles.css' layer(vendor)`;
- **токены и CSS Modules — намеренно ВНЕ слоёв.** Неслойное правило выигрывает у любого слоя,
поэтому ни сброс, ни чужие стили наши перебить не могут, а модуль экрана при нужде может
локально переопределить токен обычным каскадом.
Токены сознательно не завёрнуты в слой: заворачивать нечего (это только объявления переменных
на `:root`), а лишний слой делает их слабее собственных стилей приложения без всякой выгоды.
---
## 3. Правила поддерживаемости — в проверяемой форме
Из `FRONTEND_SESSION_PROMPT.md` §5.1. Слева — правило, справа — чем оно проверяется.
Правило без проверки — лозунг, поэтому у каждого либо машинный гейт, либо ревью-вопрос
с однозначным ответом.
| # | Правило | Проверка |
|---|---|---|
| 1 | Цвет и размер только из `tokens.css` | **машинно**: stylelint `declaration-property-value-allowed-list` (`/^var\(--/` на `color`, `background-color`, `border-color`, `fill`, `stroke`, `font-size`, `z-index`) + ESLint `no-restricted-syntax` на `#hex`/`rgb(`/`hsl(`/`oklch(` в TSX. Исключение по пути — только `tokens/` |
| 2 | Отключить CSS-гейт комментарием нельзя | **машинно**: `reportDisables: true` на правилах + `reportUnscopedDisables: true` в корне конфига. Одного `reportDisables` НЕ хватает: он ловит только именованное отключение, а голое `/* stylelint-disable */` в шапке снимало гейт молча — проверено. В TSX то же самое делают правила `@eslint-community/eslint-comments`: `no-unlimited-disable` требует назвать правило, `require-description` — написать причину, `disable-enable-pair` — закрыть область. Голое `/* eslint-disable */` даёт три ошибки; точечное `// eslint-disable-next-line react-hooks/exhaustive-deps -- причина` проходит. Бинарный `noInlineConfig` не берём: он запрещает и то, что рекомендует сам React |
| 3 | Данные только через `src/api/` | **машинно**, см. правило 14 (было ревью-вопросом — не сработало) |
| 4 | Файл = один компонент + свой `.module.css`, больше ~150 строк — делить | ревью глазами при лендинге пакета |
| 5 | Состояние ровно в двух местах: TanStack Query (серверное), Zustand (интерфейсное) | ревью-вопрос: есть ли `useState` с копией серверных данных? Должно быть «нет» |
| 6 | Глобальных стилей два файла | **машинно**: любой `.css` вне `src/tokens/`, не являющийся `*.module.css`, — ошибка сборки правилом ESLint на импорт |
| 7 | Никаких UI-китов | ревью-вопрос: в `package.json` кроме `react-aria-components` библиотек компонентов нет |
| 8 | Компоненты глупые: данные пропсами, запросы на уровне экрана | ревью-вопрос: есть ли `useQuery` внутри `src/ui/`? Должно быть «нет» |
| 9 | Комментарии — одна-две строки «почему» | ревью глазами; проектная норма |
| 10 | Каждый экран открывается в изоляции: свой маршрут, своя фикстура | **машинно** косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется |
| 11 | `styles.имяКласса` ссылается на существующий класс | **машинно**: `src/cssModules.test.ts`. Vite типизирует модуль как `{ [key: string]: string }`, поэтому опечатка даёт `className="undefined"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась |
| 12 | Имя токена в `var(--…)` и в строках кода объявлено в `tokens.css` | **машинно**: `src/tokens/tokens.test.ts`. Тот же класс тихой ошибки: stylelint проверяет только форму обёртки `var(--…)`, тайпчек видит обычную строку, браузер отдаёт пустое значение — элемент гаснет в фон, и на скриншоте это 124 пикселя из 3,9 млн. Локальные переменные, задаваемые через `style`, перечислены в тесте явным списком |
| 14 | Данные — только через `src/api/`; сеть — только там же | **машинно**: `no-restricted-imports` на `**/mock/**` плюс `no-restricted-globals`/`no-restricted-properties` на ВСЕ транспорты (`fetch`, `EventSource`, `WebSocket`, `XMLHttpRequest` и они же через `window`/`globalThis`/`self`, плюс `navigator.sendBeacon`), с исключением для `src/api/**`. Одного `fetch` не хватало: живой прогресс по ратифицированному стеку приходит через `EventSource` (`STACK_DECISIONS.md` §5) — то есть шов расползся бы именно тем транспортом, который гейт не видел |
| 15 | Доступность проверяется машиной, а не глазами владельца | **машинно**: axe-core прогоняется в `npm run shot` по каждому маршруту и валит команду; список маршрутов скрипта сверяется с `routes.tsx` тестом (`src/routes.test.ts`) — иначе новый экран просто не попадал в прогон, а команда возвращала успех. Контраст вынесен в отчёт: палитра снята с Fleet замером и принята владельцем, менять её под порог WCAG — отдельное решение (`BACKLOG.md` Ф-11) |
| 17 | Пара языков живёт в данных, а не в разметке | **машинно**: `src/generality.test.ts` запрещает литерал `lang="…"` в TSX. Это ревью-вопрос канона в исполняемой форме («заработает ли пара, которой в репо ещё нет, без правки кода?» — `CLAUDE.md` §2). Найдено ревью S1 в живом коде: `lang="zh"` пережил бы приезд ja→ru молча, и кандзи отрисовались бы китайскими начертаниями |
| 16 | Плавающий промис не проходит | **машинно**: `tseslint.configs.recommendedTypeChecked` с `projectService`. Проверено: `load()` без `await` роняет линт |
| 13 | Инлайновый стиль — только литерал объекта прямо в атрибуте, и только с ключами-CSS-переменными | **машинно**: `no-restricted-syntax` запрещает сам атрибут `style` и разрешает единственную форму. Перечислять формы записи оказалось бесполезно: из одиннадцати способов записать то же самое ловилось три. JSX-спред закрыт с другой стороны — запрещено само свойство `style` в объектных литералах, то есть носитель ловится там, где он собран. Плюс значение CSS-переменной обязано быть числом или `var(--…)` |
| 18 | Библиотека примитивов видна ровно из одного места | **машинно**: `no-restricted-imports` на `react-aria-components` с исключением для `src/ui/**`. Две библиотеки примитивов означали бы два focus-scope и два портальных менеджера (`STACK_DECISIONS.md` §2), а один вход делает замену библиотеки правкой одной папки |
| 19 | Отступы, промежутки и радиусы — только из токенов | **машинно** (Ф-4, включён в S2): stylelint `declaration-property-value-allowed-list` на `/^(padding\|margin)(-\|$)/`, `/^(gap\|row-gap\|column-gap)$/`, `/^border-.*radius$/`. Значение — последовательность `var(--…)`; `0`, `auto`, `inherit` легальны, `calc()` намеренно нет |
| 21 | Плоско и скучно: никаких абстракций «на будущее», фабрик и слоёв ради слоёв | ревью-вопрос: что этот слой даёт СЕГОДНЯ? Дублирование двух строк лучше преждевременного обобщения — проектная норма «механизм там, где несёт ценность». Машинного гейта нет, и это честно |
| 22 | Имена человеческие, без аббревиатур | ревью глазами; проектная норма. Машинного гейта нет |
| 20 | Числа, которые нужны коду, а не стилям, не заводят вторую истину | **машинно**: `src/tokens/measures.ts` держит их одним объектом (высота строки для виртуализатора, ширины панелей для `react-resizable-panels`), а `tokens.test.ts` сверяет каждое значение с одноимённым токеном. Без этого высота строки существовала бы в двух местах и разъезжалась молча |
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
добавление нового состояния главы или нового вида замечания правит **один** файл.
Правит три — структура неверна, переделать до движения дальше.
Ответ этой структуры (типы приходят из спеки, авторского файла типов у зоны нет):
состояние книги живёт **одной записью в `src/api/vocabulary.ts`** — там же его
слово, тон и то, что с ним можно делать (`startable`, `intake`); экраны эту запись читают и ничего
о списке значений не знают. Цвет — токен. Новый вид замечания — запись в карте видов, компонент
выноски не трогается. Проверено S4: добавление свойства «книгу в этом состоянии можно запустить»
тронуло один файл, а новое состояние без слова роняет `tsc` на этой же карте.
---
## 4. Протокол скриншот-цикла
Цель — не «код валиден», а «вид совпал». Смотреть на построенное обязательно, а не предполагать.
**Команда:** `npm run shot [маршрут ...] [--size ШxВ]`. Без аргументов снимает `/showcase`
при 1440×900.
**Что делает:**
1. собирает и поднимает `vite preview` (сборка, а не dev — dev-оверлеи не должны попадать в кадр);
2. запускает Chromium из Playwright, вьюпорт **1440×900** (базовая ширина из §4.5 промта),
`deviceScaleFactor: 2` — чтобы снимок был сравним с референсом, снятым на macOS при 2x;
`--size 1280x764` даёт вьюпорт самого референса, когда нужно прямое наложение;
3. ждёт готовности экрана и загрузки шрифтов (`document.fonts.ready`) — иначе в кадр попадает
фолбэк-шрифт;
4. кладёт PNG в `frontend/.shots/<маршрут>.png`.
**⚠ Сигнал готовности сменился в S3, и `networkidle` вернуть нельзя.** Живой прогон держит
SSE-соединение открытым ПО ПОСТРОЕНИЮ — сеть не затихает никогда, и прежнее ожидание
`waitUntil: 'networkidle'` на маршруте с потоком висело бы до таймаута. Вместо него экран сам
говорит, когда решил, что рисовать: `document.documentElement.dataset.screen` = `ready`, когда ни
один запрос не в полёте, и `loading`, пока хоть один летит. «В полёте», а не «pending»: выключенный
запрос TanStack Query остаётся `pending` навсегда, и счёт по `isPending` держал бы флаг в `loading`
на давно сошедшемся экране. У маршрута `/loading` финальная картинка — само ожидание, поэтому он
перечислен в `PENDING_ROUTES` скрипта и снимается на `loading`.
**У каждого маршрута свой мир фикстур.** Список маршрутов выводится из `src/api/scenarios.ts`
и замкнут со вторым списком `scripts/shot.mjs` тестом `src/routes.test.ts`; что означает каждый
мир — в шапке `src/mock/worlds.ts`. Прозаической копии списка здесь нет намеренно: она отставала
на этап. Каждый маршрут получает свой кадр и свой прогон axe: ветка, которую не открыть
по прямой ссылке, не проверяется ничем.
**Что делаю я после команды — обязательная часть цикла, а не опция:** открываю PNG инструментом
чтения файлов, смотрю на него, кладу рядом `references/fleet.png`, называю расхождения словами,
правлю, снимаю снова.
**Два ключа и вторая команда (S3.5).**
- `--dpr 1` снимает то, что видит владелец. Дефолтные 2x нужны для сравнения с референсом, но они
ПРЯЧУТ целый класс дефектов: полупиксель CSS ложится в целый пиксель устройства, и мыло на
штрихах иконок и на мелком тексте в кадр не попадает. Замечание владельца 17 нашлось только так.
- `.tooling/py/bin/python scripts/measure.py <кадр> [<кадр>…]` — доли поверхностей, промежутки
и полосы в CSS. Норма «работать замером, а не на глаз» держится инструментом, а не обещанием.
Окружение запинено: `scripts/requirements.txt` (Pillow), ставится в `.tooling/py` одной строкой
из шапки того же файла — системный python на стенде идёт без Pillow и с PEP 668.
- `npm run scenes` — сценарии ИНТЕРАКЦИЙ (`scripts/scenes.mjs`): клик → кадр → проверка состояния.
Статичный кадр не принимает вкладки, драг ручки, модалы и спойлеры — они существуют только
в движении. Падение сценария роняет команду; кадры ложатся в `.shots/scenes/`. Сегодня их
**одиннадцать** (сверено 02.09 по `scripts/scenes.mjs`, объект `scenes`; прежняя редакция писала
«девять» — два сценария S4 в неё не попали): `tabs` (модель VS Code + контраст выбранной строки),
`scroll` (keep-alive, Ф-19), `drag` (залипание полоски), `context` (сводка замечаний),
`bank` (таблица, спойлер, ширины), `hidden` (таблица, смонтированная в скрытой вкладке),
`zoom` (раскладка при 125/150/200%), `perf` (кадры прокрутки, поиска и драга на 1200 терминах),
`intake` (добавление книги), `refusals` (отказы формы), `overlays` (модальные окна).
**Стенд без root.** `.tooling/` собирается один раз и в репозиторий не едет (вместе с `.shots/`
он в `.gitignore`). Chromium не стартует, пока не найдёт `libnss3`, `libnssutil3`, `libnspr4`,
`libasound.so.2`; в системе их нет, а sudo недоступен, поэтому пакеты выкачиваются и
распаковываются локально. Тем же способом кладётся шрифт CJK: на стенде не установлено ни одного
(`fc-list :lang=zh` пуст), и без него весь китайский текст в кадре — квадраты, а плотность колонок
проверить нечем. `scripts/shot.mjs` сам подставляет `LD_LIBRARY_PATH` и `XDG_DATA_HOME`, когда
видит `.tooling/root` — думать об этом больше не нужно.
```bash
npm i -D playwright
npx playwright install chromium # БЕЗ --with-deps: он требует sudo и ставит лишнее
mkdir -p .tooling && cd .tooling
apt-get download libnss3 libnspr4 libasound2t64 fonts-noto-cjk
for d in *.deb; do dpkg -x "$d" root; done
rm -f *.deb && cd ..
```
Шрифт нужен только стенду: продукт отдаёт CJK системному стеку (`STACK_DECISIONS.md` §2),
на Windows у пользователя он есть.
---
## 5. Критерий сходимости с референсом
**Растровое совпадение не является целью и недостижимо:** Fleet закрыт (скачивание прекращено
22.12.2025), референс снят на macOS при 2x, цель — Chromium с другим хинтингом шрифтов.
Эталонных скриншотов в тестах нет — вместо них контракт-тест токенов
(`STACK_DECISIONS.md` §3).
**Сходиться обязаны:** цвета · промежутки 8px · радиусы 6px · плотность строк · общее впечатление.
**Подбираются на глаз:** кегли, интерлиньяж, внутренние отступы — померить их больше негде.
### 5.1. Замеры референса: действующие числа, способ повторить, что отвергнуто
> Прежние **§5.55.7** (пере-замеры по сессиям) слиты СЮДА: ссылки на них из кода
> (`tokens.css`, `reset.css`, `tokens.test.ts`, `Panel.tsx`, `Shell.module.css`) и из журналов
> зоны резолвятся в этот раздел.
**Способ, обязательный к повторению.** Поверхности, промежутки и полосы — `scripts/measure.py`
(команда и запинованное окружение — §4). Чего он не печатает: общая гистограмма кадра —
`PIL.Image` + `collections.Counter` по `im.getdata()`, именованная область — `im.crop(box)`,
насыщенные тона — фильтр по `colorsys.rgb_to_hls` (s > 0.25, 0.12 < l < 0.75). Масштаб снимков
2x подтверждён независимо: все четыре промежутка между панелями равны ровно 16 физическим
пикселям, что даёт целые 8 CSS-пикселей.
**Поверхности — тон снят с `fleet_2.png` и `antigravity_*.png`**, двух редакторов, которых
владелец назвал эталоном (замечания второго круга 2 и 9):
| Что | fleet_2.png | antigravity_*.png | Наш токен |
|---|---|---|---|
| полотно панели | `#181818` (63.97% кадра) | `#161616` (18%) | `--color-panel: #181818` |
| хром (полоса вкладок, верх окна) | `#292929` (7.17%) | `#1c1c1c`/`#252525` | `--color-chrome: #242424` |
| фон окна | | `#101010` (72%) | `--color-shell: #090909` (не трогали) |
| выбранная строка | `#184176` (срез строки CardPicker.tsx) | | `--color-selected: #1c4478` |
| синий/зелёный акценты | | `#2964ad` / `#3c7d4f` (NOTE/TIP) | `--color-note` / `--color-ok` |
Главное, что дал замер: **оба референса строго нейтральны, R=G=B** тёплый подтон
«по рекомендациям про тёмные фоны» противоречил тем самым кадрам, на которые ссылались. Числа
воспроизведены вторым исполнением по `references/fleet_2.png` и сошлись, то есть `--color-panel`
стоит на замере, а `--color-chrome` (`#242424`) остаётся осознанно на ступень спокойнее хрома
референса.
**Остальные роли — с `fleet.png`** (доля площади по гистограмме кадра 2560×1528):
| Роль | Значение | Подтверждение |
|---|---|---|
| фон оболочки | `#090909` | 10.45% площади |
| **активная вкладка открытого документа** | `#142f4c` | заливка вкладки `rpc-node.ts` 7 183 px синеватого, а не серого |
| подсветка текущей строки | `#152945` | 36 800 px |
| выделение | `#164e8d` | 476 px |
| текст основной | `#dfe1e3` | 0.40% площади |
| текст вторичный | `#8a8e91` | статус-полоса |
| текст приглушённый | `#707479` | колонка номеров строк |
| акцент | `#746deb` | 1 258 px, рамка поля ввода |
| красный индикатора | `#b82e45` | 829 px |
Текстовый ряд и два акцента в токенах ПОДНЯТЫ против замера ради контраста
(`#dedede`/`#9b9b9b`/`#707070`, `--color-note`, `--color-danger` нетекстовый порог 3:1, Ф-52):
замер здесь провенанс происхождения, а не действующее значение. Действующие числа `tokens.css`.
**Геометрия** (физические пиксели / 2):
| Величина | CSS | Как замерено |
|---|---|---|
| промежуток между панелями | **8** | четыре среза по 16 физ. без исключений |
| поле оболочки по краям | **8** | тот же срез: слева и справа те же 16 физ. |
| радиус панели | **6** | угол выходит на прямую за 12 физ. |
| шапка панели (вкладки) | **26** | заливка вкладки `Files` 86..137 физ. |
| шаг строки дерева | **26** | шаг текстовых полос: 52 физ., 15 строк подряд |
| подложка выбранной строки | **24** | заливка `#353739` 48 физ. при шаге 52 |
| межстрочный интервал содержимого | **21** | подсветка текущей строки 42 физ. |
| ширина боковой панели при 1280 | **320** (25%) | границы панелей: 8328 · 336944 · 9521272 |
| внутренний отступ панели | **6** | подложка строки 14321.5 при панели 8328 |
**Полосы окна — по пере-замеру, а не по первой модели.** Первая модель считала полосы частью
области панелей (8 поля + 28 полосы) и держала текст на 4px выше референса. Замер:
центр иконок верхней полосы y=17.75 при полосе 036; центр текста статус-полосы y=749.75 при
полосе 735.5764; поле текста полос 12.5 слева и 13.5 справа **от края окна**; пилюля «Files»
1559 при тексте 2552, до следующей ~5px. Отсюда `--topbar-height: 36`, `--statusbar-height: 28`,
`--bar-inset: 12`, `--tab-inset: 10`, а поле оболочки принадлежит области ПАНЕЛЕЙ, а не окну
целиком иначе поле полосы складывается с полем оболочки и текст уезжает вдвое дальше от края.
Полная геометрия окна при этом не изменилась: 36 + 836 + 28 = 900, промежутки панелей остались
на 0 · 328 · 944 · 1272; после правки текст статус-полосы `x 12.5…1267.0`, `y 744.5…756.0` против
`12.5…1266.5`, `744.5…756.0` у Fleet.
**Типографика** (оценка по высоте глифов, точнее не измеряется):
интерфейс ~13px (высота выносных элементов 10px), статус-полоса ~12px,
содержимое ~13px моноширинным при интерлиньяже 21px.
**Из `antigravity_chat.png` — модель выноски замечания** 3.8 промта):
левая граница 3px, синяя `#2964ad` (заметка) / зелёная `#3c7d4f` (подсказка),
заливки текста нет. Палитра Antigravity (нейтральный серый, без холодного оттенка):
`#101010` фон · `#161616` боковая панель · `#1c1c1c` приподнятая карточка ·
`#252525` кнопка основного действия · `#323232` разделитель · `#cecece` текст.
Две палитры в одном приложении не смешиваются. Базовая Fleet; из Antigravity берётся
**форма** пустого состояния и выноски, не цвета. Токенов Antigravity в `tokens.css` не заводим.
**Что видно только при `--dpr 1`.** Замечание владельца 17 иконки шакальные») в кадрах при 2x
не видно ВООБЩЕ: полупиксель CSS там ложится в целый пиксель устройства. Поэтому у `npm run shot`
есть ключ `--dpr`. Причина мыла сетка, а не толщина: набор нарисован на сетке 24, рисуется
размером 16, координата c уезжает в 2c/3, и у геометрических форм (c кратно трём) центр штриха
шириной 1px попадает ровно на целый пиксель, размазываясь по двум половинкам. Проверены четыре
варианта кадрами 1x: «штрих 2 при 16» и «размер 18, штрих 2» дают жирнее, но по-прежнему мыльно;
полупиксельный сдвиг SVG даёт целые линии. Взят он (`reset.css`).
**Сведение нашего кадра с референсом** (`node scripts/shot.mjs /showcase --size 1280x764`,
тот же инструмент):
| Что | fleet.png (референс) | Наш `/showcase` | Вердикт |
|---|---|---|---|
| фон оболочки | `#090909`, 10.45% кадра | `#090909`, 10.43% | сошлось |
| полотно панелей | `#17191a`, 80.48% | `#181818`, 75.36% | тон по замеру `fleet_2`; доля ниже, потому что банк в правой панели держит свои цвета |
| промежутки по горизонтали | 0 · 328 · 944 · 1272, ширина 8 | 0 · 316 · 644 · 1272, ширина 8 | **ширина та же, границы сдвинуты осознанно:** правая панель шире (`--panel-context-width: 620`), в ней таблица, а не список |
| линия в 1px на x=515 | | есть | не дефект: жёлоб читалки покрашен в цвет оболочки (замечание второго круга 5) |
| полосы по вертикали | верхняя 036, статус 735.5764 | 037 и 735764 | сошлось (полпикселя округление среза) |
По критерию §5 витрина СТОИТ на референсе: ширины панелей 320 · 608 · 320, шаг строки дерева 26,
высота пилюли вкладки 26 совпадают, и «масштабы великоваты» при вьюпорте референса замером
не подтверждается речь про физический размер на конкретном экране (В-7, Ф-36).
**ОТВЕРГНУТО — не переоткрывать:**
- **тёплый ряд поверхностей `#17191a` полотном, `#27292b` приподнятой, `#353739` выбранной
строкой.** Он снят с `fleet.png` и отменён; в §1.1 промта таблицы поверхностей БОЛЬШЕ НЕТ
(`FRONTEND_SESSION_PROMPT.md` греп `Таблицы поверхностей здесь БОЛЬШЕ НЕТ`) действующие числа
здесь, в §5.1. Тон отверг владелец, и токены стоят на нейтральном ряде `fleet_2`/antigravity
выше. Заодно развелись роли: §1.1 отдавал `#353739` разом «выбранной строке» и «активной
вкладке», а в кадре это разные вещи вкладка документа `#142f4c`, вкладка панели серая,
и `#353739` под вкладками не встречается вовсе;
- **модель «вырез в хроме» под активную вкладку.** Срез по вертикали через активную вкладку
`fleet_2` (x=790, y=36‥68) даёт `#181818` без единой разделительной линии до содержимого, тогда
как соседняя неактивная лежит на `#292929`. Модель по этому замеру была построена и ОТКАЧЕНА
по слову владельца: в `Tabs.module.css` пилюля по `fleet.png` полоса вкладок это полотно
панели, активная вкладка лежит НА нём скруглённой подложкой (`--color-raised`, у документа
`--color-tab-active`). Замер верен про сам референс, не про наш код;
- **предел меры набора читалки.** **Ограничивать ширину набора нельзя решение владельца
10.08:** попытку центрировать колонку фиксированным `--reader-width` он назвал «жёстко приклеен
к центру», токен удалён. Набор идёт во всю ширину панели; на 2560×1440 это ~120 знаков в строке.
Функциональную ширину боковых панелей намеренно НЕ переводили в долю кадра: 25% у Fleet это
следствие окна 1280, а не инвариант 640px под дерево разделов были бы пустой ширины.
### 5.2. Расхождения с референсом, названные вслух
Числа сведения в §5.1; здесь то, что расходится осознанно, и почему. Из типографики сошлось
и там не повторено: высота глифов статус-полосы точно (23 физ.), вкладки 19 против 20 физ.
1. **Субпиксельное сглаживание.** У Fleet текст сглажен в серую шкалу (macOS): в статус-полосе
ровно 0% цветных пикселей. У нас в том же месте 1.29%, в дереве 1.35% против 0.37%
это цветная бахрома Chromium. `-webkit-font-smoothing: antialiased` в сбросе действует
только на macOS. Расхождение принято: §8 промта прямо снимает совпадение по хинтингу,
а на Windows субпиксельное сглаживание норма платформы. Гасить его флагом в скриншот-цикле
не стали: снимок должен показывать то, что рисует настоящий браузер.
2. **Колонка оригинала приглушена** (`--color-text-secondary`) это решение, а не замер:
во Fleet на этом месте подсвеченный код. Пересмотреть на S6, когда читалка будет настоящей.
3. **`+` стоит только в ряду вкладок левой панели** и запускает «Добавить книгу» (решение
владельца 04.08); в центре и справа его нет наборы вкладок там фиксированные, и кнопка
вышла бы декоративной. Интерактивные состояния снимаются прогоном браузера, а не статичным
кадром: `--color-raised` подтверждён на наведении кнопки, вкладки и строки списка.
4. **Боковые панели — пиксели, центр — доля.** Решено в S2 замером и продуктом: боковые получают
`groupResizeBehavior: "preserve-pixel-size"` и стартуют с замеренных 320px, центр
`preserve-relative-size`. На 1920 всю лишнюю ширину забирает читалка, а справочные колонки
остаются той ширины, под которую нарисованы. Библиотека требует хотя бы одну панель с долевым
поведением ею и оказывается центр. Минимум и максимум боковой новые токены
`--panel-side-min`/`--panel-side-max`; минимум центра та же ширина, что у боковой панели
(читалка уже боковой панели бессмысленна).
5. **Свёрнутая панель уходит из раскладки вместе со своим разделителем**, а не сжимается
`collapsible`-ом до нуля: при сжатии на её месте оставался бы промежуток разделителя, и поле
оболочки переставало быть замеренными 8px. Размеры при этом не теряются `useDefaultLayout`
хранит раскладку по СОСТАВУ видимых панелей (ключ storage = id группы + id панелей), и это
ровно тот случай, под который в библиотеке заведён проп `panelIds`. Проверено исполнением:
потянул разделитель до 410px перезагрузка 410px; свернул перезагрузка свёрнута.
6. **Скроллбар в кадр не попадает.** Chromium на стенде отдаёт оверлейные полосы
(`offsetWidth - clientWidth = 0`), поэтому в статичном снимке их нет. Что стилизация
применяется проверено вычисленными значениями: `scrollbar-width: thin`,
`scrollbar-color: rgb(53, 55, 57) rgba(0, 0, 0, 0)`. На Windows полосы займут место
и получат эти цвета.
### 5.3. Что выяснилось про инструменты — не переоткрывать
- **`eslint-plugin-react-hooks`**: flat-конфиг лежит в `configs.flat['recommended-latest']`.
Одноимённый ключ верхнего уровня старого формата, ESLint 10 на нём падает с ошибкой
про «plugins as array».
- **Vitest по умолчанию подменяет CSS пустой заглушкой.** Без `test.css: true` контракт-тест
токенов сверял бы пустоту и был бы вечно зелёным. Проверено: до включения он падал
на пустой строке, а не проходил.
- **happy-dom не понимает заявление `@layer a, b;`** проглатывает весь остаток файла,
и `getComputedStyle` перестаёт видеть переменные. Поэтому заявление слоёв живёт в `reset.css`,
а `tokens.css` остаётся чистым 2.1). Блочную форму `@layer x { }` он тоже не поддерживает.
- **`structuralSharing` у TanStack Query ОДИН слот, а не хук рядом с библиотечным.** Функция в
нём ЗАМЕНЯЕТ `replaceEqualDeep` (`query-core/utils.js`: `replaceData` ветвится по
`typeof options.structuralSharing === 'function'` и возвращает результат, не вызывая дедуп).
Наши риды пересобирают строки (`rows.map(narrow.*)`), поэтому без композиции обычный рефетч
отдаёт кэшу целиком новый граф объектов а это дерево на 2284 узла и банк на 1200 терминов,
которые на новой идентичности пересобирают всю коллекцию. Гейт ревизии поэтому КОМПОНУЕТСЯ:
`kept === next ? replaceEqualDeep(previous, next) : kept`.
- **`@types/node` держим на мажоре 22**, а не на latest 26: иначе тайпчек разрешает API,
которых на стенде нет.
- **`import.meta.glob` по `*.module.css` брать с `?raw`, а не `?inline`:** `?inline` отдаёт уже
скомпилированный CSS с хешированными именами (`._shell_1abc_1`), и сверять с ним имена
из TSX бессмысленно.
### 5.4. Гейты: что проверено живым нарушением и чего они не видят
> Прежние **§5.4.15.4.3** (перечни форм по сессиям) слиты СЮДА: ссылки на них из кода
> (`src/generality.test.ts`) и из зонного журнала резолвятся в этот раздел.
Формулировка «гейты проверены живым нарушением» без перечня форм ловушка: она звучит как
машинная гарантия, а покрывает ровно те входы, которые придумал автор. Поэтому граница названа
в обе стороны. **Метод, обязательный к повторению:** формы прогоняются разом одним временным
файлом, ожидание записано против каждой строки, файл уносится после прогона, откат копией
файла, а НЕ `git checkout` (в дереве лежат незакоммиченные правки чужих зон); после отката
дерево зелёное.
**Что ловится — по швам** (здесь классы, потому что список форм растёт, а классы держат).
Зеркало этого списка **что гейт обязан ПРОПУСКАТЬ**: легальные формы перечислены не прозой,
а исполняемым разрешающим списком в самих конфигах `eslint.config.js` (`allowedColorValue`:
`none`/`currentColor`/`inherit`/`transparent`/`var(--`; `dynamicSeam`, где `*.module.css` выведен
из-под запрета глобального CSS; исключения по пути для `src/ui/**` и `src/api/**`, где легальны
примитивы и все восемь транспортов) и `stylelint.config.js` (`colorValues`; `0`, `auto`,
`inherit` не размеры, а их отсутствие). Проверять надо ИХ, а не пересказ:
- **цвет и размер в CSS:** hex во всех записях, включая сокращённую `border`; `rgb()`/`color()`;
системный и именованный цвет в `outline`/`caret-color`/`box-shadow`; `font-size` числом;
`padding`/`margin`/`gap`/`border-radius` литералом; `calc()` намеренно тоже; отключение
правила комментарием, включая **голое** `/* stylelint-disable */` в шапке файла;
- **цвет и размер в TSX:** hex всех четырёх длин, цветовые функции включая `light-dark()`,
именованные и системные цвета в `fill`/`stroke`/`color`, одиннадцать форм инлайнового стиля
(литерал, спред, вынесенная переменная, приведение `as`, свойство объекта, вызов фабрики,
тернарник, вычисляемый ключ, JSX-спред, смешанный литерал), тернарник и шаблонная строка
в значении CSS-переменной, импорт глобального CSS мимо `main.tsx`;
- **сеть:** все восемь транспортов (`fetch` · `EventSource` · `WebSocket` · `XMLHttpRequest` ·
те же через `window`/`globalThis`/`self` · `navigator.sendBeacon`), в `src/api/**` все восемь
проходят;
- **швы импорта:** `react-aria-components` вне `src/ui/` и моки вне `src/api/` статическим
И динамическим импортом. Причина дубля селектором: `no-restricted-imports` разбирает только
ОБЪЯВЛЕНИЕ, а `await import(…)` выражение;
- **структурные тесты:** опечатка в имени класса CSS Module (в том числе через соседний модуль
и через деструктуризацию), опечатка в имени токена в CSS и в данных TS, число в `measures.ts`,
разошедшееся с одноимённым токеном, модуль стилей, который никто не импортирует, маршрут,
забытый в `scripts/shot.mjs`, литерал `lang="zh"` в разметке и `:lang(zh)` в стилях;
- **контракт API:** правка спеки без пере-генерации типов · правка генерённого файла руками ·
битый `$ref` · значение словаря, выпавшее из перечня ветки неизвестного (`tsc` роняет TS2344
«does not satisfy the constraint 'never'») · мажор спеки, разошедшийся с `supportedMajor`
клиента без него спека, бампнутая до 1.x, отвергала бы каждый живой поток в рантайме
вместо падения на сборке.
**Ловушки, найденные исполнением, — не переоткрывать:**
- селектор `Property[key.value=/^--/] > Literal[…]` ловит СОБСТВЕННЫЙ ключ (имя переменной тоже
прямой потомок `Property`) и падал на легальном `style={{ '--progress': x }}`; поэтому в списке
разрешённых значений стоит и префикс `--`;
- обход «объявить свою переменную в модуле (`--local-pad: 13px`) и подставить её» форму обёртки
stylelint принимает его ловит контракт-тест токенов, требующий, чтобы каждое имя `var(--…)`
было объявлено в `tokens.css`. Гейт держит ВТОРЫМ слоем, а не первым;
- контракт-тест токенов собирает имена по кавычкам вокруг `--…`, и флаг движка в доккомментарии
генерённого файла (`` `--verify-bank` ``) для него неотличим от имени токена. Генерённый файл
из скана исключён; остаточная граница честная — обратные кавычки вокруг `--флаг` в АВТОРСКОМ
комментарии всё ещё дадут ложное падение, но падение громкое, а не тихое;
- тип-осведомлённый линт отказывается разбирать `--stdin` («was not found by the project
service») — проверять нарушение только файлом на диске;
- комментарии из CSS снимаются перед проверкой языковой ветки: правило объясняется в том же
файле, где действует, и объяснение содержит запрещённую форму дословно.
**Чего гейты по-прежнему не видят — перечислено, потому что «закрыто» без границ и есть та самая
ловушка, о которой этот раздел** (найдено адверсариальным ревью, каждая граница воспроизведена):
- **соответствие мок-хендлера контракту по ПУТИ.** Тело типизировано (`json<Schemas['X']>`), путь —
обычная строка: опечатка даёт `onUnhandledRequest`, а не падение сборки. В тестах она ловится
(`'error'`), в браузере хендлер просто молчит;
- **страничность нового мира фикстур.** Пагинация проверена на мире `scale`, где страницы
настоящие, но гейта, требующего страничности от каждого нового мира, нет;
- **вычисленное значение цвета.** Гейт ловит литерал и шаблонную строку;
`const c = 'red'; <svg fill={c}/>`, тернарник, вызов функции и JSX-спред проходят. Синтаксически
это не ловится, а типового правила под такую проверку в `typescript-eslint` нет;
- **значение CSS-переменной, собранное вызовом или вынесенное в идентификатор**
(`style={{'--x': compute()}}`, `style={{'--x': value}}`) — проверять статически нечего, а запрет
идентификатора убил бы само исключение;
- **значения стилей в тесте общности.** `generality.test.ts` видит разметку и селекторы, но не
значения: именованная гарнитура письменности в токене (`--font-cjk: 'Noto Sans CJK SC', …`)
действует как `:lang(zh)`, только молча. Ровно такой токен однажды и приехал — снял его человек
на ревью, не гейт; список именованных фейсов в правиле был бы хрупким по построению;
- **императивный стиль** (`node.style.padding = '13px'`, `setAttribute('style', …)`) — в коде
такого нет ни одного случая, но правилом это не запрещено;
- **отключение TSX-гейта комментарием.** Оно обязано называть правило и нести причину, но проходит;
в CSS-половине `reportDisables` роняет даже именованное. Асимметрия записана в комментарии
конфига, а не выдана за симметрию;
- **`width`/`height`/`top`/`left` литералами в CSS:** гейт отступов закрывает
`padding`/`margin`/`gap`/`border-radius`, а собственные размеры компонента (точка 6px,
полоска 24×3) остаются на ревью глазами;
- **ложные срабатывания, которые мы принимаем:** строка вида `#1234` объявляется цветом (это
четырёхзначный hex), а поле объекта с именем `style` — инлайновым стилем. Оба случая лечатся
переименованием и стоят дешевле, чем пропущенный литерал.
## 6. Форма данных — из контракта, не из пересказа
⚠ **Здесь стоял пересказ того, что движок отдаёт фронту, и он отстал от канона.** Пересказ снят:
единственный носитель формы — ратифицированный контракт `docs/architecture/14-api-contract/`
(спека + спутник с провенансом, обоснованиями и К-вопросами). Разделы спутника по номерам:
язык кодом — §2.1, непрозрачные идентификаторы — §2.2, заголовок главы — §2.3, состояние прогона
против выполнения главы — §2.4, прогресс — §2.5, состояние пары — §2.7, подпись банка как набор
решений — §2.9, разрешающий список полей — §2.12, карта «причина → продуктовая фраза» —
приложение А. Цена расхождения замерена на этой же зоне: редакция 0.3.0 сняла пофазный
сплит с провода (К-10 закрыт вердиктом «НЕ строить», D39.138), а здешний абзац ещё учил строить
две пары счётчиков.
Что остаётся правилом ФРОНТА, а не контракта:
1. **Единица пары — edit-unit, ~1.9 на главу** (замер движка, ПТ-21): местами вся глава окажется
одним блоком, и читалка обязана нормально выглядеть в этом случае.
2. **Одна вертикальная прокрутка на читалку.** Список строк-пар, внутри строки
`grid-template-columns: 1fr 1fr`. Синхронизации двух прокруток нет и не будет — колонки
физически не могут разъехаться (`STACK_DECISIONS.md` §2).
3. **Состояние — меткой СЛОВОМ, цвет только дублирует** и только для внимания и отказа:
девять состояний точкой не различаются — проверено снимком, три пары совпадали. Правило
живёт в коде доккомментарием `Tone` (`src/api/vocabulary.ts`), замер — только здесь.
4. **Денежных полей в интерфейсе нет нигде** (§4.8 промта): ни сумм, ни потолков, ни оценки,
ни остатка.
5. **Фикстура ставится в ТРУДНЫЙ случай, а не в удобный.** Правило «прогресс не строить на
„готово N из M“» было записано здесь ещё на S0 — и витрина всё равно вышла с наивным
счётчиком глав, потому что фикстура выдумала поле, которого read-модель не отдаёт. Лечение
сильнее правила: мир фикстур поставлен в состояние, где наивный счётчик дал бы ноль,
и скриншот-цикл смотрит на трудный случай.
---
## 7. Pre-commit хук зоны
Хук один — pre-commit (запрос владельца 02.08; CI в репозитории отсутствует, до его появления
хук — единственный машинный рубеж). Зонный фрагмент `scripts/githooks/pre-commit` зовёт тот же
`npm run check` (один список инструментов, не дубль) только когда в коммите есть frontend-пути;
плюс блок файлов «никогда не коммитить» и блок смеси `frontend/` с чужой зоной (картина
инцидентов D39.88). Ставится сам: `npm install` через `prepare` кладёт диспетчер
в `.git/hooks/pre-commit` (идемпотентно, чужой хук не перетирает). Осознанный обход —
`git commit --no-verify`.
Состав `npm run check` и `check:full` — `STACK_DECISIONS.md` §3 (там он сверен по `package.json`);
копии состава здесь нет намеренно, она отставала на два пака.