textmachine/frontend/docs/FRONTEND_PLAN.md

76 KiB
Raw Permalink Blame History

План фронта — как пишется интерфейс TextMachine

Как пишется фронт: пины, карта src/, правила в проверяемой форме, протокол скриншот-цикла, замеры референса. Правят фронт-сессии; при конфликте с STACK_DECISIONS.md побеждает STACK_DECISIONS (там ратифицированы пины), при конфликте с промтом сессии — промт (там ратифицирован продукт). Статус этапов — frontend-PROGRESS.md, очередь этапов — BACKLOG.md Ф-1.

0. Границы

Зона записи — только frontend/. backend/, platform/, docs/, eval/ — read-only.

0.1. Канон: что читать перед чем

Фронт живёт в проекте с ратифицированным контрактом, и половина ответов на вопросы «как правильно» уже написана — не здесь. Таблица заведена после того, как два дефекта зоны пришли ровно из непрочитанного канона (разбор — frontend-PROGRESS.md, запись про карту канона). Ссылки, а не пересказ — пересказ протухает:

Читать Зачем фронту Когда
../../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, 99103) закрыты и в бэклоге больше не стоят — искать грепом по слову, а не по номеру перед планированием этапа
STACK_DECISIONS.md §5 транспорт до фронта и правила стрима до слоя данных
api-contract/openapi.yaml + компаньон рядом с каноном (../../docs/architecture/14-api-contract/README.md) ратифицированный контракт — форма данных, коды ответов, словари. ⚠ Заменил API_CONTRACT_INPUT.md: тот под баннером «исполнено» и живым входом больше не является до слоя данных и при каждой правке формы
../../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 и ../../platform/docs/platform-PROGRESS.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 в коде фронта появиться не может; если появился — кто-то полез не на тот уровень. Дисциплину стрима, которую обязан выдержать слой данных, задаёт STACK_DECISIONS.md §5 (строкой выше).


1. Пины

Все версии сверены с npm-регистри 02.08.2026 командой npm view <pkg> version. Ставятся точными версиями, без ^ и ~. Колонка «дата» — дата публикации именно этой версии.

Строка «= latest» означает: на 02.08.2026 эта версия и есть последняя опубликованная. Единственное намеренное отставание от latest — TypeScript (см. сноску).

⚠ 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 всё серверное состояние: прогон, книги, банк (мажор и ловушка «v6» — STACK_DECISIONS.md §1). Зачем нам: серверное состояние живёт ровно в одном месте, и поток пишет в тот же кэш, что читает экран — иначе прогресс существовал бы в двух копиях. Рефетч по возврату фокуса оставлен ВКЛЮЧЁННЫМ намеренно: это ровно та гонка, ради которой контракт завёл ревизию, и выключить его значило бы спрятать гонку вместо того, чтобы её обработать
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. Замер, сделанный ДО того, как на нём что-то построили: сервис-воркер 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 (внутри npm run contract): битые $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.tsuseText()/text() поверх @internationalized/string, language.ts — выбор языка и его локаль для I18nProvider и Intl, catalogue.test.ts — сторож языка кода. Второй язык = второй файл того же вида плюс строка в language.ts логика экранов; фикстурная проза (она данные, живёт в mock/)
showcase/ витрина: маршруты /showcase и /scale (тот же экран на настоящем масштабе книги), живой каталог для сверки с references/fleet.png. С S2 здесь же лежит наполнение трёх панелей — оно переедет в features/, когда экраны станут настоящими (S4S7) продуктовые экраны и их запросы

Правило зависимостей — сверху вниз, без обратных рёбер: features/ui/ + api/; shell/ui/; ui/tokens/. api/ не знает про React. i18n/ не зависит ни от кого и виден всем: слова нужны и примитиву, и экрану, и словарю статусов на шве api/ (там лежит КЛЮЧ, а не слово).

2.1. Слои каскада

Ловушка STACK_DECISIONS.md §7.4 — тихие расхождения из-за порядка каскада между стилями сторонних примитивов, токенами и CSS Modules. Порядок объявлен один раз, в шапке reset.css (он импортируется первым, а заявление слоёв обязано стоять раньше любого слоя):

@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-спред закрыт с другой стороны — запрещено само свойство 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() намеренно нет
21 Плоско и скучно: никаких абстракций «на будущее», фабрик и слоёв ради слоёв ревью-вопрос: что этот слой даёт СЕГОДНЯ? Дублирование двух строк лучше преждевременного обобщения — проектная норма «механизм там, где несёт ценность». Машинного гейта нет, и это честно
22 Имена человеческие, без аббревиатур ревью глазами; проектная норма. Машинного гейта нет
20 Числа, которые нужны коду, а не стилям, не заводят вторую истину машинно: src/tokens/measures.ts держит их одним объектом (высота строки для виртуализатора, ширины панелей для react-resizable-panels), а tokens.test.ts сверяет каждое значение с одноимённым токеном. Без этого высота строки существовала бы в двух местах и разъезжалась молча

Контрольный вопрос владельца (§5.1, применять к каждому пакету работ): добавление нового состояния главы или нового вида замечания правит один файл. Правит три — структура неверна, переделать до движения дальше.

Ответ этой структуры (типы приходят из спеки, авторского файла типов у зоны нет): состояние книги живёт одной записью в 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.

У каждого маршрута свой мир фикстур. Список маршрутов выводится из src/api/scenarios.ts и замкнут со вторым списком scripts/shot.mjs тестом src/routes.test.ts; что означает каждый мир — в шапке src/mock/worlds.ts. Прозаической копии списка здесь нет намеренно: она отставала на этап. Каждый маршрут получает свой кадр и свой прогон 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/. Сегодня их одиннадцать (сверено 02.09 по scripts/scenes.mjs, объект scenes; прежняя редакция писала «девять» — два сценария S4 в неё не попали): tabs (модель VS Code + контраст выбранной строки), scroll (keep-alive, Ф-19), drag (залипание полоски), context (сводка замечаний), bank (таблица, спойлер, ширины), hidden (таблица, смонтированная в скрытой вкладке), zoom (раскладка при 125/150/200%), perf (кадры прокрутки, поиска и драга на 1200 терминах), intake (добавление книги), refusals (отказы формы), 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 — думать об этом больше не нужно.

npm i -D playwright
npx playwright install chromium   # БЕЗ --with-deps: он требует sudo и ставит лишнее
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. Замеры референса: действующие числа, способ повторить, что отвергнуто

Прежние §5.55.7 (пере-замеры по сессиям) слиты СЮДА: ссылки на них из кода (tokens.css, reset.css, tokens.test.ts, Panel.tsx, Shell.module.css) и из журналов зоны резолвятся в этот раздел.

Способ, обязательный к повторению. Поверхности, промежутки и полосы — scripts/measure.py (команда и запинованное окружение — §4). Чего он не печатает: общая гистограмма кадра — PIL.Image + collections.Counter по im.getdata(), именованная область — im.crop(box), насыщенные тона — фильтр по colorsys.rgb_to_hls (s > 0.25, 0.12 < l < 0.75). Масштаб снимков 2x подтверждён независимо: все четыре промежутка между панелями равны ровно 16 физическим пикселям, что даёт целые 8 CSS-пикселей.

Поверхности — тон снят с fleet_2.png и antigravity_*.png, двух редакторов, которых владелец назвал эталоном (замечания второго круга 2 и 9):

Что fleet_2.png antigravity_*.png Наш токен
полотно панели #181818 (63.97% кадра) #161616 (18%) --color-panel: #181818
хром (полоса вкладок, верх окна) #292929 (7.17%) #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 — тёплый подтон «по рекомендациям про тёмные фоны» противоречил тем самым кадрам, на которые ссылались. Числа воспроизведены вторым исполнением по references/fleet_2.png и сошлись, то есть --color-panel стоит на замере, а --color-chrome (#242424) остаётся осознанно на ступень спокойнее хрома референса.

Остальные роли — с fleet.png (доля площади по гистограмме кадра 2560×1528):

Роль Значение Подтверждение
фон оболочки #090909 10.45% площади
активная вкладка открытого документа #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

⚠ Текстовый ряд и два акцента в токенах ПОДНЯТЫ против замера ради контраста (#dedede/#9b9b9b/#707070, --color-note, --color-danger — нетекстовый порог 3:1, Ф-52): замер здесь провенанс происхождения, а не действующее значение. Действующие числа — tokens.css.

Геометрия (физические пиксели / 2):

Величина CSS Как замерено
промежуток между панелями 8 четыре среза по 16 физ. без исключений
поле оболочки по краям 8 тот же срез: слева и справа те же 16 физ.
радиус панели 6 угол выходит на прямую за 12 физ.
шапка панели (вкладки) 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

Полосы окна — по пере-замеру, а не по первой модели. Первая модель считала полосы частью области панелей (8 поля + 28 полосы) и держала текст на 4px выше референса. Замер: центр иконок верхней полосы y=17.75 при полосе 0‥36; центр текста статус-полосы y=749.75 при полосе 735.5‥764; поле текста полос 12.5 слева и 13.5 справа от края окна; пилюля «Files» 15‥59 при тексте 25‥52, до следующей ~5px. Отсюда --topbar-height: 36, --statusbar-height: 28, --bar-inset: 12, --tab-inset: 10, а поле оболочки принадлежит области ПАНЕЛЕЙ, а не окну целиком — иначе поле полосы складывается с полем оболочки и текст уезжает вдвое дальше от края. Полная геометрия окна при этом не изменилась: 36 + 836 + 28 = 900, промежутки панелей остались на 0 · 328 · 944 · 1272; после правки текст статус-полосы x 12.5…1267.0, y 744.5…756.0 против 12.5…1266.5, 744.5…756.0 у Fleet.

Типографика (оценка по высоте глифов, точнее не измеряется): интерфейс ~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 не заводим.

Что видно только при --dpr 1. Замечание владельца 17 («иконки шакальные») в кадрах при 2x не видно ВООБЩЕ: полупиксель CSS там ложится в целый пиксель устройства. Поэтому у npm run shot есть ключ --dpr. Причина мыла — сетка, а не толщина: набор нарисован на сетке 24, рисуется размером 16, координата c уезжает в 2c/3, и у геометрических форм (c кратно трём) центр штриха шириной 1px попадает ровно на целый пиксель, размазываясь по двум половинкам. Проверены четыре варианта кадрами 1x: «штрих 2 при 16» и «размер 18, штрих 2» дают жирнее, но по-прежнему мыльно; полупиксельный сдвиг SVG даёт целые линии. Взят он (reset.css).

Сведение нашего кадра с референсом (node scripts/shot.mjs /showcase --size 1280x764, тот же инструмент):

Что fleet.png (референс) Наш /showcase Вердикт
фон оболочки #090909, 10.45% кадра #090909, 10.43% сошлось
полотно панелей #17191a, 80.48% #181818, 75.36% тон по замеру fleet_2; доля ниже, потому что банк в правой панели держит свои цвета
промежутки по горизонтали 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 сошлось (полпикселя — округление среза)

По критерию §5 витрина СТОИТ на референсе: ширины панелей 320 · 608 · 320, шаг строки дерева 26, высота пилюли вкладки 26 совпадают, и «масштабы великоваты» при вьюпорте референса замером не подтверждается — речь про физический размер на конкретном экране (В-7, Ф-36).

ОТВЕРГНУТО — не переоткрывать:

  • тёплый ряд поверхностей #17191a полотном, #27292b приподнятой, #353739 выбранной строкой. Он снят с fleet.png и отменён; ⚠ в §1.1 промта таблицы поверхностей БОЛЬШЕ НЕТ (FRONTEND_SESSION_PROMPT.md греп Таблицы поверхностей здесь БОЛЬШЕ НЕТ) — действующие числа здесь, в §5.1. Тон отверг владелец, и токены стоят на нейтральном ряде fleet_2/antigravity выше. Заодно развелись роли: §1.1 отдавал #353739 разом «выбранной строке» и «активной вкладке», а в кадре это разные вещи — вкладка документа #142f4c, вкладка панели серая, и #353739 под вкладками не встречается вовсе;
  • модель «вырез в хроме» под активную вкладку. Срез по вертикали через активную вкладку fleet_2 (x=790, y=36‥68) даёт #181818 без единой разделительной линии до содержимого, тогда как соседняя неактивная лежит на #292929. Модель по этому замеру была построена и ОТКАЧЕНА по слову владельца: в Tabs.module.css пилюля по fleet.png — полоса вкладок это полотно панели, активная вкладка лежит НА нём скруглённой подложкой (--color-raised, у документа --color-tab-active). Замер верен про сам референс, не про наш код;
  • предел меры набора читалки.Ограничивать ширину набора нельзя — решение владельца 10.08: попытку центрировать колонку фиксированным --reader-width он назвал «жёстко приклеен к центру», токен удалён. Набор идёт во всю ширину панели; на 2560×1440 это ~120 знаков в строке. Функциональную ширину боковых панелей намеренно НЕ переводили в долю кадра: 25% у Fleet это следствие окна 1280, а не инвариант — 640px под дерево разделов были бы пустой ширины.

5.2. Расхождения с референсом, названные вслух

Числа сведения — в §5.1; здесь то, что расходится осознанно, и почему. Из типографики сошлось и там не повторено: высота глифов статус-полосы точно (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. + стоит только в ряду вкладок левой панели и запускает «Добавить книгу» (решение владельца 04.08); в центре и справа его нет — наборы вкладок там фиксированные, и кнопка вышла бы декоративной. Интерактивные состояния снимаются прогоном браузера, а не статичным кадром: --color-raised подтверждён на наведении кнопки, вкладки и строки списка.
  4. Боковые панели — пиксели, центр — доля. Решено в S2 замером и продуктом: боковые получают groupResizeBehavior: "preserve-pixel-size" и стартуют с замеренных 320px, центр — preserve-relative-size. На 1920 всю лишнюю ширину забирает читалка, а справочные колонки остаются той ширины, под которую нарисованы. Библиотека требует хотя бы одну панель с долевым поведением — ею и оказывается центр. Минимум и максимум боковой — новые токены --panel-side-min/--panel-side-max; минимум центра — та же ширина, что у боковой панели (читалка уже боковой панели бессмысленна).
  5. Свёрнутая панель уходит из раскладки вместе со своим разделителем, а не сжимается 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. Гейты: что проверено живым нарушением и чего они не видят

Прежние §5.4.15.4.3 (перечни форм по сессиям) слиты СЮДА: ссылки на них из кода (src/generality.test.ts) и из зонного журнала резолвятся в этот раздел.

Формулировка «гейты проверены живым нарушением» без перечня форм — ловушка: она звучит как машинная гарантия, а покрывает ровно те входы, которые придумал автор. Поэтому граница названа в обе стороны. Метод, обязательный к повторению: формы прогоняются разом одним временным файлом, ожидание записано против каждой строки, файл уносится после прогона, откат — копией файла, а НЕ git checkout (в дереве лежат незакоммиченные правки чужих зон); после отката дерево зелёное.

Что ловится — по швам (здесь классы, потому что список форм растёт, а классы держат). ⚠ Зеркало этого списка — что гейт обязан ПРОПУСКАТЬ: легальные формы перечислены не прозой, а исполняемым разрешающим списком в самих конфигах — eslint.config.js (allowedColorValue: none/currentColor/inherit/transparent/var(--; dynamicSeam, где *.module.css выведен из-под запрета глобального CSS; исключения по пути для src/ui/** и src/api/**, где легальны примитивы и все восемь транспортов) и stylelint.config.js (colorValues; 0, auto, inherit — не размеры, а их отсутствие). Проверять надо ИХ, а не пересказ:

  • цвет и размер в CSS: hex во всех записях, включая сокращённую border; rgb()/color(); системный и именованный цвет в outline/caret-color/box-shadow; font-size числом; padding/margin/gap/border-radius литералом; calc() — намеренно тоже; отключение правила комментарием, включая голое /* stylelint-disable */ в шапке файла;
  • цвет и размер в TSX: hex всех четырёх длин, цветовые функции включая light-dark(), именованные и системные цвета в fill/stroke/color, одиннадцать форм инлайнового стиля (литерал, спред, вынесенная переменная, приведение as, свойство объекта, вызов фабрики, тернарник, вычисляемый ключ, JSX-спред, смешанный литерал), тернарник и шаблонная строка в значении CSS-переменной, импорт глобального CSS мимо main.tsx;
  • сеть: все восемь транспортов (fetch · EventSource · WebSocket · XMLHttpRequest · те же через window/globalThis/self · navigator.sendBeacon), в src/api/** все восемь проходят;
  • швы импорта: react-aria-components вне src/ui/ и моки вне src/api/ — статическим И динамическим импортом. Причина дубля селектором: no-restricted-imports разбирает только ОБЪЯВЛЕНИЕ, а await import(…) — выражение;
  • структурные тесты: опечатка в имени класса CSS Module (в том числе через соседний модуль и через деструктуризацию), опечатка в имени токена в CSS и в данных TS, число в measures.ts, разошедшееся с одноимённым токеном, модуль стилей, который никто не импортирует, маршрут, забытый в scripts/shot.mjs, литерал lang="zh" в разметке и :lang(zh) в стилях;
  • контракт API: правка спеки без пере-генерации типов · правка генерённого файла руками · битый $ref · значение словаря, выпавшее из перечня ветки неизвестного (tsc роняет TS2344 «does not satisfy the constraint 'never'») · мажор спеки, разошедшийся с supportedMajor клиента — без него спека, бампнутая до 1.x, отвергала бы каждый живой поток в рантайме вместо падения на сборке.

Ловушки, найденные исполнением, — не переоткрывать:

  • селектор Property[key.value=/^--/] > Literal[…] ловит СОБСТВЕННЫЙ ключ (имя переменной — тоже прямой потомок Property) и падал на легальном style={{ '--progress': x }}; поэтому в списке разрешённых значений стоит и префикс --;
  • обход «объявить свою переменную в модуле (--local-pad: 13px) и подставить её» форму обёртки stylelint принимает — его ловит контракт-тест токенов, требующий, чтобы каждое имя var(--…) было объявлено в tokens.css. Гейт держит ВТОРЫМ слоем, а не первым;
  • контракт-тест токенов собирает имена по кавычкам вокруг --…, и флаг движка в доккомментарии генерённого файла (`--verify-bank`) для него неотличим от имени токена. Генерённый файл из скана исключён; остаточная граница честная — обратные кавычки вокруг --флаг в АВТОРСКОМ комментарии всё ещё дадут ложное падение, но падение громкое, а не тихое;
  • тип-осведомлённый линт отказывается разбирать --stdin («was not found by the project service») — проверять нарушение только файлом на диске;
  • комментарии из CSS снимаются перед проверкой языковой ветки: правило объясняется в том же файле, где действует, и объяснение содержит запрещённую форму дословно.

Чего гейты по-прежнему не видят — перечислено, потому что «закрыто» без границ и есть та самая ловушка, о которой этот раздел (найдено адверсариальным ревью, каждая граница воспроизведена):

  • соответствие мок-хендлера контракту по ПУТИ. Тело типизировано (json<Schemas['X']>), путь — обычная строка: опечатка даёт onUnhandledRequest, а не падение сборки. В тестах она ловится ('error'), в браузере хендлер просто молчит;
  • страничность нового мира фикстур. Пагинация проверена на мире scale, где страницы настоящие, но гейта, требующего страничности от каждого нового мира, нет;
  • вычисленное значение цвета. Гейт ловит литерал и шаблонную строку; const c = 'red'; <svg fill={c}/>, тернарник, вызов функции и JSX-спред проходят. Синтаксически это не ловится, а типового правила под такую проверку в typescript-eslint нет;
  • значение CSS-переменной, собранное вызовом или вынесенное в идентификатор (style={{'--x': compute()}}, style={{'--x': value}}) — проверять статически нечего, а запрет идентификатора убил бы само исключение;
  • значения стилей в тесте общности. generality.test.ts видит разметку и селекторы, но не значения: именованная гарнитура письменности в токене (--font-cjk: 'Noto Sans CJK SC', …) действует как :lang(zh), только молча. Ровно такой токен однажды и приехал — снял его человек на ревью, не гейт; список именованных фейсов в правиле был бы хрупким по построению;
  • императивный стиль (node.style.padding = '13px', setAttribute('style', …)) — в коде такого нет ни одного случая, но правилом это не запрещено;
  • отключение TSX-гейта комментарием. Оно обязано называть правило и нести причину, но проходит; в CSS-половине reportDisables роняет даже именованное. Асимметрия записана в комментарии конфига, а не выдана за симметрию;
  • width/height/top/left литералами в CSS: гейт отступов закрывает padding/margin/gap/border-radius, а собственные размеры компонента (точка 6px, полоска 24×3) остаются на ревью глазами;
  • ложные срабатывания, которые мы принимаем: строка вида #1234 объявляется цветом (это четырёхзначный hex), а поле объекта с именем style — инлайновым стилем. Оба случая лечатся переименованием и стоят дешевле, чем пропущенный литерал.

6. Форма данных — из контракта, не из пересказа

Здесь стоял пересказ того, что движок отдаёт фронту, и он отстал от канона. Пересказ снят: единственный носитель формы — ратифицированный контракт docs/architecture/14-api-contract/ (спека + спутник с провенансом, обоснованиями и К-вопросами). Разделы спутника по номерам: язык кодом — §2.1, непрозрачные идентификаторы — §2.2, заголовок главы — §2.3, состояние прогона против выполнения главы — §2.4, прогресс — §2.5, состояние пары — §2.7, подпись банка как набор решений — §2.9, разрешающий список полей — §2.12, карта «причина → продуктовая фраза» — приложение А. Цена расхождения замерена на этой же зоне: редакция 0.3.0 сняла пофазный сплит с провода (К-10 закрыт вердиктом «НЕ строить», D39.138), а здешний абзац ещё учил строить две пары счётчиков.

Что остаётся правилом ФРОНТА, а не контракта:

  1. Единица пары — edit-unit, ~1.9 на главу (замер движка, ПТ-21): местами вся глава окажется одним блоком, и читалка обязана нормально выглядеть в этом случае.
  2. Одна вертикальная прокрутка на читалку. Список строк-пар, внутри строки grid-template-columns: 1fr 1fr. Синхронизации двух прокруток нет и не будет — колонки физически не могут разъехаться (STACK_DECISIONS.md §2).
  3. Состояние — меткой СЛОВОМ, цвет только дублирует и только для внимания и отказа: девять состояний точкой не различаются — проверено снимком, три пары совпадали. Правило живёт в коде доккомментарием Tone (src/api/vocabulary.ts), замер — только здесь.
  4. Денежных полей в интерфейсе нет нигде (§4.8 промта): ни сумм, ни потолков, ни оценки, ни остатка.
  5. Фикстура ставится в ТРУДНЫЙ случай, а не в удобный. Правило «прогресс не строить на „готово N из M“» было записано здесь ещё на S0 — и витрина всё равно вышла с наивным счётчиком глав, потому что фикстура выдумала поле, которого read-модель не отдаёт. Лечение сильнее правила: мир фикстур поставлен в состояние, где наивный счётчик дал бы ноль, и скриншот-цикл смотрит на трудный случай.

7. Pre-commit хук зоны

Хук один — 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.

Состав npm run check и check:fullSTACK_DECISIONS.md §3 (там он сверен по package.json); копии состава здесь нет намеренно, она отставала на два пака.