textmachine/frontend/docs/FRONTEND_PLAN.md

87 KiB
Raw Blame History

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

Этап S0 сессии фронта: план до первой строки кода (FRONTEND_SESSION_PROMPT.md §7.2). Ландится отдельным коммитом. Правят следующие фронт-сессии; при конфликте с STACK_DECISIONS.md побеждает STACK_DECISIONS (там ратифицированы пины), при конфликте с промтом сессии — промт (там ратифицирован продукт).

0. Границы

Эта сессия делает S0 (план) и S1 (инструменты, скриншот-цикл, tokens.css, витрина) и останавливается на витрине. Экраны — S2S7, по одной сессии на этап (BACKLOG.md Ф-1).

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

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

Фронт живёт в проекте с ратифицированным контрактом, и половина ответов на вопросы «как правильно» уже написана — не здесь. Опыт S0/S1: два дефекта пришли ровно из непрочитанного канона (мокался не тот уровень контракта; смысл двух вердиктов взят по имени причины вместо доккоммента), оба нашлись бы за десять минут чтения. Ссылки, а не пересказ — пересказ протухает:

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

Правило чтения 05-decisions-log.md: карта актуальности в шапке + живая голова с хвоста, корпус D1D38 — grep по D-номеру, целиком не читать. Фронта касаются D39.81 (SaaS; движок = процесс-на-прогон, сервер в backend/ не пишется), D39.84 (стек + восемь решений владельца + зонные бэклоги), D39.85 (шов), D39.88 (git).

0.2. Транспорт: с кем фронт разговаривает

С движком — никогда. Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится (research/23 §4, §0). Между фронтом и движком стоит платформа, у которой пока ноль строк кода (../../platform/BACKLOG.md П-1).

Проводов два, и их легко перепутать:

Шов Формат Где ратифицировано
движок → платформа версионированный NDJSON-поток событий (объект на строку, первая строка — version-хендшейк), плюс артефакты границ стадий, плюс tmctl status --json для ре-синка D39.85, research/23 §2§3
платформа → фронт JSON поверх HTTP для чтений из Postgres read-модели + SSE для живого прогресса; события пушит воркер, фронт read-модель не опрашивает; WebSocket отвергнут D39.84, STACK_DECISIONS.md §5

Фронту принадлежит только вторая строка. NDJSON в коде фронта появиться не может; если появился — кто-то полез не на тот уровень. Дисциплина стрима, которую обязан выдержать слой данных: монотонный id + Last-Event-ID (докачка после обрыва), heartbeat ~20 с, EventSource в браузере и Authorization: Bearer с построчным разбором для десктопа.


1. Пины

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

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

S2 (04.08) поставила три ратифицированных пина, новых не заводила: react-aria-components@1.20.0, react-resizable-panels@4.12.2, zustand@5.0.14 — версии перепроверены npm view в день установки, все три по-прежнему latest, npm audit — 0 уязвимостей. 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 всё серверное состояние: прогон, книги, банк. Мажор 5 актуален. Поставлен S3 08.08, версия ратифицирована ещё в STACK_DECISIONS §1 и перепроверена live в день установки — по-прежнему latest, «v6» на npm это svelte-адаптер. Зачем нам: серверное состояние живёт ровно в одном месте, и поток пишет в тот же кэш, что читает экран — иначе прогресс существовал бы в двух копиях. Рефетч по возврату фокуса оставлен ВКЛЮЧЁННЫМ намеренно: это ровно та гонка, ради которой контракт завёл ревизию, и выключить его значило бы спрятать гонку вместо того, чтобы её обработать
zustand 5.0.14 2026-05-28 состояние интерфейса (свёрнутость панелей, активные вкладки). Только с селектором или useShallow
react-aria-components 1.20.0 2026-07-31 ЕДИНСТВЕННАЯ библиотека примитивов: Table+Virtualizer (банк), Tree (главы), Tabs/Menu/Dialog/Popover/Tooltip (оболочка), I18nProvider (русские служебные строки). Стилей не навязывает. Импорт разрешён только внутри src/ui/
react-resizable-panels 4.12.2 2026-07-12 три панели Fleet. API v4: Group/Panel/Separator — старые имена PanelGroup/PanelResizeHandle мертвы
lucide-react 1.28.0 2026-07-30 тонкие линейные монохромные иконки как во Fleet: size 16, strokeWidth 1.5, без absoluteStrokeWidth
@fontsource-variable/inter 5.3.0 2026-07-19 интерфейсный гротеск; локальный woff2, а не CDN — вид не зависит от сети и одинаков в скриншот-цикле
@fontsource-variable/jetbrains-mono 5.3.0 2026-07-19 моноширинный для содержимого

CJK-шрифт не ставим: иероглифы отдаём системному стеку, lang на элементе приходит из данных языковой пары (STACK_DECISIONS.md §2).

Сборка и типы

Пакет Пин Дата Зачем именно нам
vite 8.2.0 2026-07-30 внутри Rolldown вместо Rollup+esbuild; = latest
@vitejs/plugin-react 6.0.5 2026-07-30 без Babel; старый рецепт подключения React Compiler в нём удалён
typescript 6.0.3 2026-04-16 намеренно не latest (latest = 7.0.2): у TS 7 нет программного API до 7.1, значит нет typescript-eslint и плагинов языкового сервера. Пересмотр — BACKLOG.md Ф-3
@types/react 19.2.18 2026-07-30
@types/react-dom 19.2.4 2026-07-30
@types/node 22.20.1 мажор типов держим на мажоре рантайма (Node 22), а не на latest 26 — иначе тайпчек разрешит API, которых на стенде нет
lightningcss 1.33.0 2026-07-20 минификация CSS в бою; в dev не участвует

Контроль качества

Пакет Пин Дата Зачем именно нам
eslint 10.8.0 2026-07-24 ради no-restricted-syntax — на нём стоит половина гейта «одно место для цвета»; в oxlint такого правила нет вовсе
@eslint/js 10.0.1 2026-02-06 базовый набор для flat-config
typescript-eslint 8.65.0 2026-07-20 единый пакет (парсер+плагин+config)
eslint-plugin-react-hooks 7.1.1 2026-04-17 держит совместимость с React Compiler, пока сам компилятор выключен (BACKLOG.md Ф-2)
eslint-plugin-react-refresh 0.5.3 ловит экспорты, ломающие HMR
eslint-config-prettier 10.1.8 2025-07-18 гасит форматные правила ESLint, чтобы формат был ровно один
stylelint 17.14.1 2026-07-20 вторая половина гейта: declaration-property-value-allowed-list
stylelint-config-standard 40.0.0 2026-01-15 база
prettier 3.9.6 2026-07-21 точный пин: разные патчи форматируют по-разному и создают шум в диффах
vitest 4.1.10 2026-07-06 тесты на том же конфиге Vite
happy-dom 20.11.1 2026-07-22 DOM для контракт-теста токенов: в отличие от jsdom считает каскад CSS-переменных
@testing-library/react 16.3.2 2026-01-19 рендер компонентов в тестах
@playwright/test 1.62.1 2026-07-30 скриншот-цикл и сверка вычисленных токенов в настоящем Chromium
msw 2.15.0 2026-07-08 моки сети. Пин ратифицирован при D39.84, установлен 04.08 ради proof-of-form контракта; в S3 (08.08) развёрнут в полный набор хендлеров и в браузерный воркер. Замер, сделанный ДО того, как на нём что-то построили: сервис-воркер 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: битые $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-спред — закрыта в S2 с другой стороны: запрещено само свойство 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() намеренно нет
20 Числа, которые нужны коду, а не стилям, не заводят вторую истину машинно: src/tokens/measures.ts держит их одним объектом (высота строки для виртуализатора, ширины панелей для react-resizable-panels), а tokens.test.ts сверяет каждое значение с одноимённым токеном. Без этого высота строки существовала бы в двух местах и разъезжалась молча

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

Ответ этой структуры: состояние главы живёт как строка типа в src/api/types.ts и как одна запись в карте отображения src/features/chapters/. Цвет индикатора — токен. Новый вид замечания — запись в карте видов, компонент выноски не трогается.


4. Протокол скриншот-цикла

Цель — не «код валиден», а «вид совпал». Смотреть на построенное обязательно, а не предполагать.

Команда: npm run shot [маршрут ...] [--size ШxВ]. Без аргументов снимает /showcase при 1440×900.

Что делает:

  1. собирает и поднимает vite preview (сборка, а не dev — dev-оверлеи не должны попадать в кадр);
  2. запускает Chromium из Playwright, вьюпорт 1440×900 (базовая ширина из §4.5 промта), deviceScaleFactor: 2 — чтобы снимок был сравним с референсом, снятым на macOS при 2x; --size 1280x764 даёт вьюпорт самого референса, когда нужно прямое наложение;
  3. ждёт готовности экрана и загрузки шрифтов (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.

Маршрутов стало семь, и каждый — свой мир фикстур (S3): /showcase трудный случай · /scale длинный хвост · /empty пусто · /loading ожидание · /error отказ платформы · /offline живой поток потерян при рабочих чтениях · /partial отказал ОДИН рид при рабочих остальных. Каждый получает свой кадр и свой прогон 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 — думать об этом больше не нужно.

mkdir -p .tooling && cd .tooling
apt-get download libnss3 libnspr4 libasound2t64 fonts-noto-cjk
for d in *.deb; do dpkg -x "$d" root; done
rm -f *.deb && cd ..

Шрифт нужен только стенду: продукт отдаёт CJK системному стеку (STACK_DECISIONS.md §2), на Windows у пользователя он есть.


5. Критерий сходимости с референсом

Растровое совпадение не является целью и недостижимо: Fleet закрыт (скачивание прекращено 22.12.2025), референс снят на macOS при 2x, цель — Chromium с другим хинтингом шрифтов. Эталонных скриншотов в тестах нет — вместо них контракт-тест токенов (STACK_DECISIONS.md §3).

Сходиться обязаны: цвета · промежутки 8px · радиусы 6px · плотность строк · общее впечатление. Подбираются на глаз: кегли, интерлиньяж, внутренние отступы — померить их больше негде.

5.1. Замеры, перепроверенные в этой сессии

Значения §1.1 промта перепроверены заново по references/fleet.png (PIL: гистограмма всего изображения, срезы строк и столбцов, детект края скругления по порогу яркости). Все числа совпали, одна роль разошлась: §1.1 отдаёт #353739 и «выбранной строке», и «активной вкладке», а в кадре это разные вещи — вкладка открытого документа залита #142f4c, вкладка панели #27292b, и #353739 под вкладками не встречается вовсе. В токенах роли разведены. Ниже — сводка с тем, что удалось доснять; это и есть входные числа tokens.css.

Масштаб снимка 2x подтверждён независимо: все четыре промежутка между панелями равны ровно 16 физическим пикселям, что даёт целые 8 CSS-пикселей.

Поверхности (доля площади — по гистограмме всего кадра 2560×1528):

Роль Значение Подтверждение
фон оболочки #090909 10.45% площади
заливка панели #17191a 80.48% площади
приподнятая поверхность, наведение, активная вкладка панели #27292b 18 196 px; вкладки Files, Terminal, AI Assistant
выбранная строка дерева, клавиша-чип #353739 37 374 px; ровно три пятна в кадре, и все три — строка дерева и чипы клавиш
активная вкладка открытого документа #142f4c поправка: заливка вкладки rpc-node.ts — 7 183 px синеватого, а не серого
подсветка текущей строки #152945 36 800 px
выделение #164e8d 476 px
текст основной #dfe1e3 0.40% площади
текст вторичный #8a8e91 доснято: статус-полоса
текст приглушённый #707479 доснято: колонка номеров строк
акцент #746deb 1 258 px, рамка поля ввода
красный индикатора #b82e45 829 px

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

Величина CSS Как замерено
промежуток между панелями 8 четыре среза по 16 физ. без исключений
поле оболочки по краям 8 тот же срез: слева и справа те же 16 физ.
радиус панели 6 угол выходит на прямую за 12 физ.
верхняя полоса 28 верхняя полоса фона 72 физ. = 8 поля + 28 полосы
статус-полоса 20 нижняя полоса фона 56 физ. = 20 полосы + 8 поля
шапка панели (вкладки) 26 заливка вкладки Files 86..137 физ.
шаг строки дерева 26 шаг текстовых полос: 52 физ., 15 строк подряд
подложка выбранной строки 24 заливка #353739 — 48 физ. при шаге 52
межстрочный интервал содержимого 21 подсветка текущей строки 42 физ.
ширина боковой панели при 1280 320 (25%) границы панелей: 8‥328 · 336‥944 · 952‥1272
внутренний отступ панели 6 подложка строки 14‥321.5 при панели 8‥328

Модель раскладки, вытекающая из замеров и сходящаяся точно (764 = 8+28 + 700 + 20+8): поле оболочки 8 · промежуток 8 · верхняя полоса 28 · статус-полоса 20.

Типографика (оценка по высоте глифов, точнее не измеряется): интерфейс ~13px (высота выносных элементов 10px), статус-полоса ~12px, содержимое ~13px моноширинным при интерлиньяже 21px.

Из antigravity_chat.png — модель выноски замечания (§3.8 промта): левая граница 3px, синяя #2964ad (заметка) / зелёная #3c7d4f (подсказка), заливки текста нет. Палитра Antigravity (нейтральный серый, без холодного оттенка): #101010 фон · #161616 боковая панель · #1c1c1c приподнятая карточка · #252525 кнопка основного действия · #323232 разделитель · #cecece текст.

⚠ Две палитры в одном приложении не смешиваются. Базовая — Fleet; из Antigravity берётся форма пустого состояния и выноски, не цвета. Токенов Antigravity в tokens.css не заводим.

5.2. Витрина сведена с референсом — что сошлось и что нет

Витрина снята тем же способом, что и референс (--size 1280x764, 2x), и промерена тем же кодом.

Сошлось до пикселя: промежутки — четыре по 16 физ.; верхний край панелей — 72 физ.; радиус — угол выходит на прямую за 12 физ.; шаг строки дерева — 26 CSS; нижняя полоса — 56 физ.; доли площади поверхностей (панель 83.5% против 80.5%, фон 9.4% против 10.5% — разница от того, что у нас другое наполнение). Высота глифов статус-полосы совпала точно (23 физ.), вкладки — 19 против 20 физ.

Расхождения, названные вслух:

  1. Субпиксельное сглаживание. У Fleet текст сглажен в серую шкалу (macOS): в статус-полосе ровно 0% цветных пикселей. У нас в том же месте 1.29%, в дереве 1.35% против 0.37% — это цветная бахрома Chromium. -webkit-font-smoothing: antialiased в сбросе действует только на macOS. Расхождение принято: §8 промта прямо снимает совпадение по хинтингу, а на Windows субпиксельное сглаживание — норма платформы. Гасить его флагом в скриншот-цикле не стали: снимок должен показывать то, что рисует настоящий браузер.
  2. Колонка оригинала приглушена (--color-text-secondary) — это решение, а не замер: во Fleet на этом месте подсвеченный код. Пересмотреть на S6, когда читалка будет настоящей.
  3. + в конце рядов вкладок у Fleet есть, у нас нетзакрыто в S2 (04.08). + стоит в ряду вкладок левой панели и запускает «Добавить книгу» (решение владельца 04.08); в центре и справа его нет, потому что наборы вкладок там фиксированные и кнопка вышла бы декоративной.
  4. Наведение и прочие интерактивные состояния витриной не проверенызакрыто в S2: состояния сняты прогоном браузера, а не статичным кадром. --color-raised подтверждён на наведении кнопки, вкладки и строки списка.
  5. Боковые панели — пиксели, центр — доля. Решено в S2 замером и продуктом: боковые получают groupResizeBehavior: "preserve-pixel-size" и стартуют с замеренных 320px, центр — preserve-relative-size. На 1920 всю лишнюю ширину забирает читалка, а справочные колонки остаются той ширины, под которую нарисованы. Библиотека требует хотя бы одну панель с долевым поведением — ею и оказывается центр. Минимум и максимум боковой — новые токены --panel-side-min/--panel-side-max; минимум центра — та же ширина, что у боковой панели (читалка уже боковой панели бессмысленна).
  6. Свёрнутая панель уходит из раскладки вместе со своим разделителем, а не сжимается collapsible-ом до нуля: при сжатии на её месте оставался бы промежуток разделителя, и поле оболочки переставало быть замеренными 8px. Размеры при этом не теряются — useDefaultLayout хранит раскладку по СОСТАВУ видимых панелей (ключ storage = id группы + id панелей), и это ровно тот случай, под который в библиотеке заведён проп panelIds. Проверено исполнением: потянул разделитель до 410px → перезагрузка → 410px; свернул → перезагрузка → свёрнута.
  7. Скроллбар в кадр не попадает. 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. Что именно проверено живым нарушением

Формулировка «гейты проверены живым нарушением» без перечня форм — ловушка: она звучит как машинная гарантия, а покрывает ровно те входы, которые придумал автор. Ниже полный список проверенных форм; каждая роняла проверку, после отката дерево зелёное.

CSS: hex-литерал в модуле · hex в сокращённой записи border · rgb() · color() в сокращённой записи · font-size числом · именованное отключение правила (/* stylelint-disable color-no-hex */) · голое /* stylelint-disable */ в шапке файла.

TSX: hex всех четырёх длин (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) · цветовые функции включая color() и light-dark() · одиннадцать форм инлайнового стиля (литерал, спред, вынесенная переменная, приведение as, свойство объекта, вызов фабрики, тернарник, вычисляемый ключ, JSX-спред, смешанный литерал) · импорт глобального CSS мимо main.tsx · предупреждение линта при --max-warnings 0.

Сеть (все восемь форм ловятся, в src/api/** все восемь проходят): fetch · EventSource · WebSocket · XMLHttpRequest · window.fetch · globalThis.fetch · self.fetch · navigator.sendBeacon. Проверялось файлом на диске, а не через --stdin: тип-осведомлённый линт отказывается разбирать stdin («was not found by the project service»).

Структурные тесты: опечатка в имени класса CSS Module · то же при переименованном импорте модуля · опечатка в имени токена в CSS · опечатка в имени токена в данных TS · маршрут добавлен в routes.tsx и забыт в scripts/shot.mjs · литерал lang="zh" возвращён в разметку.

Что НЕ ловилось и осталось дырой после S1: /* eslint-disable */ в TSX (см. правило 2) · JSX-спред <div {...props} /> со style внутри объекта · именованные цвета CSS в TSX (fill="red"). Две последние закрыты в S2 (Ф-9), первая закрыта правилами @eslint-community/eslint-comments ещё в S1 — см. правило 2.

5.4.1. Что добавила S2 (04.08) — перечень форм

Гейты прогонялись разом: один временный файл со всеми формами, один прогон линтера, ожидание записано против каждой строки. Файлы уносились после прогона, дерево после отката зелёное.

Цвет в TSX (Ф-9), 15 форм. Падают: именованный цвет в fill · именованный цвет в color иконки · системный цвет в stroke через выражение · цвет в CSS-переменной style={{'--x':'red'}} · системный цвет там же · размер литералом там же ('10px') · hex-литерал · цветовая функция · обычное свойство в инлайновом стиле · { style: {...} } в объектном литерале · { 'style': {...} } строковым ключом · импорт react-aria-components вне src/ui/. Проходят (и обязаны): fill="none" · fill="currentColor" · число в CSS-переменной ('--progress': 0.5) · токен в CSS-переменной (var(--space-4)).

⚠ Ловушка, найденная исполнением: селектор Property[key.value=/^--/] > Literal[…] ловил СОБСТВЕННЫЙ ключ (имя переменной — тоже прямой потомок Property) и падал на легальном style={{ '--progress': x }}. Поэтому в списке разрешённых значений стоит и префикс --.

Отступы и системные цвета в CSS (Ф-4, Ф-9), 14 форм. Падают: padding: 8px · margin: 0 4px · gap: 4px · border-radius: 6px · border-top-left-radius: 4px · calc(var(--space-4) + 2px) · системный цвет в outline · в caret-color · в box-shadow · именованный цвет · hex в сокращённой записи border. Проходят: padding: var(--space-4) · padding-top: 0 · margin-left: auto.

Обход, найденный адверсариальным ревью и НЕ прошедший: объявить в модуле свою переменную (--local-pad: 13px) и подставить её (padding: var(--local-pad)). Форму обёртки stylelint принимает — но падает контракт-тест токенов, который требует, чтобы каждое имя var(--…) было объявлено в tokens.css. То есть гейт держит вторым слоем, а не первым; проверено исполнением.

Структурные тесты (Ф-10), 7 форм — каждая роняет vitest. Опечатка в классе у компонента, импортирующего СОСЕДНИЙ модуль (прежде выпадала из проверки) · опечатка в классе через деструктуризацию const { x } = styles · расширение из url(./x.woff2), притворяющееся объявленным классом · новый токен, не попавший ни в замеры, ни в подобранные · токен, оставшийся в списке подобранных после удаления из tokens.css · число в measures.ts, разошедшееся с одноимённым токеном · модуль стилей, который никто не импортирует.

Хвосты ревью оркестратора (04.08), 11 форм — один прогон линтера, 7 падений и 4 прохода. Падают: динамический import('../mock/book') · динамический import('../tokens/tokens.css') · динамический import('react-aria-components') вне src/ui/ · то же из src/api/ (фикстуры ему можно, примитивы — нет) · динамический импорт моков из src/ui/ · тернарник в значении CSS-переменной ({'--x': flag ? 'red' : 'blue'}) · шаблонная строка там же. Проходят (и обязаны): динамический импорт *.module.css · идентификатор в значении переменной · import('react-aria-components') внутри src/ui/ · import('../mock/book') внутри src/api/. Причина всей пачки одна: no-restricted-imports разбирает только ОБЪЯВЛЕНИЕ импорта, а await import(…) — выражение, поэтому каждый шов продублирован селектором и точечные послабления у обеих половин совпадают по имени шва.

Языковая ветка в CSS (generality.test.ts, вторая половина). Падает :lang(zh) .source { … } в любом файле стилей — проверено живым нарушением с восстановлением файла из памяти, не из git. Комментарии из CSS снимаются перед проверкой: правило объясняется в том же файле, где действует, и объяснение содержит запрещённую форму дословно.

5.4.2. Замки контракта API (04.08) — четыре формы, каждая проверена нарушением

Гейт контракта — шестой шаг npm run check плюс тесты src/api/contract.test.ts. Каждая форма роняла проверку, после отката дерево зелёное; откат — копией файла, не git.

Падают: правка спеки без пере-генерации типовopenapi.yaml добавлено поле, тест дрифта сравнил свежую генерацию с закоммиченной и упал) · правка генерённого файла рукамиschema.ts вписан лишний тип — тот же тест) · битый $ref в спеке (invalid-ref от spectral, npm run contract = 1) · значение словаря, выпавшее из перечня ветки неизвестного (awaiting_bank убран из bookStatusestsc роняет TS2344 «Type '"awaiting_bank"' does not satisfy the constraint 'never'»).

⚠ Ложное срабатывание, найденное этим же заходом и починенное по классу: контракт-тест токенов собирает имена по кавычкам вокруг --…, и флаг движка в доккомментарии генерённого файла (`--verify-bank`) для него неотличим от имени токена. Генерённый файл из скана исключён с названной причиной; остаточная граница честная — обратные кавычки вокруг --флаг в АВТОРСКОМ комментарии всё ещё дадут ложное падение, но падение громкое, а не тихое.

5.4.3. Замки слоя данных (S3, 08.08) — семь форм, каждая проверена нарушением

Прогон одним заходом, откат — копией файла, не git checkout (в дереве лежат незакоммиченные правки чужих зон). После отката npm run check зелёный.

Падают:

  1. Значение словаря выброшено из src/api/vocabulary.tstsc роняет TS2345, называя пропавшее значение по имени (Property 'paused' is missing). Это и есть механизм «новое состояние правит ОДИН файл»: словарь объявлен как Record<Значение, Смысл>, поэтому список значений выводится из записей и разойтись с типом физически не может.
  2. Спека правлена без пере-генерации типов — тест дрифта (contract.test.ts).
  3. Мажор спеки разошёлся с мажором, который говорит клиент — новый тест: info.version спеки читается из файла и сверяется с supportedMajor в stream.ts. Без него спека, бампнутая до 1.x, отвергала бы каждый живой поток в рантайме вместо падения на сборке.
  4. Новый маршрут в routes.tsx, забытый в scripts/shot.mjsroutes.test.ts.
  5. EventSource прямо в экранеno-restricted-globals с проектным сообщением.
  6. window.fetch прямо в экранеno-restricted-properties.
  7. Импорт ../mock/* в экране, статический И динамическийno-restricted-imports плюс селектор на ImportExpression.

Проходят (и обязаны): new EventSource(...) внутри src/api/**.

Что этот слой гейтом НЕ закрыт, названо честно: соответствие MSW-хендлеров контракту держится типами тел (json<Schemas['X']>), но ПУТЬ хендлера — обычная строка, и опечатка в нём даёт onUnhandledRequest, а не падение сборки; в тестах это ловится ('error'), в браузере хендлер просто не сработает. Второе: пагинация проверена на мире scale, где страницы настоящие, но гейта, требующего страничности от каждого нового мира фикстур, нет.

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

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

5.5. Пере-замер S3.5 (09.08) — что нашёл замер, а не глаз

Замечания владельца 1 и 5 требовали работать замером. Инструмент замера теперь живёт в репозитории и воспроизводим: python3 scripts/measure.py references/fleet.png .shots/showcase.png печатает доли поверхностей, промежутки по горизонтали и полосы по вертикали в CSS-пикселях.

Что сошлось при вьюпорте референса 1280×764 (прямое наложение, оба кадра при 2x): промежутки — четыре по 8 CSS ровно на тех же координатах (0, 328, 944, 1272); ширины панелей 320 · 608 · 320; шаг строки дерева 26; высота пилюли вкладки 26. То есть по критерию §5 витрина СТОИТ на референсе, и «масштабы великоваты» при вьюпорте референса замером не подтверждается.

Что разошлось и было исправлено:

Что Замер Fleet Было у нас Стало
верхняя полоса центр иконок y=17.75 при полосе 0‥36 — полоса прижата к краю окна центр 22: 8 поля + 28 полосы --topbar-height: 36, поле оболочки только по бокам
статус-полоса центр текста y=749.75 при полосе 735.5‥764 центр 745.5, под текстом пустая полоса 8px --statusbar-height: 28, текст по центру полосы
поле текста полос 12.5 слева, 13.5 справа ОТ КРАЯ ОКНА 14 (8 поля оболочки + 6) поле оболочки перенесено на область панелей, полосы полнокровные, --bar-inset: 12
поля и промежутки вкладок пилюля «Files» 15‥59 при тексте 25‥52, до следующей ~5px поле 8, промежуток 2 --tab-inset: 10, промежуток --space-3

Полная геометрия окна при этом не изменилась: 36 + 836 + 28 = 900, промежутки панелей остались на 0 · 328 · 944 · 1272. Это и есть ответ на «особенно заметно по тексту снизу»: полосы у Fleet прижаты к краям окна, а не отступают от них на поле оболочки, и текст в них центрируется по всей полосе. Поле оболочки поэтому принадлежит области ПАНЕЛЕЙ, а не окну целиком — иначе поле полосы складывается с полем оболочки, и текст уезжает вдвое дальше от края, чем в референсе (эту ошибку первый заход S3.5 и совершил: было 14, стало бы 20 при цели 12.5; поймало адверсариальное ревью). После правки замер сходится с референсом по обеим осям: текст статус-полосы x 12.5…1267.0, y 744.5…756.0 против 12.5…1266.5, 744.5…756.0 у Fleet.

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

Мера набора читалки. На кадре 2560×1440 строка перевода вырастала до ~150 знаков: боковые панели держат функциональную ширину в пикселях, и вся лишняя ширина доставалась центру. S3.5 ввела --reader-width: 1040px с центрированием блоков — ~75 знаков в колонке. ⚠ Отменено 10.08: владелец назвал это «жёстко приклеен к центру», токен удалён, набор снова во всю ширину панели; на 2560×1440 это ~120 знаков в строке. Ограничивать ширину повторно нельзя — решение владельца. Функциональную ширину боковых панелей намеренно НЕ переводили в долю кадра: 25% у Fleet это следствие окна 1280, а не инвариант — 640px под дерево разделов были бы пустой ширины.


5.6. Замер палитры S3.6 (10.08) — тон снят с референсов, а не подобран

Владелец отверг тёплый подтон S3.5 и назвал эталоном два редактора разом: Fleet (fleet_2.png, докинут 10.08) и Antigravity. Тон снят с их кадров гистограммой (PIL, счёт точек по всему кадру плюс срезы именованных областей), а не выбран глазом:

Что fleet_2.png antigravity_*.png Наш токен
полотно панели #181818 (64% кадра) #161616 (18%) --color-panel: #181818
хром (полоса вкладок, верх окна) #292929 (7%) #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. То есть тёплый подтон, внесённый S3.5 «по рекомендациям про тёмные фоны», противоречил тем самым кадрам, на которые сессия ссылалась. Второй вывод — про вкладки: срез по вертикали через активную вкладку fleet_2 (x=790, y=36‥68) даёт #181818 без единой разделительной линии до содержимого, тогда как соседняя неактивная лежит на #292929.

Поправка S3.7 (10.08): вывод про вкладки в коде НЕ стоит. Модель «вырез в хроме» была построена по этому замеру и тем же паком ОТКАЧЕНА по слову владельца — в Tabs.module.css сегодня пилюля по fleet.png: полоса вкладок это полотно панели, активная вкладка лежит НА нём скруглённой подложкой (--color-raised, у документа --color-tab-active). Замер остаётся верным про сам референс; описание кода в этом абзаце было неверным.

Способ повторить (проверено исполнением 10.08 в S3.7, числа сошлись): scripts/measure.py для поверхностей и промежутков, PIL.Image + collections.Counter по im.getdata() для общей гистограммы и im.crop(box) для именованной области; насыщенные тона отбираются фильтром по colorsys.rgb_to_hls (s > 0.25, 0.12 < l < 0.75). Окружение python запинено — scripts/requirements.txt (в системном python на стенде Pillow нет вовсе).

5.7. Пере-замер S3.7 (10.08) — палитра воспроизведена, кадры сведены

Референс fleet_2.png докинут владельцем при приёмке S3.6, и палитра S3.6 впервые проверена не «со слов»: .tooling/py/bin/python scripts/measure.py references/fleet_2.png даёт #181818 63.97% кадра и #292929 7.17% — ровно те числа, на которые ссылается §5.6, то есть --color-panel стоит на замере, а --color-chrome (#242424) остаётся осознанно на ступень спокойнее хрома референса.

Наши кадры при вьюпорте референса — node scripts/shot.mjs /showcase --size 1280x764 — и тот же инструмент:

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

То есть за два пака поехали ровно те числа, которые двигали намеренно, и ни одного сверх того; разошлось бы что-то ещё — эта таблица и была бы местом, где это видно.

6. Что уже известно про движок — учтено в форме данных

Из FRONTEND_SESSION_PROMPT.md §9 и STACK_DECISIONS.md §8. Влияет на типы в src/api/ уже сейчас, поэтому записано в плане, а не откладывается на S3.

  1. Прогресс — пофазный, не «готово N из M». Юнит становится done только когда есть и черновые строки всех членов, и строка редактуры: во время черновой волны сквозной счётчик стоит на нуле почти всё время. Тип прогресса — две пары { done, total } (черновик ∥ редактура), а не одно число. Пользователю всё равно показывается одна полоса без названий фаз (§4.1). ⚠ Это правило было записано здесь ещё в S0 — и витрина S1 всё равно вышла с наивным счётчиком глав, потому что фикстура выдумала поле translatedChapters, которого read-модель не отдаёт. Лечение сильнее правила: фикстура поставлена в состояние «идёт черновая волна», где наивный счётчик дал бы ноль, и скриншот-цикл видит трудный случай, а не удобный. 1а. Состояние — у ПРОГОНА над книгой, у главы его нет. Подпись банка — один стоп между черновой и редакторской волнами на всю книгу (mining.go:199), поэтому «глава ждёт подписи, пока соседняя финализируется» — картина, которую движок породить не может. У главы есть выполнение: ChapterPassport (status.go:37-55) несёт units_total/done/in_progress/pending и вердикт. В интерфейсе: лестница из девяти состояний — на строке книги, полоса выполнения — на строке главы. Девять состояний точкой не различаются (проверено снимком: три пары совпадали) — метка словом, цвет только дублирует и только для внимания и отказа. 1б. Язык — код, а не слово. Движок держит source_lang/target_lang кодами (book.go:26-27), ключ пары — zh-ru; контракт отдаст то же самое. Человеческое имя считается на экране через Intl.DisplayNames, lang на элементе берётся из данных книги (правило 17).
  2. Подпись термина — не UPDATE строки. Банк пересобирается из файлов на каждом прогоне, прямая запись стирается следующим прогоном. Контракт подписи — отправка набора решений, а не правка записи; фикстуры S3 моделируют именно это.
  3. Байтовых смещений у замечаний нет. Замечание адресует блок или главу — и только. Тип замечания не имеет полей offset/length; подчёркиваний внутри текста не будет.
  4. Единица пары — edit-unit, ~1.9 на главу. Местами вся глава окажется одним блоком. Читалка обязана нормально выглядеть в этом случае; выравнивание грубое, по единицам экспорта.
  5. Одна вертикальная прокрутка на читалку. Список строк-пар, внутри строки grid-template-columns: 1fr 1fr. Синхронизации двух прокруток нет и не будет — колонки физически не могут разъехаться.

Денежных полей в интерфейсе нет нигде (§4.8): ни сумм, ни потолков, ни оценки, ни остатка.


7. Порядок S1 и что в него НЕ входит

Порядок работ S1: каркас → npm run check → скриншот-цикл и проверка, что он живой → tokens.css → витрина → контракт-тест токенов → сверка витрины с референсом.

npm run check = prettier --checkeslintstylelinttsc --noEmitvitest run. Шагов пять, а не четыре: STACK_DECISIONS.md §3 перечисляет четыре, но там же требует, чтобы обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint. npm run check:full = checkvite buildshot. Отдельного e2e-набора в S1 нет: единственная браузерная проверка — скриншот-цикл, он и стоит в check:full. Git-хук один — pre-commit (запрос владельца 02.08, вторая фронт-сессия; отменяет прежнее «хуков нет»: CI в репозитории отсутствует, до его появления хук — единственный машинный рубеж). Зонный фрагмент scripts/githooks/pre-commit зовёт тот же npm run check (один список инструментов, не дубль) только когда в коммите есть frontend-пути; плюс блок файлов «никогда не коммитить» и блок смеси frontend/ с чужой зоной (картина инцидентов D39.88). Ставится сам: npm install через prepare кладёт диспетчер в .git/hooks/pre-commit (идемпотентно, чужой хук не перетирает). Осознанный обход — git commit --no-verify.

Не входит в S1 (и не должно появиться раньше срока): экраны, оболочка трёх панелей, слой данных и MSW, React Compiler (Ф-2), токен-гейт на отступы (Ф-4 — включается, когда шкала отступов устоится, иначе мешает подбору), эталонные скриншоты в тестах (отложены решением STACK_DECISIONS.md §3), светлая тема, мобильная раскладка.