textmachine/frontend/docs/FRONTEND_PLAN.md

48 KiB
Raw Blame History

План фронта — как пишется интерфейс 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 зоны, гардрейлы, git-протокол (коммит только pathspec-формой) первым делом
../../docs/README.md карта: где что лежит и что чем перекрыто первым делом
../../docs/product-requirements.md реестр ПТ-1..ПТ-34; ПТ-33 и ПТ-34 — жёсткие инварианты интерфейса, ПТ-21 задаёт якорь чтения до первого экрана
../../docs/research/23-engine-platform-seam.md форма шва движок↔платформа; docs/README.md требует читать его перед любым кодом стыка до любого кода данных
CURRENT-STATE + единый бэклог в ../../docs/PROGRESS.md что движок обязан отдать фронту и чего ещё нет: строки 95 (контракт API) · 99103 (прогресс, манифест, таблица подписи, trace, эмиттер) · 49 (annot-v1) · 54 (масштаб) перед планированием этапа
STACK_DECISIONS.md §5 транспорт до фронта и правила стрима до слоя данных
../../docs/glossary.md жаргон проекта (D-номер, банк, голден, юнит) при первом непонятном слове

Правило чтения 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 (он импортируется первым, а заявление слоёв обязано стоять раньше любого слоя):

@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 — думать об этом больше не нужно.

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 --checkeslintstylelinttsc --noEmitvitest run. Шагов пять, а не четыре: STACK_DECISIONS.md §3 перечисляет четыре, но там же требует, чтобы обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint. npm run check:full = checkvite buildshot. Отдельного 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), светлая тема, мобильная раскладка.