624 lines
76 KiB
Markdown
624 lines
76 KiB
Markdown
# План фронта — как пишется интерфейс 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](../../CLAUDE.md) | зоны, гардрейлы, git-протокол (коммит только pathspec-формой) | первым делом |
|
||
| [../../docs/README.md](../../docs/README.md) | карта: где что лежит и что чем перекрыто | первым делом |
|
||
| [../../docs/product-requirements.md](../../docs/product-requirements.md) | реестр ПТ-1..ПТ-34; **ПТ-33 и ПТ-34 — жёсткие инварианты интерфейса**, ПТ-21 задаёт якорь чтения | до первого экрана |
|
||
| [../../docs/research/23-engine-platform-seam.md](../../docs/research/23-engine-platform-seam.md) | форма шва движок↔платформа; `docs/README.md` требует читать его **перед любым кодом стыка** | до любого кода данных |
|
||
| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет. ⚠ Номера строк из прежних редакций этого плана (95, 99–103) закрыты и в бэклоге больше не стоят — искать грепом по слову, а не по номеру | перед планированием этапа |
|
||
| [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных |
|
||
| [api-contract/openapi.yaml](api-contract/openapi.yaml) + компаньон рядом с каноном (`../../docs/architecture/14-api-contract/README.md`) | **ратифицированный контракт** — форма данных, коды ответов, словари. ⚠ Заменил `API_CONTRACT_INPUT.md`: тот под баннером «исполнено» и живым входом больше не является | до слоя данных и при каждой правке формы |
|
||
| [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове |
|
||
|
||
Правило чтения [05-decisions-log.md](../../docs/architecture/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`
|
||
(он импортируется первым, а заявление слоёв обязано стоять раньше любого слоя):
|
||
|
||
```css
|
||
@layer reset, vendor;
|
||
```
|
||
|
||
- **сброс** — самый низ, перебивается чем угодно;
|
||
- **vendor** — стили сторонних примитивов; подключаются только так:
|
||
`@import 'пакет/styles.css' layer(vendor)`;
|
||
- **токены и CSS Modules — намеренно ВНЕ слоёв.** Неслойное правило выигрывает у любого слоя,
|
||
поэтому ни сброс, ни чужие стили наши перебить не могут, а модуль экрана при нужде может
|
||
локально переопределить токен обычным каскадом.
|
||
|
||
Токены сознательно не завёрнуты в слой: заворачивать нечего (это только объявления переменных
|
||
на `:root`), а лишний слой делает их слабее собственных стилей приложения без всякой выгоды.
|
||
|
||
---
|
||
|
||
## 3. Правила поддерживаемости — в проверяемой форме
|
||
|
||
Из `FRONTEND_SESSION_PROMPT.md` §5.1. Слева — правило, справа — чем оно проверяется.
|
||
Правило без проверки — лозунг, поэтому у каждого либо машинный гейт, либо ревью-вопрос
|
||
с однозначным ответом.
|
||
|
||
| # | Правило | Проверка |
|
||
|---|---|---|
|
||
| 1 | Цвет и размер только из `tokens.css` | **машинно**: stylelint `declaration-property-value-allowed-list` (`/^var\(--/` на `color`, `background-color`, `border-color`, `fill`, `stroke`, `font-size`, `z-index`) + ESLint `no-restricted-syntax` на `#hex`/`rgb(`/`hsl(`/`oklch(` в TSX. Исключение по пути — только `tokens/` |
|
||
| 2 | Отключить CSS-гейт комментарием нельзя | **машинно**: `reportDisables: true` на правилах + `reportUnscopedDisables: true` в корне конфига. Одного `reportDisables` НЕ хватает: он ловит только именованное отключение, а голое `/* stylelint-disable */` в шапке снимало гейт молча — проверено. В TSX то же самое делают правила `@eslint-community/eslint-comments`: `no-unlimited-disable` требует назвать правило, `require-description` — написать причину, `disable-enable-pair` — закрыть область. Голое `/* eslint-disable */` даёт три ошибки; точечное `// eslint-disable-next-line react-hooks/exhaustive-deps -- причина` проходит. Бинарный `noInlineConfig` не берём: он запрещает и то, что рекомендует сам React |
|
||
| 3 | Данные только через `src/api/` | **машинно**, см. правило 14 (было ревью-вопросом — не сработало) |
|
||
| 4 | Файл = один компонент + свой `.module.css`, больше ~150 строк — делить | ревью глазами при лендинге пакета |
|
||
| 5 | Состояние ровно в двух местах: TanStack Query (серверное), Zustand (интерфейсное) | ревью-вопрос: есть ли `useState` с копией серверных данных? Должно быть «нет» |
|
||
| 6 | Глобальных стилей два файла | **машинно**: любой `.css` вне `src/tokens/`, не являющийся `*.module.css`, — ошибка сборки правилом ESLint на импорт |
|
||
| 7 | Никаких UI-китов | ревью-вопрос: в `package.json` кроме `react-aria-components` библиотек компонентов нет |
|
||
| 8 | Компоненты глупые: данные пропсами, запросы на уровне экрана | ревью-вопрос: есть ли `useQuery` внутри `src/ui/`? Должно быть «нет» |
|
||
| 9 | Комментарии — одна-две строки «почему» | ревью глазами; проектная норма |
|
||
| 10 | Каждый экран открывается в изоляции: свой маршрут, своя фикстура | **машинно** косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется |
|
||
| 11 | `styles.имяКласса` ссылается на существующий класс | **машинно**: `src/cssModules.test.ts`. Vite типизирует модуль как `{ [key: string]: string }`, поэтому опечатка даёт `className="undefined"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась |
|
||
| 12 | Имя токена в `var(--…)` и в строках кода объявлено в `tokens.css` | **машинно**: `src/tokens/tokens.test.ts`. Тот же класс тихой ошибки: stylelint проверяет только форму обёртки `var(--…)`, тайпчек видит обычную строку, браузер отдаёт пустое значение — элемент гаснет в фон, и на скриншоте это 124 пикселя из 3,9 млн. Локальные переменные, задаваемые через `style`, перечислены в тесте явным списком |
|
||
| 14 | Данные — только через `src/api/`; сеть — только там же | **машинно**: `no-restricted-imports` на `**/mock/**` плюс `no-restricted-globals`/`no-restricted-properties` на ВСЕ транспорты (`fetch`, `EventSource`, `WebSocket`, `XMLHttpRequest` и они же через `window`/`globalThis`/`self`, плюс `navigator.sendBeacon`), с исключением для `src/api/**`. Одного `fetch` не хватало: живой прогресс по ратифицированному стеку приходит через `EventSource` (`STACK_DECISIONS.md` §5) — то есть шов расползся бы именно тем транспортом, который гейт не видел |
|
||
| 15 | Доступность проверяется машиной, а не глазами владельца | **машинно**: axe-core прогоняется в `npm run shot` по каждому маршруту и валит команду; список маршрутов скрипта сверяется с `routes.tsx` тестом (`src/routes.test.ts`) — иначе новый экран просто не попадал в прогон, а команда возвращала успех. Контраст вынесен в отчёт: палитра снята с Fleet замером и принята владельцем, менять её под порог WCAG — отдельное решение (`BACKLOG.md` Ф-11) |
|
||
| 17 | Пара языков живёт в данных, а не в разметке | **машинно**: `src/generality.test.ts` запрещает литерал `lang="…"` в TSX. Это ревью-вопрос канона в исполняемой форме («заработает ли пара, которой в репо ещё нет, без правки кода?» — `CLAUDE.md` §2). Найдено ревью S1 в живом коде: `lang="zh"` пережил бы приезд ja→ru молча, и кандзи отрисовались бы китайскими начертаниями |
|
||
| 16 | Плавающий промис не проходит | **машинно**: `tseslint.configs.recommendedTypeChecked` с `projectService`. Проверено: `load()` без `await` роняет линт |
|
||
| 13 | Инлайновый стиль — только литерал объекта прямо в атрибуте, и только с ключами-CSS-переменными | **машинно**: `no-restricted-syntax` запрещает сам атрибут `style` и разрешает единственную форму. Перечислять формы записи оказалось бесполезно: из одиннадцати способов записать то же самое ловилось три. JSX-спред закрыт с другой стороны — запрещено само свойство `style` в объектных литералах, то есть носитель ловится там, где он собран. Плюс значение CSS-переменной обязано быть числом или `var(--…)` |
|
||
| 18 | Библиотека примитивов видна ровно из одного места | **машинно**: `no-restricted-imports` на `react-aria-components` с исключением для `src/ui/**`. Две библиотеки примитивов означали бы два focus-scope и два портальных менеджера (`STACK_DECISIONS.md` §2), а один вход делает замену библиотеки правкой одной папки |
|
||
| 19 | Отступы, промежутки и радиусы — только из токенов | **машинно** (Ф-4, включён в S2): stylelint `declaration-property-value-allowed-list` на `/^(padding\|margin)(-\|$)/`, `/^(gap\|row-gap\|column-gap)$/`, `/^border-.*radius$/`. Значение — последовательность `var(--…)`; `0`, `auto`, `inherit` легальны, `calc()` намеренно нет |
|
||
| 21 | Плоско и скучно: никаких абстракций «на будущее», фабрик и слоёв ради слоёв | ревью-вопрос: что этот слой даёт СЕГОДНЯ? Дублирование двух строк лучше преждевременного обобщения — проектная норма «механизм там, где несёт ценность». Машинного гейта нет, и это честно |
|
||
| 22 | Имена человеческие, без аббревиатур | ревью глазами; проектная норма. Машинного гейта нет |
|
||
| 20 | Числа, которые нужны коду, а не стилям, не заводят вторую истину | **машинно**: `src/tokens/measures.ts` держит их одним объектом (высота строки для виртуализатора, ширины панелей для `react-resizable-panels`), а `tokens.test.ts` сверяет каждое значение с одноимённым токеном. Без этого высота строки существовала бы в двух местах и разъезжалась молча |
|
||
|
||
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
|
||
добавление нового состояния главы или нового вида замечания правит **один** файл.
|
||
Правит три — структура неверна, переделать до движения дальше.
|
||
|
||
Ответ этой структуры (типы приходят из спеки, авторского файла типов у зоны нет):
|
||
состояние книги живёт **одной записью в `src/api/vocabulary.ts`** — там же его
|
||
слово, тон и то, что с ним можно делать (`startable`, `intake`); экраны эту запись читают и ничего
|
||
о списке значений не знают. Цвет — токен. Новый вид замечания — запись в карте видов, компонент
|
||
выноски не трогается. Проверено S4: добавление свойства «книгу в этом состоянии можно запустить»
|
||
тронуло один файл, а новое состояние без слова роняет `tsc` на этой же карте.
|
||
|
||
---
|
||
|
||
## 4. Протокол скриншот-цикла
|
||
|
||
Цель — не «код валиден», а «вид совпал». Смотреть на построенное обязательно, а не предполагать.
|
||
|
||
**Команда:** `npm run shot [маршрут ...] [--size ШxВ]`. Без аргументов снимает `/showcase`
|
||
при 1440×900.
|
||
|
||
**Что делает:**
|
||
1. собирает и поднимает `vite preview` (сборка, а не dev — dev-оверлеи не должны попадать в кадр);
|
||
2. запускает Chromium из Playwright, вьюпорт **1440×900** (базовая ширина из §4.5 промта),
|
||
`deviceScaleFactor: 2` — чтобы снимок был сравним с референсом, снятым на macOS при 2x;
|
||
`--size 1280x764` даёт вьюпорт самого референса, когда нужно прямое наложение;
|
||
3. ждёт готовности экрана и загрузки шрифтов (`document.fonts.ready`) — иначе в кадр попадает
|
||
фолбэк-шрифт;
|
||
4. кладёт PNG в `frontend/.shots/<маршрут>.png`.
|
||
|
||
**⚠ Сигнал готовности сменился в S3, и `networkidle` вернуть нельзя.** Живой прогон держит
|
||
SSE-соединение открытым ПО ПОСТРОЕНИЮ — сеть не затихает никогда, и прежнее ожидание
|
||
`waitUntil: 'networkidle'` на маршруте с потоком висело бы до таймаута. Вместо него экран сам
|
||
говорит, когда решил, что рисовать: `document.documentElement.dataset.screen` = `ready`, когда ни
|
||
один запрос не в полёте, и `loading`, пока хоть один летит. «В полёте», а не «pending»: выключенный
|
||
запрос TanStack Query остаётся `pending` навсегда, и счёт по `isPending` держал бы флаг в `loading`
|
||
на давно сошедшемся экране. У маршрута `/loading` финальная картинка — само ожидание, поэтому он
|
||
перечислен в `PENDING_ROUTES` скрипта и снимается на `loading`.
|
||
|
||
**У каждого маршрута свой мир фикстур.** Список маршрутов выводится из `src/api/scenarios.ts`
|
||
и замкнут со вторым списком `scripts/shot.mjs` тестом `src/routes.test.ts`; что означает каждый
|
||
мир — в шапке `src/mock/worlds.ts`. Прозаической копии списка здесь нет намеренно: она отставала
|
||
на этап. Каждый маршрут получает свой кадр и свой прогон axe: ветка, которую не открыть
|
||
по прямой ссылке, не проверяется ничем.
|
||
|
||
**Что делаю я после команды — обязательная часть цикла, а не опция:** открываю PNG инструментом
|
||
чтения файлов, смотрю на него, кладу рядом `references/fleet.png`, называю расхождения словами,
|
||
правлю, снимаю снова.
|
||
|
||
**Два ключа и вторая команда (S3.5).**
|
||
|
||
- `--dpr 1` снимает то, что видит владелец. Дефолтные 2x нужны для сравнения с референсом, но они
|
||
ПРЯЧУТ целый класс дефектов: полупиксель CSS ложится в целый пиксель устройства, и мыло на
|
||
штрихах иконок и на мелком тексте в кадр не попадает. Замечание владельца 17 нашлось только так.
|
||
- `.tooling/py/bin/python scripts/measure.py <кадр> [<кадр>…]` — доли поверхностей, промежутки
|
||
и полосы в CSS. Норма «работать замером, а не на глаз» держится инструментом, а не обещанием.
|
||
Окружение запинено: `scripts/requirements.txt` (Pillow), ставится в `.tooling/py` одной строкой
|
||
из шапки того же файла — системный python на стенде идёт без Pillow и с PEP 668.
|
||
- `npm run scenes` — сценарии ИНТЕРАКЦИЙ (`scripts/scenes.mjs`): клик → кадр → проверка состояния.
|
||
Статичный кадр не принимает вкладки, драг ручки, модалы и спойлеры — они существуют только
|
||
в движении. Падение сценария роняет команду; кадры ложатся в `.shots/scenes/`. Сегодня их
|
||
**одиннадцать** (сверено 02.09 по `scripts/scenes.mjs`, объект `scenes`; прежняя редакция писала
|
||
«девять» — два сценария S4 в неё не попали): `tabs` (модель VS Code + контраст выбранной строки),
|
||
`scroll` (keep-alive, Ф-19), `drag` (залипание полоски), `context` (сводка замечаний),
|
||
`bank` (таблица, спойлер, ширины), `hidden` (таблица, смонтированная в скрытой вкладке),
|
||
`zoom` (раскладка при 125/150/200%), `perf` (кадры прокрутки, поиска и драга на 1200 терминах),
|
||
`intake` (добавление книги), `refusals` (отказы формы), `overlays` (модальные окна).
|
||
|
||
**Стенд без root.** `.tooling/` собирается один раз и в репозиторий не едет (вместе с `.shots/`
|
||
он в `.gitignore`). Chromium не стартует, пока не найдёт `libnss3`, `libnssutil3`, `libnspr4`,
|
||
`libasound.so.2`; в системе их нет, а sudo недоступен, поэтому пакеты выкачиваются и
|
||
распаковываются локально. Тем же способом кладётся шрифт CJK: на стенде не установлено ни одного
|
||
(`fc-list :lang=zh` пуст), и без него весь китайский текст в кадре — квадраты, а плотность колонок
|
||
проверить нечем. `scripts/shot.mjs` сам подставляет `LD_LIBRARY_PATH` и `XDG_DATA_HOME`, когда
|
||
видит `.tooling/root` — думать об этом больше не нужно.
|
||
|
||
```bash
|
||
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 физ.
|
||
|
||
1. **Субпиксельное сглаживание.** У Fleet текст сглажен в серую шкалу (macOS): в статус-полосе
|
||
ровно 0% цветных пикселей. У нас в том же месте 1.29%, в дереве 1.35% против 0.37% —
|
||
это цветная бахрома Chromium. `-webkit-font-smoothing: antialiased` в сбросе действует
|
||
только на macOS. Расхождение принято: §8 промта прямо снимает совпадение по хинтингу,
|
||
а на Windows субпиксельное сглаживание — норма платформы. Гасить его флагом в скриншот-цикле
|
||
не стали: снимок должен показывать то, что рисует настоящий браузер.
|
||
2. **Колонка оригинала приглушена** (`--color-text-secondary`) — это решение, а не замер:
|
||
во Fleet на этом месте подсвеченный код. Пересмотреть на S6, когда читалка будет настоящей.
|
||
3. **`+` стоит только в ряду вкладок левой панели** и запускает «Добавить книгу» (решение
|
||
владельца 04.08); в центре и справа его нет — наборы вкладок там фиксированные, и кнопка
|
||
вышла бы декоративной. Интерактивные состояния снимаются прогоном браузера, а не статичным
|
||
кадром: `--color-raised` подтверждён на наведении кнопки, вкладки и строки списка.
|
||
4. **Боковые панели — пиксели, центр — доля.** Решено в S2 замером и продуктом: боковые получают
|
||
`groupResizeBehavior: "preserve-pixel-size"` и стартуют с замеренных 320px, центр —
|
||
`preserve-relative-size`. На 1920 всю лишнюю ширину забирает читалка, а справочные колонки
|
||
остаются той ширины, под которую нарисованы. Библиотека требует хотя бы одну панель с долевым
|
||
поведением — ею и оказывается центр. Минимум и максимум боковой — новые токены
|
||
`--panel-side-min`/`--panel-side-max`; минимум центра — та же ширина, что у боковой панели
|
||
(читалка уже боковой панели бессмысленна).
|
||
5. **Свёрнутая панель уходит из раскладки вместе со своим разделителем**, а не сжимается
|
||
`collapsible`-ом до нуля: при сжатии на её месте оставался бы промежуток разделителя, и поле
|
||
оболочки переставало быть замеренными 8px. Размеры при этом не теряются — `useDefaultLayout`
|
||
хранит раскладку по СОСТАВУ видимых панелей (ключ storage = id группы + id панелей), и это
|
||
ровно тот случай, под который в библиотеке заведён проп `panelIds`. Проверено исполнением:
|
||
потянул разделитель до 410px → перезагрузка → 410px; свернул → перезагрузка → свёрнута.
|
||
6. **Скроллбар в кадр не попадает.** Chromium на стенде отдаёт оверлейные полосы
|
||
(`offsetWidth - clientWidth = 0`), поэтому в статичном снимке их нет. Что стилизация
|
||
применяется — проверено вычисленными значениями: `scrollbar-width: thin`,
|
||
`scrollbar-color: rgb(53, 55, 57) rgba(0, 0, 0, 0)`. На Windows полосы займут место
|
||
и получат эти цвета.
|
||
|
||
### 5.3. Что выяснилось про инструменты — не переоткрывать
|
||
|
||
- **`eslint-plugin-react-hooks`**: flat-конфиг лежит в `configs.flat['recommended-latest']`.
|
||
Одноимённый ключ верхнего уровня — старого формата, ESLint 10 на нём падает с ошибкой
|
||
про «plugins as array».
|
||
- **Vitest по умолчанию подменяет CSS пустой заглушкой.** Без `test.css: true` контракт-тест
|
||
токенов сверял бы пустоту и был бы вечно зелёным. Проверено: до включения он падал
|
||
на пустой строке, а не проходил.
|
||
- **happy-dom не понимает заявление `@layer a, b;`** — проглатывает весь остаток файла,
|
||
и `getComputedStyle` перестаёт видеть переменные. Поэтому заявление слоёв живёт в `reset.css`,
|
||
а `tokens.css` остаётся чистым (§2.1). Блочную форму `@layer x { }` он тоже не поддерживает.
|
||
- **`structuralSharing` у TanStack Query — ОДИН слот, а не хук рядом с библиотечным.** Функция в
|
||
нём ЗАМЕНЯЕТ `replaceEqualDeep` (`query-core/utils.js`: `replaceData` ветвится по
|
||
`typeof options.structuralSharing === 'function'` и возвращает результат, не вызывая дедуп).
|
||
Наши риды пересобирают строки (`rows.map(narrow.*)`), поэтому без композиции обычный рефетч
|
||
отдаёт кэшу целиком новый граф объектов — а это дерево на 2284 узла и банк на 1200 терминов,
|
||
которые на новой идентичности пересобирают всю коллекцию. Гейт ревизии поэтому КОМПОНУЕТСЯ:
|
||
`kept === next ? replaceEqualDeep(previous, next) : kept`.
|
||
- **`@types/node` держим на мажоре 22**, а не на latest 26: иначе тайпчек разрешает API,
|
||
которых на стенде нет.
|
||
- **`import.meta.glob` по `*.module.css` брать с `?raw`, а не `?inline`:** `?inline` отдаёт уже
|
||
скомпилированный CSS с хешированными именами (`._shell_1abc_1`), и сверять с ним имена
|
||
из TSX бессмысленно.
|
||
### 5.4. Гейты: что проверено живым нарушением и чего они не видят
|
||
|
||
> Прежние **§5.4.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), а здешний абзац ещё учил строить
|
||
две пары счётчиков.
|
||
|
||
Что остаётся правилом ФРОНТА, а не контракта:
|
||
|
||
1. **Единица пары — edit-unit, ~1.9 на главу** (замер движка, ПТ-21): местами вся глава окажется
|
||
одним блоком, и читалка обязана нормально выглядеть в этом случае.
|
||
2. **Одна вертикальная прокрутка на читалку.** Список строк-пар, внутри строки
|
||
`grid-template-columns: 1fr 1fr`. Синхронизации двух прокруток нет и не будет — колонки
|
||
физически не могут разъехаться (`STACK_DECISIONS.md` §2).
|
||
3. **Состояние — меткой СЛОВОМ, цвет только дублирует** и только для внимания и отказа:
|
||
девять состояний точкой не различаются — проверено снимком, три пары совпадали. Правило
|
||
живёт в коде доккомментарием `Tone` (`src/api/vocabulary.ts`), замер — только здесь.
|
||
4. **Денежных полей в интерфейсе нет нигде** (§4.8 промта): ни сумм, ни потолков, ни оценки,
|
||
ни остатка.
|
||
5. **Фикстура ставится в ТРУДНЫЙ случай, а не в удобный.** Правило «прогресс не строить на
|
||
„готово N из M“» было записано здесь ещё на S0 — и витрина всё равно вышла с наивным
|
||
счётчиком глав, потому что фикстура выдумала поле, которого read-модель не отдаёт. Лечение
|
||
сильнее правила: мир фикстур поставлен в состояние, где наивный счётчик дал бы ноль,
|
||
и скриншот-цикл смотрит на трудный случай.
|
||
|
||
---
|
||
|
||
## 7. Pre-commit хук зоны
|
||
|
||
Хук один — pre-commit (запрос владельца 02.08; CI в репозитории отсутствует, до его появления
|
||
хук — единственный машинный рубеж). Зонный фрагмент `scripts/githooks/pre-commit` зовёт тот же
|
||
`npm run check` (один список инструментов, не дубль) только когда в коммите есть frontend-пути;
|
||
плюс блок файлов «никогда не коммитить» и блок смеси `frontend/` с чужой зоной (картина
|
||
инцидентов D39.88). Ставится сам: `npm install` через `prepare` кладёт диспетчер
|
||
в `.git/hooks/pre-commit` (идемпотентно, чужой хук не перетирает). Осознанный обход —
|
||
`git commit --no-verify`.
|
||
|
||
Состав `npm run check` и `check:full` — `STACK_DECISIONS.md` §3 (там он сверен по `package.json`);
|
||
копии состава здесь нет намеренно, она отставала на два пака.
|
||
|