75 KiB
План фронта — как пишется интерфейс 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, 99–103) закрыты и в бэклоге больше не стоят — искать грепом по слову, а не по номеру | перед планированием этапа |
| 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: карта актуальности
в шапке + живая голова с хвоста, корпус D1–D38 — 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.ts — useText()/text() поверх @internationalized/string, language.ts — выбор языка и его локаль для I18nProvider и Intl, catalogue.test.ts — сторож языка кода. Второй язык = второй файл того же вида плюс строка в language.ts |
логика экранов; фикстурная проза (она данные, живёт в mock/) |
showcase/ |
витрина: маршруты /showcase и /scale (тот же экран на настоящем масштабе книги), живой каталог для сверки с references/fleet.png. С S2 здесь же лежит наполнение трёх панелей — оно переедет в features/, когда экраны станут настоящими (S4–S7) |
продуктовые экраны и их запросы |
Правило зависимостей — сверху вниз, без обратных рёбер:
features/ → ui/ + api/; shell/ → ui/; ui/ → tokens/. api/ не знает про React.
i18n/ не зависит ни от кого и виден всем: слова нужны и примитиву, и экрану, и словарю статусов
на шве api/ (там лежит КЛЮЧ, а не слово).
2.1. Слои каскада
Ловушка STACK_DECISIONS.md §7.4 — тихие расхождения из-за порядка каскада между стилями
сторонних примитивов, токенами и CSS Modules. Порядок объявлен один раз, в шапке reset.css
(он импортируется первым, а заявление слоёв обязано стоять раньше любого слоя):
@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.
Что делает:
- собирает и поднимает
vite preview(сборка, а не dev — dev-оверлеи не должны попадать в кадр); - запускает Chromium из Playwright, вьюпорт 1440×900 (базовая ширина из §4.5 промта),
deviceScaleFactor: 2— чтобы снимок был сравним с референсом, снятым на macOS при 2x;--size 1280x764даёт вьюпорт самого референса, когда нужно прямое наложение; - ждёт готовности экрана и загрузки шрифтов (
document.fonts.ready) — иначе в кадр попадает фолбэк-шрифт; - кладёт 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/. Сегодня их девять:tabs(модель VS Code + контраст выбранной строки),scroll(keep-alive, Ф-19),drag(залипание полоски),context(сводка замечаний),bank(таблица, спойлер, ширины),hidden(таблица, смонтированная в скрытой вкладке),zoom(раскладка при 125/150/200%),perf(кадры прокрутки, поиска и драга на 1200 терминах),overlays(модальные окна).
Стенд без root. .tooling/ собирается один раз и в репозиторий не едет (вместе с .shots/
он в .gitignore). Chromium не стартует, пока не найдёт libnss3, libnssutil3, libnspr4,
libasound.so.2; в системе их нет, а sudo недоступен, поэтому пакеты выкачиваются и
распаковываются локально. Тем же способом кладётся шрифт CJK: на стенде не установлено ни одного
(fc-list :lang=zh пуст), и без него весь китайский текст в кадре — квадраты, а плотность колонок
проверить нечем. scripts/shot.mjs сам подставляет LD_LIBRARY_PATH и XDG_DATA_HOME, когда
видит .tooling/root — думать об этом больше не нужно.
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.5–5.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 физ.
- Субпиксельное сглаживание. У Fleet текст сглажен в серую шкалу (macOS): в статус-полосе
ровно 0% цветных пикселей. У нас в том же месте 1.29%, в дереве 1.35% против 0.37% —
это цветная бахрома Chromium.
-webkit-font-smoothing: antialiasedв сбросе действует только на macOS. Расхождение принято: §8 промта прямо снимает совпадение по хинтингу, а на Windows субпиксельное сглаживание — норма платформы. Гасить его флагом в скриншот-цикле не стали: снимок должен показывать то, что рисует настоящий браузер. - Колонка оригинала приглушена (
--color-text-secondary) — это решение, а не замер: во Fleet на этом месте подсвеченный код. Пересмотреть на S6, когда читалка будет настоящей. +стоит только в ряду вкладок левой панели и запускает «Добавить книгу» (решение владельца 04.08); в центре и справа его нет — наборы вкладок там фиксированные, и кнопка вышла бы декоративной. Интерактивные состояния снимаются прогоном браузера, а не статичным кадром:--color-raisedподтверждён на наведении кнопки, вкладки и строки списка.- Боковые панели — пиксели, центр — доля. Решено в S2 замером и продуктом: боковые получают
groupResizeBehavior: "preserve-pixel-size"и стартуют с замеренных 320px, центр —preserve-relative-size. На 1920 всю лишнюю ширину забирает читалка, а справочные колонки остаются той ширины, под которую нарисованы. Библиотека требует хотя бы одну панель с долевым поведением — ею и оказывается центр. Минимум и максимум боковой — новые токены--panel-side-min/--panel-side-max; минимум центра — та же ширина, что у боковой панели (читалка уже боковой панели бессмысленна). - Свёрнутая панель уходит из раскладки вместе со своим разделителем, а не сжимается
collapsible-ом до нуля: при сжатии на её месте оставался бы промежуток разделителя, и поле оболочки переставало быть замеренными 8px. Размеры при этом не теряются —useDefaultLayoutхранит раскладку по СОСТАВУ видимых панелей (ключ storage = id группы + id панелей), и это ровно тот случай, под который в библиотеке заведён пропpanelIds. Проверено исполнением: потянул разделитель до 410px → перезагрузка → 410px; свернул → перезагрузка → свёрнута. - Скроллбар в кадр не попадает. 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.1–5.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), а здешний абзац ещё учил строить
две пары счётчиков.
Что остаётся правилом ФРОНТА, а не контракта:
- Единица пары — edit-unit, ~1.9 на главу (замер движка, ПТ-21): местами вся глава окажется одним блоком, и читалка обязана нормально выглядеть в этом случае.
- Одна вертикальная прокрутка на читалку. Список строк-пар, внутри строки
grid-template-columns: 1fr 1fr. Синхронизации двух прокруток нет и не будет — колонки физически не могут разъехаться (STACK_DECISIONS.md§2). - Состояние — меткой СЛОВОМ, цвет только дублирует и только для внимания и отказа:
девять состояний точкой не различаются — проверено снимком, три пары совпадали. Правило
живёт в коде доккомментарием
Tone(src/api/vocabulary.ts), замер — только здесь. - Денежных полей в интерфейсе нет нигде (§4.8 промта): ни сумм, ни потолков, ни оценки, ни остатка.
- Фикстура ставится в ТРУДНЫЙ случай, а не в удобный. Правило «прогресс не строить на „готово 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:full — STACK_DECISIONS.md §3 (там он сверен по package.json);
копии состава здесь нет намеренно, она отставала на два пака.