textmachine/frontend/docs/FRONTEND_PLAN.md

447 lines
48 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) | что движок обязан отдать фронту и чего ещё нет: строки **95** (контракт API) · **99103** (прогресс, манифест, таблица подписи, trace, эмиттер) · **49** (annot-v1) · **54** (масштаб) | перед планированием этапа |
| [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных |
| [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове |
Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности
в шапке + живая голова с хвоста, корпус D1D38 — grep по D-номеру, целиком не читать.
Фронта касаются **D39.81** (SaaS; движок = процесс-на-прогон, сервер в `backend/` не пишется),
**D39.84** (стек + восемь решений владельца + зонные бэклоги), **D39.85** (шов), **D39.88** (git).
### 0.2. Транспорт: с кем фронт разговаривает
**С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс
на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится
(`research/23` §4, §0). Между фронтом и движком стоит платформа, у которой пока ноль строк кода
(`../../platform/BACKLOG.md` П-1).
Проводов два, и их легко перепутать:
| Шов | Формат | Где ратифицировано |
|---|---|---|
| движок → платформа | версионированный **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 (см. сноску).
### Рантайм
| Пакет | Пин | Дата | Зачем именно нам |
|---|---|---|---|
| `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 актуален, «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 | моки сети; пин ставим сейчас, включает S3 |
| `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` | ничего кроме двух глобальных файлов |
| `ui/` | глупые примитивы на токенах: кнопка, поле, вкладки, строка дерева, таблица, выноска. Единственное место, где разрешён импорт `react-aria-components` | запросы, знание о доменных сущностях |
| `shell/` | оболочка: три панели, верхняя полоса, статус-полоса, вкладки | доменная логика экранов |
| `features/` | `books`, `chapters`, `bank`, `reader`, `settings` — по экрану на папку; запрос живёт здесь, на уровне экрана | цвета и размеры мимо токенов |
| `api/` | единственный вход к данным: типы + функции. Сегодня внутри фикстура, на S3 — MSW и асинхронность, потом HTTP и SSE к платформе (§0.2). Единственное место, где легальны импорт из `mock/` и любой сетевой вызов | React-компоненты |
| `mock/` | фикстуры. В день появления API папка удаляется целиком, экраны не трогаются | логика; фикстуры — данные |
| `showcase/` | витрина примитивов: маршрут `/showcase`, живой каталог для сверки с `references/fleet.png` | продуктовые экраны |
Правило зависимостей — сверху вниз, без обратных рёбер:
`features/``ui/` + `api/`; `shell/``ui/`; `ui/``tokens/`. `api/` не знает про React.
### 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-спред `<div {...props} />`, где `style` приезжает внутри объекта; без типовой информации не ловится |
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
добавление нового состояния главы или нового вида замечания правит **один** файл.
Правит три — структура неверна, переделать до движения дальше.
Ответ этой структуры: состояние главы живёт как строка типа в `src/api/types.ts` и
как одна запись в карте отображения `src/features/chapters/`. Цвет индикатора — токен.
Новый вид замечания — запись в карте видов, компонент выноски не трогается.
---
## 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. ждёт `networkidle` и загрузки шрифтов (`document.fonts.ready`) — иначе в кадр попадает
фолбэк-шрифт;
4. кладёт PNG в `frontend/.shots/<маршрут>.png`.
**Что делаю я после команды — обязательная часть цикла, а не опция:** открываю PNG инструментом
чтения файлов, смотрю на него, кладу рядом `references/fleet.png`, называю расхождения словами,
правлю, снимаю снова.
**Стенд без 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).
4. **Наведение и прочие интерактивные состояния** витриной не проверены: снимок статичен.
Токен `--color-raised` подтверждён только на активной вкладке.
5. **Боковые панели фиксированы 320px** (замеренная абсолютная ширина при 1280). Тянущиеся
панели на `react-resizable-panels` — S2; тогда же решится, тянуть их долей или пикселями.
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 { }` он тоже не поддерживает.
- **`@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"` возвращён в разметку.
**Что НЕ ловится и остаётся известной дырой:** `/* eslint-disable */` в TSX (см. правило 2) ·
JSX-спред `<div {...props} />` со `style` внутри объекта · именованные цвета CSS в TSX
(`fill="red"`) — в CSS они запрещены, в TSX ратифицированный список `STACK_DECISIONS.md` §3
их не называет; строка в бэклоге.
---
## 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), светлая тема, мобильная раскладка.