# План фронта — как пишется интерфейс TextMachine > Этап **S0** сессии фронта: план до первой строки кода (`FRONTEND_SESSION_PROMPT.md` §7.2). > Ландится отдельным коммитом. Правят следующие фронт-сессии; при конфликте с > `STACK_DECISIONS.md` побеждает STACK_DECISIONS (там ратифицированы пины), при конфликте > с промтом сессии — промт (там ратифицирован продукт). ## 0. Границы Эта сессия делает **S0 (план) и S1 (инструменты, скриншот-цикл, `tokens.css`, витрина)** и останавливается на витрине. Экраны — S2–S7, по одной сессии на этап (`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): карта актуальности в шапке + живая голова с хвоста, корпус D1–D38 — 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 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/`, когда экраны станут настоящими (S4–S7) | продуктовые экраны и их запросы | Правило зависимостей — сверху вниз, без обратных рёбер: `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-спред `
` со `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`), но ПУТЬ хендлера — обычная строка, и опечатка в нём даёт `onUnhandledRequest`, а не падение сборки; в тестах это ловится (`'error'`), в браузере хендлер просто не сработает. Второе: пагинация проверена на мире `scale`, где страницы настоящие, но гейта, требующего страничности от каждого нового мира фикстур, нет. **Чего гейты по-прежнему не видят — перечислено, потому что «закрыто» без границ и есть та самая ловушка, о которой этот раздел** (найдено адверсариальным ревью S2, каждая граница воспроизведена): - **соответствие мок-хендлера контракту по ПУТИ.** Тело типизировано (`json`), путь — обычная строка: опечатка даёт `onUnhandledRequest`, а не падение сборки. В тестах она ловится (`'error'`), в браузере хендлер просто молчит; - **вычисленное значение цвета.** Гейт на цветных атрибутах ловит литерал и шаблонную строку; `const c = 'red'; `, тернарник, вызов функции и 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), светлая тема, мобильная раскладка.