textmachine/frontend/docs/FRONTEND_PLAN.md

746 lines
88 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
> Этап **S0** сессии фронта: план до первой строки кода (`FRONTEND_SESSION_PROMPT.md` §7.2).
> Ландится отдельным коммитом. Правят следующие фронт-сессии; при конфликте с
> `STACK_DECISIONS.md` побеждает STACK_DECISIONS (там ратифицированы пины), при конфликте
> с промтом сессии — промт (там ратифицирован продукт).
## 0. Границы
Эта сессия делает **S0 (план) и S1 (инструменты, скриншот-цикл, `tokens.css`, витрина)** и
останавливается на витрине. Экраны — S2S7, по одной сессии на этап (`BACKLOG.md` Ф-1).
Зона записи — только `frontend/`. `backend/`, `platform/`, `docs/`, `eval/` — read-only.
### 0.1. Канон: что читать перед чем
Фронт живёт в проекте с ратифицированным контрактом, и половина ответов на вопросы «как правильно»
уже написана — не здесь. Опыт S0/S1: два дефекта пришли ровно из непрочитанного канона (мокался
не тот уровень контракта; смысл двух вердиктов взят по имени причины вместо доккоммента), оба
нашлись бы за десять минут чтения. Ссылки, а не пересказ — пересказ протухает:
| Читать | Зачем фронту | Когда |
|---|---|---|
| [../../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) | что движок обязан отдать фронту и чего ещё нет. ⚠ **Актуализировано S4 14.08:** строка 95 (контракт API) ЗАКРЫТА ратификацией D39.99, строки 99/100/103 построены (D39.122/131) — таблица ниже больше не подаёт их как «чего нет»; живые для фронта — **101** (машиночитаемая таблица подписи, вход S5) и **169** (экспорт банка) | перед планированием этапа |
| [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). Между фронтом и движком стоит платформа. ⚠ **Актуализировано S4 14.08:**
«у платформы пока ноль строк кода» устарело — P5 принята D39.130, живы вход OIDC, библиотека,
кредитный леджер, раннер и `POST /books`; чего у неё нет, смотреть в `../../platform/BACKLOG.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 в коде фронта появиться не может; если появился —
кто-то полез не на тот уровень. Дисциплина стрима, которую обязан выдержать слой данных:
монотонный `id` + `Last-Event-ID` (докачка после обрыва), heartbeat ~20 с, `EventSource` в браузере
и `Authorization: Bearer` с построчным разбором для десктопа.
---
## 1. Пины
Все версии сверены с npm-регистри **02.08.2026** командой `npm view <pkg> version`.
Ставятся **точными** версиями, без `^` и `~`. Колонка «дата» — дата публикации именно этой версии.
Строка «= latest» означает: на 02.08.2026 эта версия и есть последняя опубликованная.
Единственное намеренное отставание от latest — TypeScript (см. сноску).
**S2 (04.08) поставила три ратифицированных пина, новых не заводила:** `react-aria-components@1.20.0`,
`react-resizable-panels@4.12.2`, `zustand@5.0.14` — версии перепроверены `npm view` в день установки,
все три по-прежнему latest, `npm audit` — 0 уязвимостей. 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 | всё серверное состояние: прогон, книги, банк. Мажор 5 актуален. **Поставлен S3 08.08**, версия ратифицирована ещё в `STACK_DECISIONS` §1 и перепроверена live в день установки — по-прежнему latest, «v6» на npm это svelte-адаптер. Зачем нам: серверное состояние живёт ровно в одном месте, и поток пишет в тот же кэш, что читает экран — иначе прогресс существовал бы в двух копиях. Рефетч по возврату фокуса оставлен ВКЛЮЧЁННЫМ намеренно: это ровно та гонка, ради которой контракт завёл ревизию, и выключить его значило бы спрятать гонку вместо того, чтобы её обработать |
| `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, **установлен 04.08** ради proof-of-form контракта; **в S3 (08.08) развёрнут в полный набор хендлеров и в браузерный воркер.** Замер, сделанный ДО того, как на нём что-то построили: сервис-воркер 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`: битые `$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-спред~~ — закрыта в S2 с другой стороны: запрещено само свойство `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()` намеренно нет |
| 20 | Числа, которые нужны коду, а не стилям, не заводят вторую истину | **машинно**: `src/tokens/measures.ts` держит их одним объектом (высота строки для виртуализатора, ширины панелей для `react-resizable-panels`), а `tokens.test.ts` сверяет каждое значение с одноимённым токеном. Без этого высота строки существовала бы в двух местах и разъезжалась молча |
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
добавление нового состояния главы или нового вида замечания правит **один** файл.
Правит три — структура неверна, переделать до движения дальше.
Ответ этой структуры (⚠ актуализировано S4 14.08 — `src/api/types.ts` удалён ещё в S3, типы
приходят из спеки): состояние книги живёт **одной записью в `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`.
**Маршрутов стало семь, и каждый — свой мир фикстур** (S3): `/showcase` трудный случай ·
`/scale` длинный хвост · `/empty` пусто · `/loading` ожидание · `/error` отказ платформы ·
`/offline` живой поток потерян при рабочих чтениях · `/partial` отказал ОДИН рид при рабочих
остальных. Каждый получает свой кадр и свой прогон 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/`. Сегодня их
**девять**: `tabs` (модель VS Code + контраст выбранной строки), `scroll` (keep-alive, Ф-19),
`drag` (залипание полоски), `context` (сводка замечаний), `bank` (таблица, спойлер, ширины),
`hidden` (таблица, смонтированная в скрытой вкладке), `zoom` (раскладка при 125/150/200%),
`perf` (кадры прокрутки, поиска и драга на 1200 терминах), `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
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. Замеры, перепроверенные в этой сессии
Значения §1.1 промта перепроверены заново по `references/fleet.png` (PIL: гистограмма всего
изображения, срезы строк и столбцов, детект края скругления по порогу яркости).
**Все числа совпали, одна роль разошлась:** §1.1 отдаёт `#353739` и «выбранной строке», и
«активной вкладке», а в кадре это разные вещи — вкладка открытого документа залита `#142f4c`,
вкладка панели `#27292b`, и `#353739` под вкладками не встречается вовсе. В токенах роли
разведены. Ниже — сводка с тем, что удалось доснять; это и есть входные числа `tokens.css`.
Масштаб снимка 2x подтверждён независимо: все четыре промежутка между панелями равны ровно
16 физическим пикселям, что даёт целые 8 CSS-пикселей.
**Поверхности** (доля площади — по гистограмме всего кадра 2560×1528):
| Роль | Значение | Подтверждение |
|---|---|---|
| фон оболочки | `#090909` | 10.45% площади |
| заливка панели | `#17191a` | 80.48% площади |
| приподнятая поверхность, наведение, **активная вкладка панели** | `#27292b` | 18 196 px; вкладки `Files`, `Terminal`, `AI Assistant` |
| выбранная строка дерева, клавиша-чип | `#353739` | 37 374 px; ровно три пятна в кадре, и все три — строка дерева и чипы клавиш |
| **активная вкладка открытого документа** | `#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 |
**Геометрия** (физические пиксели / 2):
| Величина | CSS | Как замерено |
|---|---|---|
| промежуток между панелями | **8** | четыре среза по 16 физ. без исключений |
| поле оболочки по краям | **8** | тот же срез: слева и справа те же 16 физ. |
| радиус панели | **6** | угол выходит на прямую за 12 физ. |
| верхняя полоса | **28** | верхняя полоса фона 72 физ. = 8 поля + 28 полосы |
| статус-полоса | **20** | нижняя полоса фона 56 физ. = 20 полосы + 8 поля |
| шапка панели (вкладки) | **26** | заливка вкладки `Files` 86..137 физ. |
| шаг строки дерева | **26** | шаг текстовых полос: 52 физ., 15 строк подряд |
| подложка выбранной строки | **24** | заливка `#353739` — 48 физ. при шаге 52 |
| межстрочный интервал содержимого | **21** | подсветка текущей строки 42 физ. |
| ширина боковой панели при 1280 | **320** (25%) | границы панелей: 8‥328 · 336‥944 · 952‥1272 |
| внутренний отступ панели | **6** | подложка строки 14‥321.5 при панели 8‥328 |
Модель раскладки, вытекающая из замеров и сходящаяся точно (764 = 8+28 + 700 + 20+8):
поле оболочки 8 · промежуток 8 · верхняя полоса 28 · статус-полоса 20.
**Типографика** (оценка по высоте глифов, точнее не измеряется):
интерфейс ~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` не заводим.
### 5.2. Витрина сведена с референсом — что сошлось и что нет
Витрина снята тем же способом, что и референс (`--size 1280x764`, 2x), и промерена тем же кодом.
**Сошлось до пикселя:** промежутки — четыре по 16 физ.; верхний край панелей — 72 физ.;
радиус — угол выходит на прямую за 12 физ.; шаг строки дерева — 26 CSS; нижняя полоса — 56 физ.;
доли площади поверхностей (панель 83.5% против 80.5%, фон 9.4% против 10.5% — разница от того,
что у нас другое наполнение). Высота глифов статус-полосы совпала точно (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. ~~**`+` в конце рядов вкладок** у Fleet есть, у нас нет~~**закрыто в S2 (04.08).** `+` стоит
в ряду вкладок левой панели и запускает «Добавить книгу» (решение владельца 04.08); в центре
и справа его нет, потому что наборы вкладок там фиксированные и кнопка вышла бы декоративной.
4. ~~**Наведение и прочие интерактивные состояния** витриной не проверены~~**закрыто в S2:**
состояния сняты прогоном браузера, а не статичным кадром. `--color-raised` подтверждён
на наведении кнопки, вкладки и строки списка.
5. **Боковые панели — пиксели, центр — доля.** Решено в S2 замером и продуктом: боковые получают
`groupResizeBehavior: "preserve-pixel-size"` и стартуют с замеренных 320px, центр —
`preserve-relative-size`. На 1920 всю лишнюю ширину забирает читалка, а справочные колонки
остаются той ширины, под которую нарисованы. Библиотека требует хотя бы одну панель с долевым
поведением — ею и оказывается центр. Минимум и максимум боковой — новые токены
`--panel-side-min`/`--panel-side-max`; минимум центра — та же ширина, что у боковой панели
(читалка уже боковой панели бессмысленна).
6. **Свёрнутая панель уходит из раскладки вместе со своим разделителем**, а не сжимается
`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. Что именно проверено живым нарушением
Формулировка «гейты проверены живым нарушением» без перечня форм — ловушка: она звучит как
машинная гарантия, а покрывает ровно те входы, которые придумал автор. Ниже полный список
проверенных форм; каждая роняла проверку, после отката дерево зелёное.
**CSS:** hex-литерал в модуле · hex в сокращённой записи `border` · `rgb()` · `color()`
в сокращённой записи · `font-size` числом · именованное отключение правила
(`/* stylelint-disable color-no-hex */`) · **голое** `/* stylelint-disable */` в шапке файла.
**TSX:** hex всех четырёх длин (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) · цветовые функции
включая `color()` и `light-dark()` · одиннадцать форм инлайнового стиля (литерал, спред,
вынесенная переменная, приведение `as`, свойство объекта, вызов фабрики, тернарник,
вычисляемый ключ, JSX-спред, смешанный литерал) · импорт глобального CSS мимо `main.tsx` ·
предупреждение линта при `--max-warnings 0`.
**Сеть (все восемь форм ловятся, в `src/api/**` все восемь проходят):** `fetch` · `EventSource` ·
`WebSocket` · `XMLHttpRequest` · `window.fetch` · `globalThis.fetch` · `self.fetch` ·
`navigator.sendBeacon`. Проверялось файлом на диске, а не через `--stdin`: тип-осведомлённый
линт отказывается разбирать stdin («was not found by the project service»).
**Структурные тесты:** опечатка в имени класса CSS Module · то же при переименованном импорте
модуля · опечатка в имени токена в CSS · опечатка в имени токена в данных TS · маршрут добавлен
в `routes.tsx` и забыт в `scripts/shot.mjs` · литерал `lang="zh"` возвращён в разметку.
**Что НЕ ловилось и осталось дырой после S1:** `/* eslint-disable */` в TSX (см. правило 2) ·
JSX-спред `<div {...props} />` со `style` внутри объекта · именованные цвета CSS в TSX
(`fill="red"`). Две последние закрыты в S2 (Ф-9), первая закрыта правилами
`@eslint-community/eslint-comments` ещё в S1 — см. правило 2.
### 5.4.1. Что добавила S2 (04.08) — перечень форм
Гейты прогонялись разом: один временный файл со всеми формами, один прогон линтера, ожидание
записано против каждой строки. Файлы уносились после прогона, дерево после отката зелёное.
**Цвет в TSX (Ф-9), 15 форм.** Падают: именованный цвет в `fill` · именованный цвет в `color`
иконки · системный цвет в `stroke` через выражение · цвет в CSS-переменной `style={{'--x':'red'}}` ·
системный цвет там же · размер литералом там же (`'10px'`) · hex-литерал · цветовая функция ·
обычное свойство в инлайновом стиле · `{ style: {...} }` в объектном литерале · `{ 'style': {...} }`
строковым ключом · импорт `react-aria-components` вне `src/ui/`.
Проходят (и обязаны): `fill="none"` · `fill="currentColor"` · число в CSS-переменной
(`'--progress': 0.5`) · токен в CSS-переменной (`var(--space-4)`).
⚠ Ловушка, найденная исполнением: селектор `Property[key.value=/^--/] > Literal[…]` ловил
СОБСТВЕННЫЙ ключ (имя переменной — тоже прямой потомок Property) и падал на легальном
`style={{ '--progress': x }}`. Поэтому в списке разрешённых значений стоит и префикс `--`.
**Отступы и системные цвета в CSS (Ф-4, Ф-9), 14 форм.** Падают: `padding: 8px` ·
`margin: 0 4px` · `gap: 4px` · `border-radius: 6px` · `border-top-left-radius: 4px` ·
`calc(var(--space-4) + 2px)` · системный цвет в `outline` · в `caret-color` · в `box-shadow` ·
именованный цвет · hex в сокращённой записи `border`.
Проходят: `padding: var(--space-4)` · `padding-top: 0` · `margin-left: auto`.
⚠ **Обход, найденный адверсариальным ревью и НЕ прошедший:** объявить в модуле свою переменную
(`--local-pad: 13px`) и подставить её (`padding: var(--local-pad)`). Форму обёртки stylelint
принимает — но падает контракт-тест токенов, который требует, чтобы каждое имя `var(--…)` было
объявлено в `tokens.css`. То есть гейт держит вторым слоем, а не первым; проверено исполнением.
**Структурные тесты (Ф-10), 7 форм — каждая роняет `vitest`.** Опечатка в классе у компонента,
импортирующего СОСЕДНИЙ модуль (прежде выпадала из проверки) · опечатка в классе через
деструктуризацию `const { x } = styles` · расширение из `url(./x.woff2)`, притворяющееся
объявленным классом · новый токен, не попавший ни в замеры, ни в подобранные · токен, оставшийся
в списке подобранных после удаления из `tokens.css` · число в `measures.ts`, разошедшееся
с одноимённым токеном · модуль стилей, который никто не импортирует.
**Хвосты ревью оркестратора (04.08), 11 форм — один прогон линтера, 7 падений и 4 прохода.**
Падают: динамический `import('../mock/book')` · динамический `import('../tokens/tokens.css')` ·
динамический `import('react-aria-components')` вне `src/ui/` · то же из `src/api/` (фикстуры ему
можно, примитивы — нет) · динамический импорт моков из `src/ui/` · тернарник в значении
CSS-переменной (`{'--x': flag ? 'red' : 'blue'}`) · шаблонная строка там же.
Проходят (и обязаны): динамический импорт `*.module.css` · идентификатор в значении переменной ·
`import('react-aria-components')` внутри `src/ui/` · `import('../mock/book')` внутри `src/api/`.
Причина всей пачки одна: `no-restricted-imports` разбирает только ОБЪЯВЛЕНИЕ импорта, а
`await import(…)` — выражение, поэтому каждый шов продублирован селектором и точечные послабления
у обеих половин совпадают по имени шва.
**Языковая ветка в CSS (`generality.test.ts`, вторая половина).** Падает `:lang(zh) .source { … }`
в любом файле стилей — проверено живым нарушением с восстановлением файла из памяти, не из git.
Комментарии из CSS снимаются перед проверкой: правило объясняется в том же файле, где действует,
и объяснение содержит запрещённую форму дословно.
### 5.4.2. Замки контракта API (04.08) — четыре формы, каждая проверена нарушением
Гейт контракта — шестой шаг `npm run check` плюс тесты `src/api/contract.test.ts`.
Каждая форма роняла проверку, после отката дерево зелёное; откат — копией файла, не `git`.
Падают: **правка спеки без пере-генерации типов** (в `openapi.yaml` добавлено поле, тест дрифта
сравнил свежую генерацию с закоммиченной и упал) · **правка генерённого файла руками**
(в `schema.ts` вписан лишний тип — тот же тест) · **битый `$ref` в спеке**
(`invalid-ref` от spectral, `npm run contract` = 1) · **значение словаря, выпавшее из перечня
ветки неизвестного** (`awaiting_bank` убран из `bookStatuses` → `tsc` роняет TS2344
«Type '"awaiting_bank"' does not satisfy the constraint 'never'»).
⚠ Ложное срабатывание, найденное этим же заходом и починенное по классу: контракт-тест токенов
собирает имена по кавычкам вокруг `--…`, и флаг движка в доккомментарии генерённого файла
(`` `--verify-bank` ``) для него неотличим от имени токена. Генерённый файл из скана исключён
с названной причиной; остаточная граница честная — обратные кавычки вокруг `--флаг`
в АВТОРСКОМ комментарии всё ещё дадут ложное падение, но падение громкое, а не тихое.
### 5.4.3. Замки слоя данных (S3, 08.08) — семь форм, каждая проверена нарушением
Прогон одним заходом, откат — копией файла, не `git checkout` (в дереве лежат незакоммиченные
правки чужих зон). После отката `npm run check` зелёный.
**Падают:**
1. **Значение словаря выброшено из `src/api/vocabulary.ts`** — `tsc` роняет `TS2345`, называя
пропавшее значение по имени (`Property 'paused' is missing`). Это и есть механизм «новое
состояние правит ОДИН файл»: словарь объявлен как `Record<Значение, Смысл>`, поэтому список
значений выводится из записей и разойтись с типом физически не может.
2. **Спека правлена без пере-генерации типов** — тест дрифта (`contract.test.ts`).
3. **Мажор спеки разошёлся с мажором, который говорит клиент** — новый тест: `info.version`
спеки читается из файла и сверяется с `supportedMajor` в `stream.ts`. Без него спека, бампнутая
до 1.x, отвергала бы каждый живой поток в рантайме вместо падения на сборке.
4. **Новый маршрут в `routes.tsx`, забытый в `scripts/shot.mjs`** — `routes.test.ts`.
5. **`EventSource` прямо в экране** — `no-restricted-globals` с проектным сообщением.
6. **`window.fetch` прямо в экране** — `no-restricted-properties`.
7. **Импорт `../mock/*` в экране, статический И динамический** — `no-restricted-imports` плюс
селектор на `ImportExpression`.
**Проходят (и обязаны):** `new EventSource(...)` внутри `src/api/**`.
**Что этот слой гейтом НЕ закрыт, названо честно:** соответствие MSW-хендлеров контракту держится
типами тел (`json<Schemas['X']>`), но ПУТЬ хендлера — обычная строка, и опечатка в нём даёт
`onUnhandledRequest`, а не падение сборки; в тестах это ловится (`'error'`), в браузере хендлер
просто не сработает. Второе: пагинация проверена на мире `scale`, где страницы настоящие, но
гейта, требующего страничности от каждого нового мира фикстур, нет.
**Чего гейты по-прежнему не видят — перечислено, потому что «закрыто» без границ и есть та самая
ловушка, о которой этот раздел** (найдено адверсариальным ревью S2, каждая граница воспроизведена):
- **соответствие мок-хендлера контракту по ПУТИ.** Тело типизировано (`json<Schemas['X']>`), путь —
обычная строка: опечатка даёт `onUnhandledRequest`, а не падение сборки. В тестах она ловится
(`'error'`), в браузере хендлер просто молчит;
- **вычисленное значение цвета.** Гейт на цветных атрибутах ловит литерал и шаблонную строку;
`const c = 'red'; <svg fill={c}/>`, тернарник, вызов функции и JSX-спред проходят. Синтаксически
это не ловится, а типовое правило под такую проверку в `typescript-eslint` отсутствует;
- **значение CSS-переменной, собранное вызовом или вынесенное в идентификатор**
(`style={{'--x': compute()}}`, `style={{'--x': value}}`) — проверять статически нечего, а запрет
идентификатора убил бы само исключение. Тернарник и шаблонная строка с 04.08 ловятся: это
носители литерала, и обход через них был буквальным;
- **значения стилей в тесте общности.** `generality.test.ts` видит разметку и селекторы, но не
значения: именованная гарнитура письменности в токене (`--font-cjk: 'Noto Sans CJK SC', …`)
действует как `:lang(zh)`, только молча. Ровно такой токен и приехал в S2 — снял его человек
на ревью, не гейт; список именованных фейсов в правиле был бы хрупким по построению;
- **императивный стиль** (`node.style.padding = '13px'`, `setAttribute('style', …)`) — в коде
такого нет ни одного случая, но правилом это не запрещено;
- **отключение TSX-гейта комментарием.** Оно обязано называть правило и нести причину, но проходит;
в CSS-половине `reportDisables` роняет даже именованное. Асимметрия честно записана в комментарии
конфига, а не выдана за симметрию, как было раньше;
- **`width`/`height`/`top`/`left` литералами в CSS:** Ф-4 по STACK_DECISIONS §3 закрывает
`padding`/`margin`/`gap`/`border-radius`, а собственные размеры компонента (точка 6px,
полоска 24×3) остаются на ревью глазами;
- **ложные срабатывания, которые мы принимаем:** строка вида `#1234` объявляется цветом (это
четырёхзначный hex), а поле объекта с именем `style` — инлайновым стилем. Оба случая лечатся
переименованием и стоят дешевле, чем пропущенный литерал.
### 5.5. Пере-замер S3.5 (09.08) — что нашёл замер, а не глаз
Замечания владельца 1 и 5 требовали работать замером. Инструмент замера теперь живёт в репозитории
и воспроизводим: `python3 scripts/measure.py references/fleet.png .shots/showcase.png` печатает
доли поверхностей, промежутки по горизонтали и полосы по вертикали в CSS-пикселях.
**Что сошлось при вьюпорте референса 1280×764** (прямое наложение, оба кадра при 2x):
промежутки — четыре по 8 CSS ровно на тех же координатах (0, 328, 944, 1272); ширины панелей
320 · 608 · 320; шаг строки дерева 26; высота пилюли вкладки 26. То есть по критерию §5 витрина
СТОИТ на референсе, и «масштабы великоваты» при вьюпорте референса замером не подтверждается.
**Что разошлось и было исправлено:**
| Что | Замер Fleet | Было у нас | Стало |
|---|---|---|---|
| верхняя полоса | центр иконок y=17.75 при полосе 0‥36 — полоса прижата к краю окна | центр 22: 8 поля + 28 полосы | `--topbar-height: 36`, поле оболочки только по бокам |
| статус-полоса | центр текста y=749.75 при полосе 735.5‥764 | центр 745.5, под текстом пустая полоса 8px | `--statusbar-height: 28`, текст по центру полосы |
| поле текста полос | 12.5 слева, 13.5 справа ОТ КРАЯ ОКНА | 14 (8 поля оболочки + 6) | поле оболочки перенесено на область панелей, полосы полнокровные, `--bar-inset: 12` |
| поля и промежутки вкладок | пилюля «Files» 15‥59 при тексте 25‥52, до следующей ~5px | поле 8, промежуток 2 | `--tab-inset: 10`, промежуток `--space-3` |
Полная геометрия окна при этом не изменилась: 36 + 836 + 28 = 900, промежутки панелей остались
на 0 · 328 · 944 · 1272. Это и есть ответ на «особенно заметно по тексту снизу»: полосы у Fleet
прижаты к краям окна, а не отступают от них на поле оболочки, и текст в них центрируется по всей
полосе. Поле оболочки поэтому принадлежит области ПАНЕЛЕЙ, а не окну целиком — иначе поле полосы
складывается с полем оболочки, и текст уезжает вдвое дальше от края, чем в референсе (эту ошибку
первый заход S3.5 и совершил: было 14, стало бы 20 при цели 12.5; поймало адверсариальное ревью).
После правки замер сходится с референсом по обеим осям: текст статус-полосы `x 12.5…1267.0`,
`y 744.5…756.0` против `12.5…1266.5`, `744.5…756.0` у Fleet.
**Что видно только при `--dpr 1`.** Замечание 17 («иконки шакальные») в кадрах при 2x не видно
ВООБЩЕ: полупиксель CSS там ложится в целый пиксель устройства. Поэтому у `npm run shot` появился
ключ `--dpr`. Причина мыла — сетка, а не толщина: набор нарисован на сетке 24, рисуется размером 16,
координата c уезжает в 2c/3, и у геометрических форм (c кратно трём) центр штриха шириной 1px
попадает ровно на целый пиксель, размазываясь по двум половинкам. Проверены четыре варианта кадрами
1x: «штрих 2 при 16» и «размер 18, штрих 2» дают жирнее, но по-прежнему мыльно; полупиксельный
сдвиг SVG даёт целые линии. Взят он (`reset.css`).
**Мера набора читалки.** На кадре 2560×1440 строка перевода вырастала до ~150 знаков: боковые панели
держат функциональную ширину в пикселях, и вся лишняя ширина доставалась центру. S3.5 ввела
`--reader-width: 1040px` с центрированием блоков — ~75 знаков в колонке. ⚠ **Отменено 10.08:**
владелец назвал это «жёстко приклеен к центру», токен удалён, набор снова во всю ширину панели;
на 2560×1440 это ~120 знаков в строке. Ограничивать ширину повторно нельзя — решение владельца. Функциональную ширину
боковых панелей намеренно НЕ переводили в долю кадра: 25% у Fleet это следствие окна 1280, а
не инвариант — 640px под дерево разделов были бы пустой ширины.
---
### 5.6. Замер палитры S3.6 (10.08) — тон снят с референсов, а не подобран
Владелец отверг тёплый подтон S3.5 и назвал эталоном два редактора разом: Fleet (`fleet_2.png`,
докинут 10.08) и Antigravity. Тон снят с их кадров гистограммой (`PIL`, счёт точек по всему
кадру плюс срезы именованных областей), а не выбран глазом:
| Что | fleet_2.png | antigravity_*.png | Наш токен |
|---|---|---|---|
| полотно панели | `#181818` (64% кадра) | `#161616` (18%) | `--color-panel: #181818` |
| хром (полоса вкладок, верх окна) | `#292929` (7%) | `#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**. То есть тёплый подтон,
внесённый S3.5 «по рекомендациям про тёмные фоны», противоречил тем самым кадрам, на которые
сессия ссылалась. Второй вывод — про вкладки: срез по вертикали через активную вкладку fleet_2
(x=790, y=36‥68) даёт `#181818` без единой разделительной линии до содержимого, тогда как
соседняя неактивная лежит на `#292929`.
⚠ **Поправка S3.7 (10.08): вывод про вкладки в коде НЕ стоит.** Модель «вырез в хроме» была
построена по этому замеру и тем же паком ОТКАЧЕНА по слову владельца — в `Tabs.module.css`
сегодня пилюля по `fleet.png`: полоса вкладок это полотно панели, активная вкладка лежит НА нём
скруглённой подложкой (`--color-raised`, у документа `--color-tab-active`). Замер остаётся верным
про сам референс; описание кода в этом абзаце было неверным.
Способ повторить (проверено исполнением 10.08 в S3.7, числа сошлись): `scripts/measure.py` для
поверхностей и промежутков, `PIL.Image` + `collections.Counter` по `im.getdata()` для общей
гистограммы и `im.crop(box)` для именованной области; насыщенные тона отбираются фильтром
по `colorsys.rgb_to_hls` (s > 0.25, 0.12 < l < 0.75). Окружение python запинено —
`scripts/requirements.txt` (в системном python на стенде Pillow нет вовсе).
### 5.7. Пере-замер S3.7 (10.08) — палитра воспроизведена, кадры сведены
Референс `fleet_2.png` докинут владельцем при приёмке S3.6, и палитра S3.6 впервые проверена
не «со слов»: `.tooling/py/bin/python scripts/measure.py references/fleet_2.png` даёт **`#181818`
63.97% кадра** и **`#292929` 7.17%** — ровно те числа, на которые ссылается §5.6, то есть
`--color-panel` стоит на замере, а `--color-chrome` (`#242424`) остаётся осознанно на ступень
спокойнее хрома референса.
Наши кадры при вьюпорте референса — `node scripts/shot.mjs /showcase --size 1280x764` — и тот же
инструмент:
| Что | fleet.png (референс) | Наш `/showcase` | Вердикт |
|---|---|---|---|
| фон оболочки | `#090909`, 10.45% кадра | `#090909`, 10.43% | сошлось |
| полотно панелей | `#17191a`, 80.48% | `#181818`, 75.36% | тон по замеру S3.6; доля ниже, потому что банк в правой панели держит свои цвета |
| промежутки по горизонтали | 0 · 328 · 944 · 1272, ширина 8 | 0 · 316 · 644 · 1272, ширина 8 | **ширина та же, границы сдвинуты осознанно:** правая панель шире (`--panel-context-width: 620`), в ней таблица, а не список |
| линия в 1px на x=515 | — | есть | не дефект: жёлоб читалки покрашен в цвет оболочки (замечание второго круга 5) |
| полосы по вертикали | верхняя 0‥36, статус 735.5‥764 | 0‥37 и 735‥764 | сошлось (полпикселя — округление среза) |
То есть за два пака поехали ровно те числа, которые двигали намеренно, и ни одного сверх того;
разошлось бы что-то ещё — эта таблица и была бы местом, где это видно.
## 6. Что уже известно про движок — учтено в форме данных
Из `FRONTEND_SESSION_PROMPT.md` §9 и `STACK_DECISIONS.md` §8. Влияет на типы в `src/api/`
уже сейчас, поэтому записано в плане, а не откладывается на S3.
1. **Прогресс — пофазный, не «готово N из M».** Юнит становится `done` только когда есть и
черновые строки всех членов, и строка редактуры: во время черновой волны сквозной счётчик
стоит на нуле почти всё время. Тип прогресса — две пары `{ done, total }` (черновик ∥ редактура),
а не одно число. Пользователю всё равно показывается одна полоса без названий фаз (§4.1).
⚠ Это правило было записано здесь ещё в S0 — и витрина S1 всё равно вышла с наивным
счётчиком глав, потому что фикстура выдумала поле `translatedChapters`, которого read-модель
не отдаёт. Лечение сильнее правила: фикстура поставлена в состояние «идёт черновая волна»,
где наивный счётчик дал бы ноль, и скриншот-цикл видит трудный случай, а не удобный.
1а. **Состояние — у ПРОГОНА над книгой, у главы его нет.** Подпись банка — один стоп между
черновой и редакторской волнами на всю книгу (`mining.go:199`), поэтому «глава ждёт подписи,
пока соседняя финализируется» — картина, которую движок породить не может. У главы есть
выполнение: `ChapterPassport` (`status.go:37-55`) несёт `units_total/done/in_progress/pending`
и вердикт. В интерфейсе: лестница из девяти состояний — на строке книги, полоса выполнения —
на строке главы. Девять состояний точкой не различаются (проверено снимком: три пары
совпадали) — метка словом, цвет только дублирует и только для внимания и отказа.
1б. **Язык — код, а не слово.** Движок держит `source_lang`/`target_lang` кодами (`book.go:26-27`),
ключ пары — `zh-ru`; контракт отдаст то же самое. Человеческое имя считается на экране
через `Intl.DisplayNames`, `lang` на элементе берётся из данных книги (правило 17).
2. **Подпись термина — не UPDATE строки.** Банк пересобирается из файлов на каждом прогоне,
прямая запись стирается следующим прогоном. Контракт подписи — отправка набора решений, а не
правка записи; фикстуры S3 моделируют именно это.
3. **Байтовых смещений у замечаний нет.** Замечание адресует блок или главу — и только.
Тип замечания не имеет полей `offset`/`length`; подчёркиваний внутри текста не будет.
4. **Единица пары — edit-unit, ~1.9 на главу.** Местами вся глава окажется одним блоком.
Читалка обязана нормально выглядеть в этом случае; выравнивание грубое, по единицам экспорта.
5. **Одна вертикальная прокрутка на читалку.** Список строк-пар, внутри строки
`grid-template-columns: 1fr 1fr`. Синхронизации двух прокруток нет и не будет — колонки
физически не могут разъехаться.
Денежных полей в интерфейсе нет нигде (§4.8): ни сумм, ни потолков, ни оценки, ни остатка.
---
## 7. Порядок S1 и что в него НЕ входит
Порядок работ S1: каркас → `npm run check` → скриншот-цикл и проверка, что он живой →
`tokens.css` → витрина → контракт-тест токенов → сверка витрины с референсом.
`npm run check` = `prettier --check` → `eslint` → `stylelint` → `tsc --noEmit` → `vitest run`.
Шагов пять, а не четыре: `STACK_DECISIONS.md` §3 перечисляет четыре, но там же требует, чтобы
обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint.
`npm run check:full` = `check` → `vite build` → `shot`. Отдельного e2e-набора в S1 нет:
единственная браузерная проверка — скриншот-цикл, он и стоит в `check:full`.
Git-хук один — 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`.
**Не входит в S1** (и не должно появиться раньше срока): экраны, оболочка трёх панелей,
слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда
шкала отступов устоится, иначе мешает подбору), эталонные скриншоты в тестах (отложены
решением `STACK_DECISIONS.md` §3), светлая тема, мобильная раскладка.