textmachine/frontend/docs/FRONTEND_PLAN.md

356 lines
34 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.
---
## 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/` | единственный вход к данным: типы контракта + функции. Сейчас внутри моки, потом HTTP | React-компоненты |
| `mock/` | фикстуры: книга, главы, пары оригинал/перевод, банк, состояния прогона | логика; фикстуры — данные |
| `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 | Отключить гейт комментарием нельзя | **машинно**: `reportDisables: true` в конфиге stylelint — `/* stylelint-disable */` сам становится ошибкой |
| 3 | Данные только через `src/api/` | ревью-вопрос: есть ли `fetch`/`axios`/импорт из `mock/` вне `src/api/`? Должно быть «нет» |
| 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"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась |
**Контрольный вопрос владельца** (`§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 бессмысленно.
- **Гейты и оба структурных теста проверены живым нарушением, а не заявлением:** литерал цвета
в модуле · литерал в сокращённой записи `border` · `font-size` числом · попытка отключить
правило комментарием · hex в TSX · инлайновый стиль с обычным свойством · импорт глобального
CSS мимо `main.tsx` · опечатка в имени класса. Каждый раз проверка падала, после отката —
зелено. Разрешённое исключение `style={{ '--dot': … }}` проходит.
---
## 6. Что уже известно про движок — учтено в форме данных
Из `FRONTEND_SESSION_PROMPT.md` §9 и `STACK_DECISIONS.md` §8. Влияет на типы в `src/api/`
уже сейчас, поэтому записано в плане, а не откладывается на S3.
1. **Прогресс — пофазный, не «готово N из M».** Юнит становится `done` только когда есть и
черновые строки всех членов, и строка редактуры: во время черновой волны сквозной счётчик
стоит на нуле почти всё время. Тип прогресса — две пары `{ done, total }` (черновик ∥ редактура),
а не одно число. Пользователю всё равно показывается одна полоса без названий фаз (§4.1).
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-хуков нет.
**Не входит в S1** (и не должно появиться раньше срока): экраны, оболочка трёх панелей,
слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда
шкала отступов устоится, иначе мешает подбору), эталонные скриншоты в тестах (отложены
решением `STACK_DECISIONS.md` §3), светлая тема, мобильная раскладка.