Land frontend S1: pinned Vite/React/TS scaffold, five-step check with verified colour gate, no-sudo Playwright shot loop, measured tokens.css, showcase converged with Fleet reference

This commit is contained in:
Claude (backend session) 2026-08-02 15:07:34 +03:00
parent 84e2fe0fd7
commit b98afb577d
27 changed files with 6342 additions and 18 deletions

View file

@ -0,0 +1,123 @@
# Промт бэкенд-сессии: РУЧКА ЭФФОРТА + ЭКРАН ЦЕЛЕВОГО ЯЗЫКА + ДОБИВКА ХОЛОДНОГО ПРОГОНА (строки 104 · 46 · 16)
**Выдан 02.08.2026, оркестратор №10; решение владельца по развилке строки 74 (D39.87): вариант (б) — явный `reasoning_effort: "low"` для черновика ПЛЮС правка Go, дающая ручку тем ролям, у которых её нет.** Зона: `backend/` + стенд `~/books/gu-zhenren/coldrun-b/` + свой отчёт в `docs/archive/reports/`. **НЕ коммитить** — лендит оркестратор. **Первый деливерабл — эхо-блок ≤10 строк** (первым сообщением ДО работы: как понял скоуп / три работы и их СТОП-точки / потолки денег / что вне скоупа; подтверждения не жди — работай до СТОП-условий).
**Деньги (жёстко):** работа A = **$0**. Работа B = **$0**. Проба банка на `low` (§3.4) ≤ **$0.10**. Работа C (прогон) ≤ **$5.00** суммарно. ⚠ **$5 — ВНЕШНИЙ предохранитель, и он НЕ первый.** Внутри прогона стоят два суб-бюджета, которые связывают раньше и **гаснут МОЛЧА, без ошибки**: `gates.terminology.budget_usd: 0.05` (≈4 прохода терминолога — WARN `budget would be exceeded by the next batch; the remaining terms are left unconsolidated` + `break`) и `escalation.budget_usd: 0.10` (≈11 хопов — WARN `escalation hop denied by a USD ceiling; keeping the primary flag`, деградация без падения; ровно это усекло наблюдение эскалаций на прошлой пробе). Оба — в `~/books/gu-zhenren/coldrun-b/pipeline.yaml`. **Не спутай молчащий суб-бюджет с отказом модели** — иначе поставишь ложный СТОП; и не подними их молча — правку суб-бюджета объяви в отчёте с причиной. Потолки продублированы в `book.yaml ceilings`: `book_usd: 5.00` И `day_usd: 5.00` (ключа `budget_usd` не существует — строгий декодер упадёт; ⚠ `day_usd` считается по КАЛЕНДАРНОМУ дню UTC и по ЭТОЙ БД, `store/ledger.go:39,47` — через полночь ось обнуляется, несущий предохранитель = `book_usd`). Превышение любого потолка = СТОП+релей. Проба §3.4 живёт в СВОЁМ проекте (свой `book_id`, свой `project_db`) ⇒ её леджер отдельный и в потолок $5 работы C **не входит** — $0.10 сверх; в отчёте выведи ТРИ числа: проба · прогон C · сумма пака. В отчёте деньги — **двумя независимыми путями** (леджер И собственный пересчёт провайдерского `usage` по вендор-ценам); помни: **леджер = НИЖНЯЯ граница** (строка 78: 2xx с битым декодом списывается и в `request_log` не попадает — на прошлой пробе это было $0.0151 неизвестности на 17 оплаченных вызовов).
---
## §0. Рамка и ГЛАВНЫЙ мандат этого пака
**Слово владельца дословно: «элегантно встроить в текущее решение по коду. Чтоб было чисто».** Это не украшение — это приёмочный критерий. Перевожу его в проверяемые вопросы, на каждый из которых отчёт обязан ответить:
1. **«Это новый механизм или использование существующего?»** Правильный ответ — второй. Поле `Stage.Reasoning` существует с самого начала (`config/pipeline.go:208`), проброс на провод существует (`stagerun.go``llm/capability.go`), капабилити-слой различает провайдеров данными. Ничего из этого изобретать заново НЕ надо.
2. **«Появится завтра ЧЕТВЁРТАЯ синтетическая стадия — она унаследует ручку сама или её опять забудут?»** Это главный вопрос пака. Корень дефекта — не «забыли ключ», а **рукописные литералы `config.Stage`, каждый из которых молча теряет поля родителя**: в шиппинг-коде их ровно три (`terminologist.go:515`обе бэнк-роли; `terminologist.go:568` — не-проводная, из неё читается только `Name`; `internal/pipeline/repair.go:384` — ⚠ в дереве ДВА `repair.go`, нужен пакет pipeline; здесь родительская стадия РЕАЛЬНАЯ и несёт выбор владельца, который просто теряется), **плюс ЧЕТВЁРТЫЙ в live-риге** (`internal/pipeline/live_reprobe_test.go`), и он не декоративный — см. §3.4. Правка, чинящая места копипастой, дефект НЕ закрывает — она его тиражирует. Перед стройкой пере-проверь список сам: `grep -rn 'config\.Stage{' --include='*.go' backend/`.
3. **«Заработает ли пара, которой в репо ещё НЕТ, без правки Go?»** и **«вторая книга той же пары — без ложных флагов?»** — обязательны на каждый фикс, как всегда.
4. **«Не появилось ли второго способа сказать то же самое?»** Один шов, одно место решения, никаких параллельных путей.
**Дисциплина:** никаких хаков и ворэраундов; каждый фикс — поддерживаемое мультиязычное решение с тестом. **Архитектурная дыра → СТОП+пинг с диагнозом и вариантами, не чинить на месте.** Комментарии — 12 строки «почему», улики — в отчёт (норма владельца 26.07). **Самопроверка исполнением обязательна** (свой код + сформированные запросы + полученные результаты); **вывод на агрегате до вскрытия единиц запрещён** — перед любым «узел работает» открой хотя бы одну живую единицу с каждой стороны. Дифф `^func Test`**исполнением, не памятью**. В конце — адверсариальный проход author≠reviewer по своим правкам. **Канал вопросов:** непонятно / конфликт промта с кодом / замер бьёт по посылке → СТОП+пинг оркестратору через владельца, НЕ интерпретация. **Право сказать «этого делать не надо»** — есть, с аргументом в отчёте.
---
## §1. Онбординг (карта чтения, целиком доки НЕ читать)
1. `CLAUDE.md` (гардрейлы: `.env` НЕ читать · **DeepSeek thinking НЕ отключать** · аномалия провайдера → вендор-дока, не гадать) → CURRENT-STATE в `docs/PROGRESS.md` (строки **104**, **46**, **74**, **16**, **13б** — твои).
2. **`docs/architecture/05-decisions-log.md` — блоки D39.86 и D39.87 целиком** (приёмка ре-пробы со всеми числами и поправками; решение владельца по развилке + мандат «элегантно и чисто» как приёмочный критерий).
3. **`docs/experiments/00-provider-quirks.md`, секция `DeepSeek-V4-Flash-0731` целиком, особенно п.3а и п.10** — вендор-таблица маппинга эффорта и что именно замерено на боевой форме. Читать ПЕРЕД любым платным вызовом.
4. `docs/archive/reports/COLDRUN_B_DEBUG_2026-08-02.md`**ревю-шапка обязательна** (в ней поправки приёмки; тело отчёта местами точнее шапки не является).
5. `backend/README.md` (инварианты «ЛОМАТЬ НЕЛЬЗЯ») + `docs/architecture/12-go-style-notes.md` §0 (норматив общности).
---
## §2. Что УЖЕ установлено замером — не пере-открывать (приоры, опровергаются аргументом/замером)
- **Блокер — не эхо, а ПУСТЫЕ ответы.** На боевом `max_tokens=8496` 0 из 9 вызовов пригодны: размышление (1521 тыс. симв.) съедает общий бюджет, до ответа модель не доходит. Удвоение потолка НЕ помогает (4/5 пустых при 16992) — вариант «поднять флор» фальсифицирован и закрыт.
- **Причина — вендор-дефолт эффорта.** Дословно из `guides/thinking_mode`: *«Thinking mode is enabled by default, with the default effort being high»*. Маппинг: у `deepseek-v4-flash` `low→low`, `high→high`, `xhigh→high`, `max→max`; **у `deepseek-v4-pro` `low→high`** — то есть на pro ручка эффорта не работает вовсе. Сноска вендора: *«We will update the actual mapped effort of `deepseek-v4-pro` in early August 2026»* — то есть прямо сейчас; при аномалиях у РЕДАКТОРА идти в вендор-доку, а не гадать.
- **`reasoning: "low"` на deepseek-стадии легален и гейт `echoMineViolation` НЕ трогает** (гейт инспектирует МЕХАНИЗМ `capabilities.reasoning.control`, не значение; у deepseek `ReasoningNone`, где `off`/`""` — осознанный no-op, а `low` уходит на провод). Проверено проводом: 22 тела с `reasoning_effort:"low"`, ноль ключей `thinking`, ни одного пустого `reasoning_content` (отчёт: 24/24 разобранных; пере-счёт приёмки по сырому логу — 27/27, разница только в знаменателе) ⇒ **гардрейл «thinking не выключать» не нарушается.**
- **Замеры варианта (б) на 20 чанках:** 20/22 вызовов `finish=stop`, 18/20 чанков отгружено, волна **$0.028195** all-in ($0.019489 только flash), латентность 944 с. Цена: эхо 3/20 · **1 черновик из 18 пришёл ЦЕЛИКОМ НА АНГЛИЙСКОМ и прошёл как `ok`** (это и есть работа B).
- **Ре-ген лечит СТОХАСТИКОЙ, а не бюджетом:** единственный выживший вызов пробы A уложился в 8285 токенов при базовом потолке 8496 и выданных 16992 ⇒ доливать бюджет бессмысленно.
- **Терминолог на 0731 сломан:** 4 батча из 5 `length` с `completion=8000` ровно и пустым телом, банк консолидировал 1 терм из 81 (было 21/21 `stop` до смены весов). **При thinking-off он был здоров 5/5 — но thinking-off это разворот гардрейла, мы его НЕ делаем.**
- ⚠ **Терминолог на `low` НЕ ЗАМЕРЕН НИКЕМ.** Это открытый вопрос твоей §3.4, а не известный факт. Формулировку квирков п.10 «терминолог … ручкой НЕ лечится» читай вместе со следующей фразой того же буллета — «бэнк-роли не имеют ручки эффорта вовсе ⇒ всегда едут вендор-дефолтом `high`»: там сказано «ручки НЕТ», а не «`low` пробовали». Противоречия с этим промтом нет.
---
## §3. Работа A — ручка эффорта для ролей, у которых её нет (строка 104). $0 кода + ≤$0.10 пробы
**Цель:** выбор владельца «сколько думать» должен доезжать до КАЖДОГО платного вызова движка, а не только до стадий книги. Сегодня он доезжает до `draft`/`edit` и теряется у бэнк-ролей (терминолог, классификатор) и у repair.
**Делай РОВНО так (ратифицировано):**
- Значение приходит из **ДАННЫХ** (конфиг), Go не ветвится ни по провайдеру, ни по паре, ни по книге.
- Ручка ран-скоупная, **не пар-данные и не книжный канон** — в пар-пак и в `book.yaml` она не заходит.
- Правка обязана накрыть **все три** места разом и так, чтобы **четвёртое такое же место не могло появиться молча** (см. §0 п.2).
- `repair.go:384`: родительская стадия `st` РЕАЛЬНАЯ и уже несёт выбор владельца — здесь речь не о новом ключе, а о **прекращении его потери**. Доккоммент `repair.go:378-379` («no reasoning setting, so the provider default holds — on DeepSeek that means thinking stays ON and the echo mine is not armed») декларирует посылку, **которую замер фальсифицировал** — он обязан уехать вместе с правкой.
- **Ловушка хеш-оси, закрыть ТЕМ ЖЕ диффом — ОБЕ расщеплённые пары, не одна:** `bankCheckpointExists` строит `Request` БЕЗ `Temperature`/`Reasoning` (`terminologist.go:498-501`), а `runAttempt`С ними (`stagerun.go:403-406`); `Reasoning` входит в `RequestHash` (`internal/pipeline/render.go:279,308` — в дереве ДВА `render.go`, нужен пакет pipeline). **ТА ЖЕ пара есть на пути repair:** `repairCheckpointExists` (`repair.go:350`) собирает `Request` тем же обрезанным способом, и её доккоммент (`repair.go:346-349`) прямо декларирует «mirrors runRepairAttempt's request identity exactly … the two must address the same checkpoint» — правка `:384` без `:350` этот инвариант ФАЛЬСИФИЦИРУЕТ. Сегодня оси совпадают только потому, что значения нулевые. **⚠ Расходятся они в ОБЕ стороны, и вторая — денежная, она и опаснее:** (i) `paid` ложно-**false** — на резюме уже оплаченного прохода бюджетный пред-чек засчитает оценку заново и может оборвать проход до первого вызова; (ii) `paid` ложно-**true** — на стенде, где уже лежат бэнк-чекпойнты, записанные при ПУСТОМ `Reasoning` (а такой стенд у тебя есть — `reprobe/optB`), проба адресует старый хеш и вернёт «оплачено», из-за чего гейт суб-бюджета (`terminologist.go:583`, условие `!paid && …`) не выполнится ВООБЩЕ, а `runAttempt` посчитает новый хеш, чекпойнта не найдёт и уйдёт в свежий ПЛАТНЫЙ вызов **мимо `gates.terminology.budget_usd`** — прикрывать останется только книжный потолок. Оба направления закрываются ОДНИМ диффом и оба обязаны иметь тест: (1) резюм оплаченного пасса бюджетом не режется; (2) свежий пасс на стенде со старыми чекпойнтами гейт бюджета ПРОХОДИТ, а не обходит. Пере-проверь сам, нет ли третьей такой пары: `grep -rn 'RequestHash(Request{' --include='*.go' backend/`.
- **Доккомменты эхо-гейта, тем же диффом (обязанность D39.86 п.2 / D39.87):** `config/models.go:440-448` и `llm/capability.go:52-58` утверждают инвариант «echo-prone провайдер ОБЯЗАН держать thinking на дефолте провайдера». После санкции (б) это неверно: тот же провод легален через `stages[].reasoning` и запрещён через `model.extra_body` (`thinkingControlExtraKeys` содержит сам ключ `reasoning_effort` при любом значении). Гардрейл «thinking не выключать» цел — неполна ДОКУМЕНТАЦИЯ защиты, и следующая сессия прочитает её как полную. Сам гейт **не ослаблять**.
**Реши сам и аргументируй в отчёте:**
- **Форма шва.** Мой приор (опровергается аргументом): корень — рукописные литералы `config.Stage`, теряющие поля; значит лечение — ОДИН шов деривации синтетической стадии, где по каждому полю принято ЯВНОЕ решение, и все три места идут через него. Если найдёшь форму чище — бери свою, но ответь §0 п.2 явно.
- **Гранулярность ручки внутри блока банка.** `gates.terminology` обслуживает ДВЕ роли и допускает ДВЕ РАЗНЫЕ модели (`Model` для терминолога и `ClassifyModel`/`ClassifierModel()` для классификатора). Одна ручка на обе или две — решить и обосновать: у ролей разная форма задачи (длинный рендер против короткой классификации с закрытым словарём), и замер показал, что в стену упирается именно длинная (классификатор при `high` уложился в 1323 токена вывода). Не размножай ключи без нужды, но и не склеивай молча.
- **`Temperature` тем же диффом.** Та же конструкция роняет и её (на провод уходит `temperature: 0`). Сегодня ущерба нет — DeepSeek в thinking-режиме температуру молча игнорирует (quirks) — но при смене модели бэнк-роли поедут на нуле без единой строки конфига, это описывающей. Решить осознанно: пробрасывать, или явно зафиксировать нуль комментарием «почему так правильно».
- **Мульти-провайдерность ручки.** У `control = extra_body_disable` (glm-5/5.1) ПУСТОЙ эффорт означает не «вендор-дефолт», а `thinking:{type:disabled}` — то есть thinking ВЫКЛЮЧЕН, зона эха. Конфиг это допускает: `gates.terminology.model` не ограничен семейством. Дизайн обязан закрывать оба режима осознанно, иначе ручка починит только `ReasoningNone`-семейство и оставит на glm тихий thinking-off.
- **Наш enum против вендорского.** Валидация (`config/pipeline.go:954-957`) принимает `"" | off | low | medium | high` — это НАШ набор, он per-стадийный и **слеп к провайдеру**. У DeepSeek документированы `low/high/max` (+`xhigh` в таблице маппинга): `medium` на deepseek — неопределённое поведение, а `max`/`xhigh` нашим конфигом недостижимы вовсе. Нужно ли валидировать значение против капабилити РЕЗОЛВНУТОЙ модели — твой дизайн-вызов; если да, это касается и новой ручки. Аргументируй решение либо явный отказ.
**§3.4. Проба банка на `low` (≤$0.10, ОБЯЗАТЕЛЬНА перед работой C).** Терминолог на `low` не мерил никто — это открытый вопрос, а не известный факт.
- **⚠ Хост пробы: НЕ `coldrun-a`.** Тот проект — ЗАМОРОЖЕННЫЙ ЭТАЛОН (банк-стоп, подписные сайдкары), на нём держится сравнимость цены строки 16; писать в него ЗАПРЕЩЕНО, только читать. Отдельной команды «прогнать только бэнк-стадию» не существует (`--verify-bank` лишь ОСТАНАВЛИВАЕТ после банка), а майнинг-стоп идёт после ВСЕЙ черновой волны ⇒ проба «с нуля» купила бы ещё одну черновую волну и вышла за потолок.
- **Маршрут (приор, опровергается аргументом): СВОЙ проб-проект, а не резюм чужого.** Заведи `~/books/gu-zhenren/coldrun-b/reprobe/bank-low/` по образцу `reprobe/classify6/`: свой `book_id`, свой `project_db`, свои `ceilings: book_usd 0.10 / day_usd 0.10`, `source_file` = срез coldrun-a **на чтение**, черновая стадия на выбранном уровне. Смета: черновая волна ≈$0.02 + батчи банка ≈$0.01 ⇒ порядок $0.035, влезает с запасом. Так проба изолирована, её леджер отдельный, и провалиться она может не тронув ничего. ⚠ Альтернатива «резюмнуть `optB`, у которого волна уже куплена» дешевле, НО: у него остаток потолка $0.000534 при `book_usd: 0.04` (потрачено $0.039466) — потолок пришлось бы поднимать, а в его БД лежат СТАРЫЕ бэнк-чекпойнты, написанные при пустом `Reasoning`, то есть ровно материал для ложно-true из ловушки §3. Выберешь этот путь — аргументируй и закрой оба риска явно.
- **⚠ ДВА молчащих пути обрыва — критерий обязан их различать.** Пасс бэнк-роли деградирует БЕЗ ошибки и БЕЗ падения: (i) ролевой суб-бюджет `gates.terminology.budget_usd` (`terminologist.go:583-594`) — WARN `budget would be exceeded by the next batch; the remaining terms are left unconsolidated` + `break`; (ii) книжный потолок леджера — отказ резервации. В ОБОИХ случаях `consolidated` проседает ровно так же, как при сломанной модели. **Поэтому вердикт «`low` не работает» имеешь право вынести ТОЛЬКО предъявив, что ни один из двух путей не сработал:** прочитай `RoleSpentUSD` роли, остаток суб-бюджета и `spend` ДО и ПОСЛЕ, и приведи их в отчёте рядом с числом батчей. Иначе получится ложный СТОП, отменяющий работу C на пустом месте.
- **Ответь числами:** сколько батчей `finish=stop` из скольких, сколько термов консолидировано из скольких, `off_language`, цена, и вскрой СЫРОЕ ТЕЛО хотя бы одного батча (прошлая сессия этого не сделала и сама назвала это слабостью). **Критерий:** батчи должны пролезать. Не пролезают на `low` — это НОВАЯ развилка (другой уровень / другая модель бэнк-роли) ⇒ **СТОП+релей владельцу с числами, работу C не начинать.**
- **Классификатор** проверяется тем же заходом ригом 6/6 (`internal/pipeline/live_reprobe_test.go`, build-tag `live`, ~$0.0004). ⚠ **Риг несёт тот самый ЧЕТВЁРТЫЙ рукописный `config.Stage`, а `runBankAttempt` оставляет от переданной стадии только `Name`.** После твоего шва риг ОБЯЗАН ехать через тот же шов — иначе его зелёное 6/6 не доказывает про `low` ничего (ложно-зелёный результат за $0.0004). Это и есть живая проверка вопроса §0 п.2: если риг пришлось править руками отдельно — шов недостаточно элегантен.
---
## §4. Работа B — экран целевого языка (строка 46). $0
**Предмет:** на `low` наблюдался полный связный черновик **на английском**, прошедший гейт черновой стадии как `disposition=ok`. Корень: `classify` экранирует ИСХОДНУЮ письменность, пустоту, обрыв и отказ; «не исходный и не целевой» не ловит ни один предикат. Санитайзер поймал бы, но он стоит **только на последней стадии** (`chunkrun.go:52`, `isFinal`).
**Делай РОВНО так:**
- **Область — выход не-последней стадии КНИГИ, и только её.**`isFinal=false` — это НЕ синоним «черновик»: тем же `runAttempt(..., isFinal=false)` идут бэнк-роли (`terminologist.go`) и repair, а их ответы **законно не в целевой письменности** (таблица терминов несёт исходные ханьские ключи; repair возвращает спан). Экран, повешенный на «любую не-последнюю стадию» буквально, даст 100% ложных флагов на терминологе. Скоуп задай так, чтобы это было невозможно, и покажи в отчёте, ЧЕМ именно он ограничен (роль? позиция в `Stages`? явный признак «это шиппинговая проза»?).
- Экран **данными, не Go-ветвлением**. ⚠ **Носитель `gates.terminology.target_script` НЕ годится** — я его предлагал в черновике промта и снимаю: ключа нет ни в одном шиппинговом конфиге репо, и живёт он под ЧУЖИМ гейтом (при выключенном терминологе не валидируется) ⇒ новый экран оказался бы инертен в проде. Носитель выбери сам и обоснуй: у движка уже есть дата-план целевой стороны (`internal/lang/data/`, `checks.Checkers` с `TargetActive()`/`TargetScriptNonLatin()`, `Book.TargetLang`) — приор в том, что ответ там, а не в новом ключе конфига. Ревью-вопрос: «пара, которой в репо нет, получает экран автоматически или требует правки Go?»
- ⚠ **Это ВЕРДИКТ-ДВИГАЮЩЕЕ изменение.** Порядок: сначала дизайн и пинг с ним, **потом** код. В дизайне обязан назвать развилку, а не предрешать её: **(а)** предикат внутри `classify()` ⇒ бамп `classifierVersion` (`disposition.go:107-118`), который фолдится в снапшот (`snapshot.go:430`) ⇒ снапшот двигается у ВСЕХ книг и голден пере-захватывается; **(б)** отдельный гейт/флаг вне `classify()` ⇒ область фолда другая. Цена (а) на существующей книге — промах ВСЕХ чекпойнтов, `--resnapshot` + пере-покупка; на свежем стенде coldrun-b — ноль. Голден пере-захватывается ОДИН раз под ратифицированную смену поведения, и **маскированный структурный дифф обязан быть пуст**, если менялись только версии/хеши (голден-гард = инвариант №8, D23 п.1).
- ⚠ **labels-фриз работа B двигать НЕ ДОЛЖНА вовсе** — она не трогает предикаты k2/k4a/k4b/k4_inverse/K6. Любое отклонение от чисел §7 = регресс, а не новый базлайн ⇒ СТОП+пинг, не пере-базлайнить.
**Реши сам и аргументируй:**
- **Вторая половина дыры, и она важнее для общности.** `detectLatinInsertion` (`checks/sanitizer.go:228`) внутри санитайзера НЕ обусловлен `TargetScriptNonLatin()`, хотя доккоммент `disposition.go:302-305` объявляет именно эту защиту (реальный гард стоит только на репэйр-пути — `quality.go:215`, `repair.go:208`). Следствие: как только будущая **латинописьменная** цель поедет задокументированным путём расширения (`data/target-<tgt>.txt` в go:embed), детектор сработает на ЧИСТОЙ целевой прозе и дропнет 100% чанков; а частичный файл цели невозможен — `CompileCheckers` паникует, то есть новая цель ОБЯЗАНА включить санитайзер. Ревью-вопрос владельца для →en/→de сегодня отвечается **НЕТ**. Кандидат лечения — тот же шов, что уже применён к репэйру. Твой вызов: чинить это здесь (по-моему — да, это одна работа с первой половиной) или выносить с носителем; ответь явно.
- Что делать с пойманным юнитом: флаг+скип, ре-ген, или эскалация. Помни: `empty`/`length` сегодня retryable, но НЕ escalatable (`disposition.go:142-149`), и ре-ген лечит стохастикой (§2).
---
## §5. Работа C — добивка холодного прогона (строка 16). ≤$5
**Идёт только после того, как работы A и B ЗАКРЫТЫ И ЗАМОРОЖЕНЫ** (код + пере-захваченный голден + зелёная батарея ВМЕСТЕ с ними) и §3.4 зелёная. Причина жёсткая: работа B двигает версию, фолдящуюся в снапшот ⇒ **любая правка B после первого платного вызова C промахивает ВСЕ чекпойнты книги** — смок C1 осиротеет и C2 перекупит его целиком, а «итоговая цена холодного старта» (единственный деливерабл строки 16) станет невыводимой. Работа B застряла на дизайн-пинге → работа C НЕ стартует, это легитимный исход захода.
Стенд `~/books/gu-zhenren/coldrun-b/` собран, $0-проверен и стоит нетронутым: свежая БД, **сид НЕ подключён** (прогон холодный), `pipeline.yaml` побайтно равен эталону coldrun-a кроме шапки, ключи проверены.
**Правка стенда, которую делаешь ТЫ (не подразумевается — делай явно):** в `~/books/gu-zhenren/coldrun-b/pipeline.yaml` черновая стадия сейчас несёт `reasoning: "off"`**это и есть тот no-op, который отправил прошлый прогон в стену 0/9.** Поставь туда выбранный владельцем уровень; комментарий строки («NO-OP у deepseek … эхо-мину НЕ вооружает») после правки неверен и должен уехать вместе с ней. Редакторскую стадию (`deepseek-v4-pro`) НЕ трогай: у pro эффорт не настраивается (§2), `"off"` там останется no-op'ом. Бэнк-роли — по итогам §3.4. Каждую правку стенда перечисли в отчёте построчно.
**Механика ступеней (не гадать):** майнинг-стоп идёт после ВСЕЙ черновой волны. **C1 смок** = `source_file` из 2 глав. **C2 добор** = ДОПИСАТЬ остальные главы в тот же `source_file`, ту же БД, тот же проект — **вторую БД НЕ создавать**, непрерывность автобанка и есть предмет отладки. ⚠ **ЛЕГИТИМНО при доборе:** рост автобанка двигает draft-снапшот; байт-неизменные юниты спасает re-pin (`repin.go`), а изменённые инъекции глав смока перекупятся ПОД порогом консента (`min($0.50, 5%)`, `rebill.go`) — это НЕ тихая перепокупка и НЕ дефект: посчитай, объясни, ложного СТОПа не делай.
**Предохранитель редакторской волны (узел без рычага).** Редактор стенда — `deepseek-v4-pro`, у которого по §2 ручка эффорта НЕ работает (`low→high`), а сама редакторская волна **не гонялась НИ РАЗУ** — ни до смены весов, ни после (coldrun-a закрылся на банк-стопе). То есть в C1 ты впервые запускаешь узел того же класса отказа, что убил черновик и терминолога, и рычага у тебя там нет. **Останови после ПЕРВЫХ 2 edit-юнитов и посмотри `finish_reason`, `completion_tokens` и непустоту `content` на каждом** прежде чем пускать волну целиком. Упор в потолок / пустое тело / `completion` ровно в лимит = тот же блокер ⇒ **СТОП+пинг с числами**, не подбор числа и не «поднимем флор».
- **C1.** Черновая волна → майнинг-стоп → терминолог → банк-пауза `--verify-bank` как ТВОЯ инспекционная точка (не подпись владельца — банк автономен, D39.77): вскрой банк (язык dst · экран целевой письменности · размеры · KWIC-санити · доля ⟨проверить⟩), вскрой ГЛАЗАМИ 23 черновика, сверь деньги двумя путями. Всё объяснимо → продолжай с НЕподписанным банком (пере-запуск `translate` без `--verify-bank`). Редакторская волна → гейты/чекеры → экспорт → вскрой 23 финала глазами.
- **C2.** Остальные ~8 глав тем же порядком (банк-пауза на твоё усмотрение, аргументируй).
- **Обязательные наблюдения per-узел:** счётчики волн/ретраев/эскалаций · эхо-события · упоры `max_tokens` · банкнота: кандидаты→банк и потери на пути · инъекция банка — **подтверждать байт-диффом СЫРОГО ТЕЛА: `LOG_LEVEL=debug LOG_LLM_BODIES=1 ./bin/tmctl translate …` (ТОЛЬКО вместе — при дефолтном `LOG_LEVEL` тела не пишутся вовсе, `main.go:66`), НЕ по `injected_ids`** (там только exact-хиты); ⚠ тела в логе УСЕЧЕНЫ (`obs/logging.go`) — на длинных ответах JSON не парсится, поэтому деньги считай из `request_log`, а не из лога · распределение `flag_reason` · wall-clock по стадиям и итогом · деньги нарастающим итогом двумя путями · **минимум ОДИН намеренный стоп+резюм посреди волны** · бэкапы F4 на каждом платном старте.
- **⚠ ОСИ ГОЛОСА — впервые получат данные (строка 13б).** Флаггер голоса живёт на ФИНАЛЬНОЙ волне (`waverun.go:392,512`), а редакторская волна не гонялась НИ РАЗУ — поэтому на coldrun-a оси показывают `flags=0 over attributed=0 of replies=0`, и это «не считалось», а не «чисто». Твой прогон — первый, где они реально отработают: **вынеси их числа со знаменателями отдельным пунктом отчёта**, это гейт решения владельца по `speech-cue.txt`. ⚠ Помни: поле `rules=` в строке `VOICE` говорит «гейт СКОНФИГУРИРОВАН» (читается из конфига времени отчёта, `quality.go:436`), а не «отработал» — честный дискриминатор это знаменатели.
**Прогноз цены — приор, чтобы ты узнал аномалию, а не просто упёрся в потолок** (опровергается замером; считай свой по ходу): черновая волна 20 чанков на `low` ≈ $0.02 · терминолог ≈ $0.010.02 · редакторская волна — **никогда не гонялась**, оценка из единственного наблюдённого вызова `deepseek-v4-pro` (9059 выходных токенов, $0.008706) на ~14 edit-юнитов ⇒ ≈$0.14. **Итого порядок $0.2 на 10 глав, потолок $5 = запас ×20.** Если фактическая цена уходит за ~$1 — это не «дорого», это СИГНАЛ: останавливайся и смотри, что покупаешь, а не жди потолка. ⚠ Редактор едет на вендор-дефолте `high` и придушить его нельзя (маппинг pro `low→high`), а вендор объявил смену маппинга pro на начало августа — если редакторские вызовы начнут упираться в потолок или возвращать пустое, это тот же класс, что блокер 74, и он требует СТОП+пинга, а не подбора числа.
- **Дебаг-дисциплина:** дефект узла → СТОП волны (чекпойнты держат) → диагноз до корня (аномалия провайдера → официальная вендор-дока, НЕ гадать) → фикс по норме §0 (мультиязычный, с тестом; батарея + голден зелёные ДО resume) → точечный redrive (пере-покупка видима и посчитана). **Заморозка прогона чекпойнтом ради доработки — ЛЕГИТИМНЫЙ исход, не провал.**
---
## §6. Чего НЕ делать
Сид НЕ заводить (прогон холодный) · судья Ф2 не гоняется · DC7-остаток НЕ мерить (строка 98 требует СИДОВОГО прогона — этот бессидовый) · **`backend/configs/models.yaml`: блоки `capabilities` (включая `reasoning`, `min_max_tokens`) и `price` — ВЕРДИКТ-НЕСУЩИЕ** (резолвнутая капабилити фолдится в снапшот): любая их правка обнуляет чекпойнты и перекупает прогон ⇒ трогать ТОЛЬКО до первого платного вызова работы C, с явной записью в отчёт; ослаблять `echoMineViolation` или флаг `echoes_when_thinking_off`**нельзя вообще** · **`docs/` — чужая зона: твоё в ней РОВНО два места** — свой отчёт в `docs/archive/reports/` и секция `## Бэкенд` в `docs/PROGRESS.md`; `docs/experiments/*` (зона полигона, включая `00-provider-quirks.md`), `docs/architecture/*`, `docs/product-requirements.md` — только ЧИТАТЬ, находки для них — пунктом отчёта, правит оркестратор · **thinking у DeepSeek НЕ выключать ни в одной конфигурации** (проба C прошлой сессии была карантинным ЗАМЕРОМ по вопросу владельца, а не разрешением; боевой гейт `echoMineViolation` цел и трогать его не надо) · **проект `~/books/gu-zhenren/coldrun-b/reprobe/optNoThink/` — СПЕНТ-проба, НЕ резюмить** (его `book.yaml` указывает на карантинную копию models.yaml с выключенным thinking) · `CheapGateVersion`/labels-фриз/голден-фикстуры двигать ТОЛЬКО под работу B по процедуре §4 · слаги моделей не менять без live-фактчека `/models` · ручка #77 (ре-ген эха перед эскалацией) сама не включается — она за владельцем · чужое в дереве не трогать: `START_PROMT.MD`, `frontend/`, `platform/`, `.gitignore` (⚠ **фронт-сессия сейчас ЖИВАЯ и коммитит** — перед любой git-операцией `git status` и опознание чужого; `reset --hard`/history-rewrite запрещены) · НЕ коммитить · тексты книги в отчёт НЕ цитировать (сырьё живёт durable в `~/books`).
---
## §7. Отчёт и СТОП
`docs/archive/reports/EFFORT_HANDLE_<дата>.md`: эхо-шапка · **работа A** (форма шва + ответы на четыре вопроса §0 + числа пробы §3.4) · **работа B** (дизайн, что решено по второй половине дыры, маскированный дифф голдена) · **работа C** — per-узел наблюдения, **оси голоса со знаменателями**, **итоговая цена холодного старта ЦЕЛИКОМ, включая редактуру** (это и есть закрытие строки 16) двумя путями · дефекты: найдено / починено (с тестами) / отложено (с носителем) · **критик полноты против себя** (обязательная секция) · **адверсариальный проход по своим фиксам** · **заявление = команда**: каждое число сопровождено командой, которой получено — приёмка их пере-ранит · git-список тронутого (только своё).
Батарея в отчёт числами: `go build ./...` · `go vet ./...` · `go vet -tags live ./internal/pipeline/` · `go test -race -count=1 ./...` · майнер-парити `TM_MINER_PARITY=1 … -run Parity` EXACT · labels-фриз `TM_CHECKER_LABELS=1` (k4b 10/1/36/99 · k4_inverse 3/0/1/71 · k2 34/1/6/51 · K6 1/6/0/251 · k4a 0/0/0/348) · голден `TestGoldenDeterminism` · `gofmt -l`.
Краткий итог — `docs/PROGRESS.md`, секция `## Бэкенд`, допиши СВЕРХУ; бэклог-таблицу и CURRENT-STATE НЕ трогать. D-номер лендинга проставит оркестратор. **СТОП — приёмка оркестратора.**

File diff suppressed because one or more lines are too long

View file

@ -15,7 +15,7 @@
- `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные 0116 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе).
- `research/` — фактура ресёрчей 0122; у принятых — ревью-шапки, часть тел под ⚠ superseded: **читай баннер прежде содержимого**. Ключевые для навигации: 15 голос · 16 ридер-IDE · 17 внешняя критика · 18 рычаги качества · 19 нарезка · 20 банк-майнинг · 21 обзор транспорта · 22 доменные харнессы · 23 шов движок↔платформа (читать перед любым кодом стыка).
- [PROGRESS.md](PROGRESS.md) — журнал: CURRENT-STATE + **ЕДИНЫЙ БЭКЛОГ** (единственный трекер) + живой хвост хроники. НЕ источник решений.
- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен). **Активного бэкендного промта нет:** coldrun-b отработан фазами A/B и заархивирован (D39.86) — фаза C переиздаётся ПОСЛЕ решения владельца по развилке строки 74. Фронт-промт — в чужой зоне `frontend/docs/`.
- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен). [BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md](BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md) (**текущий бэкендный**, D39.87: ручка эффорта для ролей без неё · экран целевого языка · добивка холодного прогона; предшественник coldrun-b отработан фазами A/B и заархивирован, D39.86). Фронт-промт — в чужой зоне `frontend/docs/`.
- Зоны фронта (чужие, читать при касании стыка; каждая ведёт СВОЙ зонный бэклог — единый бэклог их строк не принимает, D39.84): [../frontend/](../frontend/) — веб-интерфейс: промт фронт-сессий S0S7 + [STACK_DECISIONS.md](../frontend/docs/STACK_DECISIONS.md) (пины версий точными числами и ловушки, сверены с вебом 02.08) + [BACKLOG.md](../frontend/docs/BACKLOG.md) · [../platform/](../platform/) — SaaS control plane: README-заглушка + [BACKLOG.md](../platform/BACKLOG.md).
- `archive/` — история ([правила архива](archive/README.md)): закрытые промты (`prompts/`) · отчёты с ревью-шапками (`reports/` — на них ссылаются приёмки) · исполненные арх-доки (`architecture/`) · слайсы хроники `PROGRESS-*.md`. Инструкции оттуда не исполнять.
- Диаграммы: [../backend/docs/components.puml](../backend/docs/components.puml) · [../backend/docs/pipeline.puml](../backend/docs/pipeline.puml) — дом рядом с кодом (D39.80), правятся бэкендом одним коммитом с кодом; вручную НЕ рендерить (владелец смотрит PlantUML-расширением VS Code).

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.86 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
# Журнал решений оркестратора — контракт D1D39.87 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1D39.28; решения D39.29+ живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`.
@ -1271,3 +1271,13 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
**(6) Принято как построенное:** две дыры наблюдаемости закрыты $0 (строки `BANKNOTE` и `VOICE` + 5 тестов, включая тесты молчания; голден/`CheapGateVersion` не тронуты; живая проверка на БД coldrun-a воспроизвела `lines=182 over 18/20 · parse-fail 3`, sha256 БД до/после идентичен) · **live-риг не собирался с D39.64** (`undefined: cjkShare` — per-adapter live conformance физически не запускался; слом бисектнут до коммита D39.64) — починен передачей `SourceScripts` по языку пробы · порог классификатора **6/6 ВЗЯТ** ($0.000390, `finish=stop`), оговорка честна и проверена: `元石`/`灵泉` стоят в `prompts/zh-ru/classifier.md` дословно, остальных четырёх там 0 ⇒ дискриминирующие 4/4 настоящие · девиация «носитель ре-пробы = сам боевой путь вместо расширения live-рига» ПРИНЯТА с аргументом сессии (риг мог бы разойтись с боевой формой по любой оси — ровно класс ошибки D37). **Живыми деньгами доказаны:** нарезка · волна на 4 воркерах · classify/disposition · эскалация одним хопом с ре-гейтом · банкнота · майнер+банк · банк-пауза exit 3 · потолок физически выстрелил на 98.7% и отказал в хопах, не уронив прогон · F4-pre-flight (первый боевой клиент) · graceful SIGINT · **$0-резюм** (20 юнитов из `chunk_status`, 5 чекпойнтов, 0 вызовов, леджер байт-в-байт).
**(7) Строки:** НОВЫЕ **104** (ручки `reasoning`/`temperature` у синтетических стадий — блокер-очереди при выборе (б)/(в′), с пятью констрейнтами дизайна, включая ловушку расхождения хеш-оси `bankCheckpointExists``runAttempt`) и **105** (мис-вердикт классификатора + честность двух новых поверхностей). **46** получила живой носитель, повышена до «скоро» и пере-сформулирована как «экран целевого языка на выходе ЛЮБОЙ не-последней стадии». **44** сужена, **78** получила живое число ($0.015111 = 13% захода), **13б** уточнена («не считалось», не «пусто»), **74** переписана числами и остаётся НА ВЛАДЕЛЬЦЕ — **платных прогонов до его слова нет**. **16 НЕ закрыта** (полная цена холодного старта требует фазы C с редактурой). Промт `BACKEND_COLDRUN_DEBUG_SESSION_PROMPT.md` — в архив с баннером: фазы A/B исполнены, фаза C переиздаётся после развилки (стенд coldrun-b собран, $0-проверен и стоит нетронутым — прогон стартует одной командой) (02.08.2026, оркестратор №10). ✅
## D39.87 — Развилка строки 74 РЕШЕНА владельцем: вариант (б) плюс ручка эффорта для ролей без неё; мандат «элегантно и чисто» ратифицирован как приёмочный критерий (02.08)
Решение владельца по итогам приёмки D39.86 (числа и четыре варианта поданы ему таблицей): **берём (б) — явный `reasoning_effort: "low"` черновой стадии — ВМЕСТЕ с правкой Go, дающей ручку эффорта ролям, у которых её нет (строка 104), и с экраном целевого языка (строка 46) как условием безопасности.** Отклонены: **(а)** — фальсифицирована замером (D39.86); **(в′) thinking OFF** — дешевле всех и терминолог на ней здоров, но это разворот ратифицированного гардрейла `echoes_when_thinking_off`, эхо-мина на 0731 жива (4/20), а качество прозы не судил никто ⇒ принимать нельзя без читки качества; **(г) pro** — ≈×8.9 по цене, и вендор-маппинг `low→high` означает, что на pro рычага эффорта нет вовсе, при объявленной вендором смене маппинга pro на начало августа; **(в) ждать** — вендор молчит.
**Мандат исполнения — слово владельца дословно: «элегантно встроить в текущее решение по коду. Чтоб было чисто».** Ратифицирую его как ПРИЁМОЧНЫЙ КРИТЕРИЙ, а не пожелание, в четырёх проверяемых вопросах: **(1)** правка обязана быть использованием СУЩЕСТВУЮЩЕГО механизма — поле `Stage.Reasoning` (`config/pipeline.go:208`) и проброс `stagerun.go``llm/capability.go` уже есть, новый механизм не строится; **(2)** корень дефекта — не «забыли ключ», а **рукописные литералы `config.Stage`, молча теряющие поля родителя** (в шиппинг-коде их ровно три — `terminologist.go:515` для ОБЕИХ бэнк-ролей, `terminologist.go:568` не-проводная, `repair.go:384`; плюс четвёртый в live-риге), поэтому правка, чинящая три места копипастой, дефект тиражирует: приёмочный вопрос — «появится ЧЕТВЁРТАЯ такая стадия — она унаследует ручку сама или её опять забудут?»; **(3)** оба стоячих вопроса общности на каждый фикс; **(4)** «не появилось ли второго способа сказать то же самое». Ручка — ран-скоупная и из ДАННЫХ; в пар-пак и в `book.yaml` не заходит.
**Обязанность, унаследованная от D39.86 п.2:** санкция (б) тем же диффом приводит в соответствие доккомменты эхо-гейта (`config/models.go:440-448`, `llm/capability.go:52-58`), которые утверждают инвариант «echo-prone провайдер держит thinking на дефолте провайдера» — после (б) тот же провод легален через `stages[].reasoning` и запрещён через `model.extra_body`, и защита перестаёт быть полной. Гардрейл «thinking у DeepSeek не выключать» при этом НЕ ослабляется и остаётся в силе: (б) — уровень эффорта, а не тумблер.
**Выдан промт** `docs/BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md`: работа A (ручка, $0 + проба банка на `low` ≤$0.10 — этот замер не делал НИКТО, `low` у бэнк-ролей открытый вопрос) · работа B (экран целевого языка, $0, вердикт-двигающая ⇒ дизайн и пинг ДО кода) · работа C (добивка холодного прогона ≤$5 — закрывает строку 16 «полная цена холодного старта включая редактуру» и ВПЕРВЫЕ даёт данные по осям голоса для строки 13б, потому что редакторская волна не гонялась ни разу). **Промт холодно аудирован 4-линзовым воркфлоу с независимым арбитражем ДО выдачи** (норма D39.83): **3 блокера и 15 major вправлены** — несуществующая на момент написания ссылка на этот самый блок · платная проба, буквально уводившая прогон в ЗАМОРОЖЕННЫЙ эталон coldrun-a (он держит денежный базлайн строки 16) · та же ловушка хеш-оси на пути repair (`repairCheckpointExists`, `repair.go:350`, чей доккоммент декларирует зеркальность) · область работы B, буквально накрывавшая бэнк-роли и repair, чьи ответы законно НЕ в целевой письменности (100% ложных флагов) · негодный носитель данных для экрана · молчащие суб-бюджеты (`gates.terminology.budget_usd: 0.05`, `escalation.budget_usd: 0.10`), которые связывают РАНЬШЕ потолка $5 · незапрещённая правка вердикт-несущих `capabilities` в `models.yaml` · порядок «B заморожена ДО первого платного вызова C» (иначе сдвиг `classifierVersion` осиротит чекпойнты C1) · пропущенная в промте обязанность про доккомменты гейта · отсутствие явной инструкции переставить ручку ЧЕРНОВИКА в стенде (буквальное прочтение оставляло draft на `"off"` и воспроизвело бы стену 0/9 за $5). Строка 74 → «в работе»; закрывается приёмкой этого пака (02.08.2026, оркестратор №10). ✅

6
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/
# Снимки скриншот-цикла и системные библиотеки Chromium — бинарники в репозиторий не едут.
.shots/
.tooling/

8
frontend/.prettierignore Normal file
View file

@ -0,0 +1,8 @@
node_modules/
dist/
.shots/
.tooling/
package-lock.json
docs/
references/
README.md

View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 100
}

View file

@ -1,6 +1,20 @@
# frontend — веб-интерфейс
Зона записи новой сессии «Фронт». Пусто: заведено под будущий интерфейс, кода ещё нет.
Зона записи сессий «Фронт». Пройдены этапы S0 (план) и S1 (инструменты, скриншот-цикл, токены,
витрина); продуктовых экранов ещё нет — они на S2S7 (`docs/BACKLOG.md`, Ф-1).
## Как запустить
```bash
npm install
npm run dev # http://localhost:5173/showcase
npm run check # prettier → eslint → stylelint → tsc → vitest
npm run check:full # + сборка + скриншот
npm run shot # снимок витрины в .shots/ — открыть и посмотреть глазами
```
Для скриншот-цикла нужен Chromium Playwright и локальные библиотеки в `.tooling/`
(ставятся без sudo, процедура — `docs/FRONTEND_PLAN.md` §4).
## Что здесь будет
@ -16,6 +30,10 @@
область этапов, референсы с измеренными значениями, экраны, ограничения, порядок работы.
- [`docs/STACK_DECISIONS.md`](docs/STACK_DECISIONS.md) — пины версий, ловушки и список того,
что достраивается в движке. Источник: многоагентное исследование 02.08.2026.
- [`docs/FRONTEND_PLAN.md`](docs/FRONTEND_PLAN.md) — план S0: сверенные пины, карта `src/`,
слои каскада, правила поддерживаемости в проверяемой форме, протокол скриншот-цикла,
перепроверенные замеры референса и список расхождений витрины.
- [`docs/BACKLOG.md`](docs/BACKLOG.md) — зонный бэклог фронта.
- `references/` — три скриншота-референса (Fleet, Antigravity).
## Источник внешнего вида — один

View file

@ -7,6 +7,7 @@
| Ф-1 | **Этапы S2S7** (`FRONTEND_SESSION_PROMPT.md`): S2 оболочка + слой `src/ui/` · S3 слой данных/MSW/фикстуры всех состояний · S4 библиотека/загрузка/разбор/прогресс · S5 банк памяти и подпись (самый тяжёлый) · S6 читалка двух колонок и замечания · S7 метаданные/экспорт/настройки/сквозной прогон | по очереди сессий (S0/S1 — текущая) | FRONTEND_SESSION_PROMPT |
| Ф-2 | **React Compiler** — включить ОТДЕЛЬНЫМ шагом после заморозки интерфейса, с CI-проверкой, что вставки реально попали в бандл (в MVP выключен; совместимость держит линт eslint-plugin-react-hooks) | после заморозки UI | STACK_DECISIONS §1 |
| Ф-3 | **Пересмотр TS 6 → TS 7** после выхода TS 7.1 с программным API (~октябрь 2026); решение обратимо — тайпчек не участвует в сборке | триггер: релиз TS 7.1 | STACK_DECISIONS §1 |
| Ф-4 | **Токен-гейт на отступы** (`padding`/`margin`/`gap`/`border-radius`) — вторым шагом, когда шкала токенов зафиксирована (иначе гейт мешает подбору) | после S1-витрины | STACK_DECISIONS §3 |
| Ф-4 | **Токен-гейт на отступы** (`padding`/`margin`/`gap`/`border-radius`) — вторым шагом, когда шкала токенов зафиксирована (иначе гейт мешает подбору) | **триггер наступил**: витрина S1 снята, шкала `--space-1…6` заведена; включать, когда S2 обкатает её на настоящей оболочке | STACK_DECISIONS §3 |
| Ф-7 | **Хвосты сведения витрины с референсом** (`FRONTEND_PLAN.md` §5.2): `+` в конце рядов вкладок — вместе с действием, которое он запускает (S2) · трактовка колонки оригинала приглушённым цветом — решение, а не замер, пересмотреть на настоящей читалке (S6) · состояния наведения витриной не проверены, снимок статичен | S2 · S6 | S1, сверка с fleet.png |
| Ф-5 | **Tauri 2.x вторым шагом** — по явным триггерам (трей · глобальные горячие клавиши · распространяемый .exe · офлайн · хранилище учёток ОС); до того — установимое PWA, только браузерные API | триггеры названы | STACK_DECISIONS §6, D39.84 |
| Ф-6 | **`@tanstack/react-virtual`** — в резерве, подключать только по замеру (дефолт виртуализации — RAC Virtualizer) | по замеру | STACK_DECISIONS §2 |

View file

@ -95,6 +95,26 @@ CJK-шрифт не ставим: иероглифы отдаём системн
Правило зависимостей — сверху вниз, без обратных рёбер:
`features/``ui/` + `api/`; `shell/``ui/`; `ui/``tokens/`. `api/` не знает про React.
### 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. Правила поддерживаемости — в проверяемой форме
@ -115,6 +135,7 @@ CJK-шрифт не ставим: иероглифы отдаём системн
| 8 | Компоненты глупые: данные пропсами, запросы на уровне экрана | ревью-вопрос: есть ли `useQuery` внутри `src/ui/`? Должно быть «нет» |
| 9 | Комментарии — одна-две строки «почему» | ревью глазами; проектная норма |
| 10 | Каждый экран открывается в изоляции: свой маршрут, своя фикстура | **машинно** косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется |
| 11 | `styles.имяКласса` ссылается на существующий класс | **машинно**: `src/cssModules.test.ts`. Vite типизирует модуль как `{ [key: string]: string }`, поэтому опечатка даёт `className="undefined"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась |
**Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ):
добавление нового состояния главы или нового вида замечания правит **один** файл.
@ -130,20 +151,39 @@ CJK-шрифт не ставим: иероглифы отдаём системн
Цель — не «код валиден», а «вид совпал». Смотреть на построенное обязательно, а не предполагать.
**Команда:** `npm run shot [маршрут ...]`. Без аргументов снимает все известные маршруты.
**Команда:** `npm run shot [маршрут ...] [--size ШxВ]`. Без аргументов снимает `/showcase`
при 1440×900.
**Что делает:**
1. поднимает `vite preview` на фиксированном порту (сборка, а не dev — dev-оверлеи не должны попадать в кадр);
2. запускает Chromium из Playwright, вьюпорт **1440×900** (базовая ширина из §4.5 промта), `deviceScaleFactor: 2` — чтобы снимок был сравним с референсом, снятым на macOS при 2x;
3. ждёт `networkidle` и загрузки шрифтов (`document.fonts.ready`) — иначе в кадр попадает фолбэк-шрифт;
1. собирает и поднимает `vite preview` (сборка, а не dev — dev-оверлеи не должны попадать в кадр);
2. запускает Chromium из Playwright, вьюпорт **1440×900** (базовая ширина из §4.5 промта),
`deviceScaleFactor: 2` — чтобы снимок был сравним с референсом, снятым на macOS при 2x;
`--size 1280x764` даёт вьюпорт самого референса, когда нужно прямое наложение;
3. ждёт `networkidle` и загрузки шрифтов (`document.fonts.ready`) — иначе в кадр попадает
фолбэк-шрифт;
4. кладёт PNG в `frontend/.shots/<маршрут>.png`.
**Что делаю я после команды — обязательная часть цикла, а не опция:** открываю PNG инструментом
чтения файлов, смотрю на него, кладу рядом `references/fleet.png`, называю расхождения словами,
правлю, снимаю снова.
`.tooling/` (системные библиотеки Chromium, ставятся без sudo) и `.shots/` — в `.gitignore`.
Бинарники и снимки в репозиторий не едут.
**Стенд без 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
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 у пользователя он есть.
---
@ -160,8 +200,11 @@ CJK-шрифт не ставим: иероглифы отдаём системн
### 5.1. Замеры, перепроверенные в этой сессии
Значения §1.1 промта перепроверены заново по `references/fleet.png` (PIL: гистограмма всего
изображения, срезы строк и столбцов, детект края скругления по порогу яркости). **Все совпали.**
Ниже — сводка с тем, что удалось доснять; это и есть входные числа `tokens.css`.
изображения, срезы строк и столбцов, детект края скругления по порогу яркости).
**Все числа совпали, одна роль разошлась:** §1.1 отдаёт `#353739` и «выбранной строке», и
«активной вкладке», а в кадре это разные вещи — вкладка открытого документа залита `#142f4c`,
вкладка панели `#27292b`, и `#353739` под вкладками не встречается вовсе. В токенах роли
разведены. Ниже — сводка с тем, что удалось доснять; это и есть входные числа `tokens.css`.
Масштаб снимка 2x подтверждён независимо: все четыре промежутка между панелями равны ровно
16 физическим пикселям, что даёт целые 8 CSS-пикселей.
@ -172,8 +215,9 @@ CJK-шрифт не ставим: иероглифы отдаём системн
|---|---|---|
| фон оболочки | `#090909` | 10.45% площади |
| заливка панели | `#17191a` | 80.48% площади |
| приподнятая поверхность / наведение | `#27292b` | 18 196 px |
| выбранная строка, активная вкладка | `#353739` | 37 374 px |
| приподнятая поверхность, наведение, **активная вкладка панели** | `#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% площади |
@ -214,6 +258,60 @@ CJK-шрифт не ставим: иероглифы отдаём системн
⚠ Две палитры в одном приложении не смешиваются. Базовая — Fleet; из Antigravity берётся
**форма** пустого состояния и выноски, не цвета. Токенов Antigravity в `tokens.css` не заводим.
### 5.2. Витрина сведена с референсом — что сошлось и что нет
Витрина снята тем же способом, что и референс (`--size 1280x764`, 2x), и промерена тем же кодом.
**Сошлось до пикселя:** промежутки — четыре по 16 физ.; верхний край панелей — 72 физ.;
радиус — угол выходит на прямую за 12 физ.; шаг строки дерева — 26 CSS; нижняя полоса — 56 физ.;
доли площади поверхностей (панель 83.5% против 80.5%, фон 9.4% против 10.5% — разница от того,
что у нас другое наполнение). Высота глифов статус-полосы совпала точно (23 физ.),
вкладки — 19 против 20 физ.
**Расхождения, названные вслух:**
1. **Субпиксельное сглаживание.** У Fleet текст сглажен в серую шкалу (macOS): в статус-полосе
ровно 0% цветных пикселей. У нас в том же месте 1.29%, в дереве 1.35% против 0.37% —
это цветная бахрома Chromium. `-webkit-font-smoothing: antialiased` в сбросе действует
только на macOS. Расхождение принято: §8 промта прямо снимает совпадение по хинтингу,
а на Windows субпиксельное сглаживание — норма платформы. Гасить его флагом в скриншот-цикле
не стали: снимок должен показывать то, что рисует настоящий браузер.
2. **Колонка оригинала приглушена** (`--color-text-secondary`) — это решение, а не замер:
во Fleet на этом месте подсвеченный код. Пересмотреть на S6, когда читалка будет настоящей.
3. **`+` в конце рядов вкладок** у Fleet есть, у нас нет — добавляется вместе с действием,
которое он будет запускать (S2).
4. **Наведение и прочие интерактивные состояния** витриной не проверены: снимок статичен.
Токен `--color-raised` подтверждён только на активной вкладке.
5. **Боковые панели фиксированы 320px** (замеренная абсолютная ширина при 1280). Тянущиеся
панели на `react-resizable-panels` — S2; тогда же решится, тянуть их долей или пикселями.
6. **Скроллбар в кадр не попадает.** Chromium на стенде отдаёт оверлейные полосы
(`offsetWidth - clientWidth = 0`), поэтому в статичном снимке их нет. Что стилизация
применяется — проверено вычисленными значениями: `scrollbar-width: thin`,
`scrollbar-color: rgb(53, 55, 57) rgba(0, 0, 0, 0)`. На Windows полосы займут место
и получат эти цвета.
### 5.3. Что выяснилось про инструменты — не переоткрывать
- **`eslint-plugin-react-hooks`**: flat-конфиг лежит в `configs.flat['recommended-latest']`.
Одноимённый ключ верхнего уровня — старого формата, ESLint 10 на нём падает с ошибкой
про «plugins as array».
- **Vitest по умолчанию подменяет CSS пустой заглушкой.** Без `test.css: true` контракт-тест
токенов сверял бы пустоту и был бы вечно зелёным. Проверено: до включения он падал
на пустой строке, а не проходил.
- **happy-dom не понимает заявление `@layer a, b;`** — проглатывает весь остаток файла,
и `getComputedStyle` перестаёт видеть переменные. Поэтому заявление слоёв живёт в `reset.css`,
а `tokens.css` остаётся чистым (§2.1). Блочную форму `@layer x { }` он тоже не поддерживает.
- **`@types/node` держим на мажоре 22**, а не на latest 26: иначе тайпчек разрешает API,
которых на стенде нет.
- **`import.meta.glob` по `*.module.css` брать с `?raw`, а не `?inline`:** `?inline` отдаёт уже
скомпилированный CSS с хешированными именами (`._shell_1abc_1`), и сверять с ним имена
из TSX бессмысленно.
- **Гейты и оба структурных теста проверены живым нарушением, а не заявлением:** литерал цвета
в модуле · литерал в сокращённой записи `border` · `font-size` числом · попытка отключить
правило комментарием · hex в TSX · инлайновый стиль с обычным свойством · импорт глобального
CSS мимо `main.tsx` · опечатка в имени класса. Каждый раз проверка падала, после отката —
зелено. Разрешённое исключение `style={{ '--dot': … }}` проходит.
---
## 6. Что уже известно про движок — учтено в форме данных
@ -245,7 +343,9 @@ CJK-шрифт не ставим: иероглифы отдаём системн
Порядок работ S1: каркас → `npm run check` → скриншот-цикл и проверка, что он живой →
`tokens.css` → витрина → контракт-тест токенов → сверка витрины с референсом.
`npm run check` = `prettier --check``eslint``tsc --noEmit``vitest run`.
`npm run check` = `prettier --check``eslint``stylelint``tsc --noEmit``vitest run`.
Шагов пять, а не четыре: `STACK_DECISIONS.md` §3 перечисляет четыре, но там же требует, чтобы
обе половины гейта цвета жили в одной команде, а CSS-половину гоняет именно stylelint.
`npm run check:full` = `check``vite build``shot`. Отдельного e2e-набора в S1 нет:
единственная браузерная проверка — скриншот-цикл, он и стоит в `check:full`.
Git-хуков нет.

78
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,78 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
// Гейт «одно место для цвета и размера», половина в TSX (STACK_DECISIONS §3).
// CSS-половина — в stylelint.config.js.
const colorLiteral =
'Цвет живёт только в src/tokens/tokens.css. В коде — var(--color-...) из .module.css.';
const inlineStyle =
'Инлайновый стиль запрещён: размеры и цвета берутся из .module.css на токенах. ' +
"Единственное исключение — передача CSS-переменной: style={{ '--progress': value }}.";
const tokenGate = [
{ selector: 'Literal[value=/#[0-9a-fA-F]{3}\\b/]', message: colorLiteral },
{ selector: 'Literal[value=/#[0-9a-fA-F]{6}\\b/]', message: colorLiteral },
{ selector: 'Literal[value=/#[0-9a-fA-F]{8}\\b/]', message: colorLiteral },
{ selector: 'Literal[value=/\\b(rgba?|hsla?|oklch|oklab|lab|lch)\\(/]', message: colorLiteral },
{ selector: 'TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}\\b/]', message: colorLiteral },
// Ключ-идентификатор — обычное свойство (color, width); CSS-переменная синтаксически
// обязана быть строковым ключом, поэтому проходит только она.
{
selector: "JSXAttribute[name.name='style'] ObjectExpression > Property[key.type='Identifier']",
message: inlineStyle,
},
{
selector:
"JSXAttribute[name.name='style'] ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]",
message: inlineStyle,
},
{
selector: "JSXAttribute[name.name='style'] > JSXExpressionContainer > Identifier",
message: inlineStyle,
},
];
export default tseslint.config(
{ ignores: ['dist/**', '.shots/**', '.tooling/**'] },
js.configs.recommended,
tseslint.configs.recommended,
// .flat — именно flat-вариант: одноимённый ключ верхнего уровня остался в старом формате.
reactHooks.configs.flat['recommended-latest'],
reactRefresh.configs.vite,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
'no-restricted-syntax': ['error', ...tokenGate],
// Глобальных стилей ровно два файла; всё остальное — CSS Modules, они скоупятся сами.
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/*.css', '!**/*.module.css'],
message:
'Глобальный CSS импортируется только в src/main.tsx. Экранам и примитивам — *.module.css.',
},
],
},
],
},
},
{
// Точка сборки глобальных стилей: сброс, токены и шрифты подключаются здесь и больше нигде.
files: ['src/main.tsx'],
rules: { 'no-restricted-imports': 'off' },
},
{
// Контракт-тест обязан называть замеренные цвета в лицо — иначе ему нечего сверять.
files: ['src/tokens/*.test.ts'],
rules: { 'no-restricted-syntax': 'off' },
},
prettier,
);

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Приложение никогда не индексируется; заголовок X-Robots-Tag ставит платформа, это дубль. -->
<meta name="robots" content="noindex, nofollow" />
<title>TextMachine</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4999
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

48
frontend/package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "textmachine-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.22.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "prettier --check . && eslint . && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run",
"check:full": "npm run check && npm run build && npm run shot",
"shot": "node scripts/shot.mjs"
},
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@fontsource-variable/jetbrains-mono": "5.3.0",
"lucide-react": "1.28.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router": "8.3.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/react": "16.3.2",
"@types/node": "22.20.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"eslint": "10.8.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"globals": "17.8.0",
"happy-dom": "20.11.1",
"lightningcss": "1.33.0",
"playwright": "1.62.1",
"prettier": "3.9.6",
"stylelint": "17.14.1",
"stylelint-config-standard": "40.0.0",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.2.0",
"vitest": "4.1.10"
}
}

60
frontend/scripts/shot.mjs Normal file
View file

@ -0,0 +1,60 @@
// Скриншот-цикл: собрать, поднять preview, снять PNG, положить в .shots/.
// Смотреть на снимок — обязательная часть цикла: без неё код валиден, а вид случаен.
//
// node scripts/shot.mjs все маршруты, 1440x900
// node scripts/shot.mjs /showcase один маршрут
// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения
//
// Маршруты дублируют src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный
// выбор в пользу простоты, пополнять оба.
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = resolve(root, '.shots');
// Chromium и шрифт CJK лежат локально, без sudo и без прав на систему (FRONTEND_PLAN.md §4).
const toolingRoot = resolve(root, '.tooling/root');
if (existsSync(toolingRoot)) {
const libs = resolve(toolingRoot, 'usr/lib/x86_64-linux-gnu');
process.env.LD_LIBRARY_PATH = [libs, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':');
process.env.XDG_DATA_HOME = resolve(toolingRoot, 'usr/share');
}
const { chromium } = await import('playwright');
const { build, preview } = await import('vite');
const args = process.argv.slice(2);
const sizeIndex = args.indexOf('--size');
const [width, height] = (sizeIndex === -1 ? '1440x900' : (args[sizeIndex + 1] ?? ''))
.split('x')
.map(Number);
if (!width || !height) throw new Error('Ожидается --size ШИРИНАxВЫСОТА, например --size 1440x900');
const requested = args.filter((arg, index) => arg.startsWith('/') && index !== sizeIndex + 1);
const routes = requested.length > 0 ? requested : ['/showcase'];
await mkdir(shotsDir, { recursive: true });
await build({ logLevel: 'warn' });
const server = await preview({ preview: { open: false } });
const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '');
if (!origin) throw new Error('vite preview не отдал локальный адрес');
const browser = await chromium.launch();
// deviceScaleFactor 2 — референс снят на macOS при 2x, иначе снимки несравнимы по детализации.
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 2 });
for (const route of routes) {
await page.goto(`${origin}${route}`, { waitUntil: 'networkidle' });
// Без этого в кадр попадает фолбэк-шрифт: метрики и плотность будут не те.
await page.evaluate(() => document.fonts.ready);
const file = resolve(shotsDir, `${route.replace(/^\//, '').replace(/\//g, '-') || 'index'}.png`);
await page.screenshot({ path: file });
console.log(`${route}${file} (${width}x${height} @2x)`);
}
await browser.close();
await server.close();

View file

@ -0,0 +1,47 @@
import { basename } from 'node:path';
import { describe, expect, it } from 'vitest';
// Vite типизирует модуль стилей как { [key: string]: string }, поэтому опечатка в styles.чтоТо
// проходит и тайпчек, и линт, и превращается в className="undefined" — тихо и без следа.
// Поймано на живом коде витрины, поэтому проверяется тестом, а не глазами.
const modules = import.meta.glob('./**/*.module.css', {
query: '?raw',
eager: true,
import: 'default',
});
const components = import.meta.glob('./**/*.tsx', {
query: '?raw',
eager: true,
import: 'default',
});
const classesOf = (css: string) =>
new Set(
css
.replace(/\/\*[\s\S]*?\*\//g, '')
.match(/\.[a-zA-Z][\w-]*/g)
?.map((m) => m.slice(1)),
);
const usedIn = (tsx: string) =>
[...tsx.matchAll(/styles(?:\.(\w+)|\[['"]([\w-]+)['"]\])/g)].map((m) => m[1] ?? m[2] ?? '');
describe('CSS Modules', () => {
const pairs = Object.keys(components)
.map((tsx) => [tsx, tsx.replace(/\.tsx$/, '.module.css')] as const)
.filter(([, css]) => css in modules);
it.each(pairs)('%s не ссылается на несуществующий класс', (tsx, css) => {
const defined = classesOf(modules[css] as string);
const missing = usedIn(components[tsx] as string).filter((name) => !defined.has(name));
expect(missing, `нет в ${basename(css)}`).toEqual([]);
});
it('у каждого модуля стилей есть компонент рядом', () => {
const orphans = Object.keys(modules).filter(
(css) => !(css.replace(/\.module\.css$/, '.tsx') in components),
);
expect(orphans).toEqual([]);
});
});

19
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,19 @@
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import './tokens/reset.css'; // первым: в его шапке объявлен порядок слоёв каскада
import './tokens/tokens.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createBrowserRouter } from 'react-router';
import { routes } from './routes';
const root = document.getElementById('root');
if (!root) throw new Error('Корневой узел #root не найден в index.html');
createRoot(root).render(
<StrictMode>
<RouterProvider router={createBrowserRouter(routes)} />
</StrictMode>,
);

10
frontend/src/routes.tsx Normal file
View file

@ -0,0 +1,10 @@
import { Navigate, type RouteObject } from 'react-router';
import { Showcase } from './showcase/Showcase';
// Один экран — один маршрут: только так экран открывается в изоляции и снимается скриншотом.
// Пополняется вместе со списком маршрутов в scripts/shot.mjs.
export const routes: RouteObject[] = [
{ path: '/', element: <Navigate to="/showcase" replace /> },
{ path: '/showcase', element: <Showcase /> },
];

View file

@ -0,0 +1,245 @@
/* Геометрия целиком из токенов. Модель раскладки снята с fleet.png и сходится точно:
поле оболочки 8 · промежуток 8 · верхняя полоса 28 · статус-полоса 20 (FRONTEND_PLAN.md §5.1). */
.shell {
display: grid;
grid-template-rows: var(--topbar-height) 1fr var(--statusbar-height);
height: 100%;
padding: var(--gap);
background-color: var(--color-shell);
}
.topbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding-inline: var(--space-3);
}
.topbarSide {
display: flex;
gap: var(--space-5);
align-items: center;
color: var(--color-text-secondary);
}
.topbarSide:last-child {
justify-content: flex-end;
}
.topbarTitle {
color: var(--color-text);
}
.body {
display: grid;
grid-template-columns: 320px 1fr 320px;
gap: var(--gap);
min-height: 0;
}
.center {
display: grid;
grid-template-rows: 1.7fr 1fr;
gap: var(--gap);
min-height: 0;
}
/* Панель — скруглённая карточка без рамок и теней: отделяют только промежуток и заливка. */
.panel {
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
border-radius: var(--radius-panel);
background-color: var(--color-panel);
}
.panelHeader {
display: flex;
gap: var(--space-1);
padding: var(--panel-padding) var(--panel-padding) 0;
overflow: hidden;
}
.panelBody {
min-height: 0;
padding: var(--space-1) var(--panel-padding) var(--panel-padding);
overflow: auto;
}
.panelFooter {
display: flex;
gap: var(--space-2);
align-items: center;
height: var(--row-height);
margin-bottom: var(--panel-padding);
padding-inline: calc(var(--panel-padding) + var(--space-2));
color: var(--color-text-secondary);
}
.tab {
display: flex;
align-items: center;
height: var(--panel-header-height);
padding-inline: var(--space-4);
border-radius: var(--radius-control);
color: var(--color-text-secondary);
white-space: nowrap;
}
.tabActive {
background-color: var(--color-raised);
color: var(--color-text);
}
/* Вкладка открытой главы — как вкладка файла во Fleet: синеватая, а не серая. */
.tabDocument.tabActive {
background-color: var(--color-tab-active);
}
.filter {
width: 220px;
height: var(--panel-header-height);
margin-left: auto;
border: 1px solid transparent;
padding-inline: var(--space-4);
border-radius: var(--radius-control);
color: var(--color-text);
}
.filter::placeholder {
color: var(--color-text-muted);
}
.filter:focus {
border-color: var(--color-accent);
outline: none;
}
/* Прозрачная граница вместо отступа: подложка выделения красится по padding-box,
поэтому при шаге строки 26px пилюля выходит ровно 24px, как в референсе. */
.row {
display: flex;
gap: var(--space-2);
align-items: center;
height: var(--row-height);
border-block: var(--row-fill-inset) solid transparent;
padding-inline: var(--space-2);
border-radius: var(--radius-control);
}
.rowNested {
padding-left: var(--space-6);
}
.rowSelected {
background-color: var(--color-selected);
}
.rowCurrent {
background-color: var(--color-current-line);
}
.rowMuted {
color: var(--color-text-muted);
}
.rowTitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chevron {
color: var(--color-text-muted);
}
.dot {
width: 6px;
height: 6px;
margin-left: auto;
border-radius: 50%;
background-color: var(--dot);
}
/* Читалка: один скролл-контейнер, колонки внутри строки-пары — разъехаться не могут. */
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 var(--space-6);
border-left: 2px solid transparent;
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
}
/* Замечание: тонкая полоска у левого края блока и тусклая подпись — без заливки текста. */
.pairNoted {
border-left-color: var(--color-note);
}
.source,
.target {
font-size: var(--font-size-content);
line-height: var(--line-height-content);
white-space: pre-line;
}
.source {
color: var(--color-text-secondary);
font-family: var(--font-cjk);
}
.target {
color: var(--color-text);
}
.note {
grid-column: 1 / -1;
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-small);
}
.bankRow {
display: grid;
grid-template-columns: 104px 1fr 88px 88px;
gap: var(--space-3);
align-items: center;
height: var(--row-height);
border-block: var(--row-fill-inset) solid transparent;
padding-inline: var(--space-2);
border-radius: var(--radius-control);
}
.bankHead {
color: var(--color-text-muted);
font-size: var(--font-size-small);
}
.fact {
display: grid;
grid-template-columns: 92px 1fr;
gap: var(--space-3);
align-items: center;
height: var(--row-height);
padding-inline: var(--space-2);
}
.dim {
overflow: hidden;
color: var(--color-text-secondary);
white-space: nowrap;
text-overflow: ellipsis;
}
.statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: var(--space-3);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.statusRight {
color: var(--color-text-muted);
}

View file

@ -0,0 +1,161 @@
import {
BookText,
ChevronDown,
Download,
FileText,
PanelBottom,
PanelLeft,
PanelRight,
Plus,
Search,
Settings,
} from 'lucide-react';
import type { CSSProperties } from 'react';
import styles from './Showcase.module.css';
import { bankRows, bookFacts, chapters, chapterStates, pairs } from './showcaseData';
// Тонкие линейные монохромные иконки как во Fleet (STACK_DECISIONS §2).
const icon = { size: 16, strokeWidth: 1.5 } as const;
/**
* Витрина не экран продукта, а сверочная страница скриншот-цикла: панели, вкладки,
* строка дерева, строка таблицы и пара оригинал/перевод на реальном тексте.
* Кладётся рядом с references/fleet.png и сводится глазами (docs/FRONTEND_PLAN.md §4).
*/
export function Showcase() {
return (
<div className={styles.shell}>
<header className={styles.topbar}>
<div className={styles.topbarSide}>
<PanelLeft {...icon} />
<PanelBottom {...icon} />
<PanelRight {...icon} />
</div>
<div className={styles.topbarTitle}>Путь книгохранилища</div>
<div className={styles.topbarSide}>
<Search {...icon} />
<Download {...icon} />
<Settings {...icon} />
</div>
</header>
<div className={styles.body}>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={`${styles.tab} ${styles.tabActive}`}>Книги</span>
<span className={styles.tab}>Банк памяти</span>
<span className={styles.tab}>Поиск</span>
</div>
<ul className={styles.panelBody}>
<li className={styles.row}>
<ChevronDown {...icon} className={styles.chevron} />
<BookText {...icon} className={styles.chevron} />
<span className={styles.rowTitle}>Путь книгохранилища</span>
</li>
{chapters.map((chapter, index) => (
<li
key={chapter.title}
className={`${styles.row} ${styles.rowNested} ${index === 2 ? styles.rowSelected : ''}`}
>
<FileText {...icon} className={styles.chevron} />
<span className={styles.rowTitle}>{chapter.title}</span>
{/* Цвет состояния приходит токеном из данных: новое состояние правит один файл */}
<span
className={styles.dot}
title={chapterStates[chapter.state].label}
style={
{ '--dot': `var(${chapterStates[chapter.state].colorToken})` } as CSSProperties
}
/>
</li>
))}
<li className={`${styles.row} ${styles.rowNested} ${styles.rowMuted}`}>
<Plus {...icon} className={styles.chevron} />
<span className={styles.rowTitle}>Добавить главу</span>
</li>
</ul>
<div className={styles.panelFooter}>
<Settings {...icon} className={styles.chevron} />
<span className={styles.rowTitle}>Настройки</span>
</div>
</section>
<div className={styles.center}>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={`${styles.tab} ${styles.tabDocument} ${styles.tabActive}`}>
Глава 1. Перед рассветом
</span>
<span className={`${styles.tab} ${styles.tabDocument}`}>Глава 2. Медный ключ</span>
</div>
<div className={styles.panelBody}>
{pairs.map((pair) => (
<article
key={pair.source}
className={`${styles.pair} ${pair.note ? styles.pairNoted : ''}`}
>
<div className={styles.source} lang="zh">
{pair.source}
</div>
<div className={styles.target} lang="ru">
{pair.target}
</div>
{pair.note && <p className={styles.note}>{pair.note}</p>}
</article>
))}
</div>
</section>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={`${styles.tab} ${styles.tabActive}`}>Банк памяти</span>
{/* Единственное цветное пятно панели акцентная рамка фокуса, как во Fleet.
autoFocus здесь ради снимка: иначе акцент на витрине не проверить. */}
<input className={styles.filter} placeholder="Фильтр терминов" autoFocus />
</div>
<div className={styles.panelBody}>
<div className={`${styles.bankRow} ${styles.bankHead}`}>
<span>Термин</span>
<span>Перевод</span>
<span>Тип</span>
<span>Состояние</span>
</div>
{bankRows.map((row, index) => (
<div
key={row.term}
className={`${styles.bankRow} ${index === 1 ? styles.rowCurrent : ''}`}
>
<span lang="zh">{row.term}</span>
<span>{row.translation}</span>
<span className={styles.dim}>{row.kind}</span>
<span className={styles.dim}>{row.state}</span>
</div>
))}
</div>
</section>
</div>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<span className={`${styles.tab} ${styles.tabActive}`}>О книге</span>
<span className={styles.tab}>Замечания</span>
</div>
<dl className={styles.panelBody}>
{bookFacts.map((fact) => (
<div key={fact.label} className={styles.fact}>
<dt className={styles.dim}>{fact.label}</dt>
<dd className={styles.rowTitle}>{fact.value}</dd>
</div>
))}
</dl>
</section>
</div>
<footer className={styles.statusbar}>
<span>Путь книгохранилища / Глава 1. Перед рассветом</span>
<span className={styles.statusRight}>переведено 12 из 18 · zh ru</span>
</footer>
</div>
);
}

View file

@ -0,0 +1,71 @@
// Материал витрины. Текст реальный — фрагмент backend/example/chapter1-zh.txt с русским
// переводом: выдуманный «Lorem ipsum» дал бы неверную плотность колонок, потому что кириллица
// длиннее латиницы, а иероглифы короче всего.
//
// Это ТОЛЬКО данные витрины. Продуктовые фикстуры живут в src/mock/ и заводятся на S3.
export type ChapterState = 'ready' | 'running' | 'queued' | 'attention';
// Новое состояние главы добавляется здесь и больше нигде: подпись и токен цвета — в одной записи.
export const chapterStates: Record<ChapterState, { label: string; colorToken: string }> = {
ready: { label: 'готово', colorToken: '--color-text-muted' },
running: { label: 'переводится', colorToken: '--color-accent' },
queued: { label: 'в очереди', colorToken: '--color-text-muted' },
attention: { label: 'есть замечания', colorToken: '--color-note' },
};
export const chapters: { title: string; state: ChapterState }[] = [
{ title: 'Глава 1. Перед рассветом', state: 'ready' },
{ title: 'Глава 2. Медный ключ', state: 'attention' },
{ title: 'Глава 3. Книга без названия', state: 'running' },
{ title: 'Глава 4. Звук в глубине', state: 'running' },
{ title: 'Глава 5. Разрешение старейшины', state: 'queued' },
{ title: 'Глава 6. Пыль и старая бумага', state: 'queued' },
];
export type BankState = 'подписан' | 'предложен' | 'правится';
export const bankRows: { term: string; translation: string; kind: string; state: BankState }[] = [
{ term: '美樱', translation: 'Мэйин', kind: 'имя', state: 'подписан' },
{ term: '拓海', translation: 'Тохай', kind: 'имя', state: 'предложен' },
{ term: '藏书阁', translation: 'книгохранилище', kind: 'место', state: 'подписан' },
{ term: '长老', translation: 'старейшина', kind: 'титул', state: 'подписан' },
{ term: '铜钥匙', translation: 'медный ключ', kind: 'предмет', state: 'правится' },
{ term: '师兄', translation: 'старший брат по школе', kind: 'обращение', state: 'предложен' },
{ term: '古籍', translation: 'древний свиток', kind: 'предмет', state: 'предложен' },
];
// Единица пары — edit-unit из экспорта движка, а не абзац: местами вся глава окажется
// одним блоком, и читалка обязана нормально выглядеть в этом случае.
export const pairs: { source: string; target: string; note?: string }[] = [
{
source: '黎明前的藏书阁,被一片寂静笼罩着。',
target: 'Перед рассветом книгохранилище тонуло в тишине.',
},
{
source:
'「师兄,我们真的可以进来吗?」美樱压低声音问道。她的手里,紧握着一把古旧的铜钥匙。\n' +
'「放心,长老已经准许了,」拓海答道,「趁天亮之前,把那卷古籍找出来。」',
target:
'— Брат, нам правда можно сюда? — понизив голос, спросила Мэйин. В руке она сжимала старый медный ключ.\n' +
'— Не тревожься, старейшина уже дал разрешение, — ответил Тохай. — Успеем до света найти тот свиток.',
note: 'Обращение к старшему по школе переведено двумя способами в пределах главы',
},
{
source:
'两人在书架之间静静穿行。窗外,东方的天际泛起一丝鱼肚白。尘埃的气味,与旧纸的清香,在空气中浮动。美樱停下脚步,向一本书伸出手去。',
target:
'Они бесшумно шли между стеллажами. За окном восточный край неба уже отдавал бледной белизной. В воздухе плыл запах пыли и сухой аромат старой бумаги. Мэйин остановилась и потянулась к одной из книг.',
},
];
export const bookFacts: { label: string; value: string }[] = [
{ label: 'Название', value: 'Путь книгохранилища' },
{ label: 'Оригинал', value: 'китайский' },
{ label: 'Перевод', value: 'русский' },
{ label: 'Жанр', value: 'сянься' },
{ label: 'Глав', value: '18' },
{ label: 'Объём', value: '412 тыс. знаков' },
{ label: 'Добавлена', value: '28.07.2026' },
{ label: 'Состояние', value: 'переводится' },
];

View file

@ -0,0 +1,84 @@
/* Сброс второй и последний глобальный файл стилей. Всё остальное CSS Modules.
Импортируется первым: здесь же объявлен порядок каскада, и он обязан быть раньше
любого слоя (ловушка STACK_DECISIONS §7.4).
Сброс ниже всего; стили сторонних примитивов над ним и подключаются только так:
@import 'пакет/styles.css' layer(vendor). Токены и CSS Modules намеренно ВНЕ слоёв:
неслойное правило выигрывает у любого слоя, поэтому ни сброс, ни чужие стили
не могут перебить наши. */
@layer reset, vendor;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background-color: var(--color-shell);
color: var(--color-text);
font-family: var(--font-ui);
font-size: var(--font-size-ui);
line-height: var(--line-height-ui);
/* Референс снят на macOS: субпиксельное сглаживание Chromium делает текст заметно жирнее */
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
p,
ul,
ol,
dl,
dd,
figure {
margin: 0;
}
ul,
ol {
padding: 0;
list-style: none;
}
button,
input,
select,
textarea {
margin: 0;
border: none;
background: none;
color: inherit;
font: inherit;
}
button {
cursor: pointer;
}
svg {
display: block;
flex: none;
}
/* Дефолтные скроллбары Chromium на Windows толстые и светлые на трёх панелях и в читалке
они ломают вид сразу (ловушка STACK_DECISIONS §7.3). */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar-thumb) transparent;
}
::selection {
background-color: var(--color-selection);
}
}

View file

@ -0,0 +1,66 @@
/* Единственный источник цвета, размера и шрифта во всём приложении.
Значения сняты с references/fleet.png замером (гистограмма кадра, срезы строк и столбцов,
детект края скругления по порогу яркости) таблица замеров и способ в docs/FRONTEND_PLAN.md §5.1.
Литеральный цвет легален только здесь; во всех прочих файлах его запрещают гейты
stylelint.config.js и eslint.config.js. */
/* Порядок каскада объявлен в reset.css — он импортируется первым. Токены живут вне слоёв. */
:root {
/* --- Поверхности Fleet --- */
--color-shell: #090909; /* фон оболочки: поля, верхняя и статус-полоса */
--color-panel: #17191a; /* заливка панели; все три панели одинаковы */
--color-raised: #27292b; /* приподнятая поверхность, наведение, активная вкладка панели */
--color-selected: #353739; /* выбранная строка дерева, клавиша-чип */
/* Активная вкладка открытого документа у Fleet синеватая, а не серая: замер вкладки
rpc-node.ts дал #142f4c, тогда как #353739 лежит только под строкой дерева.
§1.1 промта эти две роли смешал здесь они разделены. */
--color-tab-active: #142f4c;
--color-current-line: #152945; /* подсветка строки под курсором */
--color-selection: #164e8d; /* выделение текста */
/* --- Текст --- */
--color-text: #dfe1e3;
--color-text-secondary: #8a8e91; /* статус-полоса, подписи */
--color-text-muted: #707479; /* номера строк, третьестепенное */
/* --- Цвет означает состояние и больше ничего (промт §4.2) --- */
--color-accent: #746deb; /* единственный акцент: фокус ввода */
--color-danger: #b82e45;
--color-note: #2964ad; /* полоска замечания-заметки (antigravity_chat.png) */
--color-hint: #3c7d4f; /* полоска замечания-подсказки */
--color-scrollbar-thumb: #353739;
/* --- Геометрия (CSS-пиксели; снимок референса сделан при 2x) --- */
--gap: 8px; /* промежуток между панелями и поле оболочки — замер: 16 физ. без исключений */
--radius-panel: 6px; /* угол выходит на прямую за 12 физ. */
--radius-control: 6px;
--topbar-height: 28px;
--statusbar-height: 20px;
--panel-header-height: 26px;
--panel-padding: 6px;
--row-height: 26px; /* шаг строки дерева и таблицы */
--row-fill-inset: 1px; /* подложка выбранной строки 24px при шаге 26 */
/* --- Шкала отступов --- */
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 12px;
--space-6: 16px;
/* --- Типографика; кегли подобраны по высоте глифов, померить их больше негде --- */
--font-ui: 'Inter Variable', system-ui, sans-serif;
--font-mono: 'JetBrains Mono Variable', ui-monospace, monospace;
/* CJK отдаём системному стеку; lang на элементе приходит из данных пары */
--font-cjk:
'Noto Sans CJK SC', 'Source Han Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-size-ui: 13px;
--font-size-small: 12px;
--font-size-content: 13px;
--line-height-ui: 18px;
--line-height-content: 21px;
}

View file

@ -0,0 +1,56 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import reset from './reset.css?inline';
import tokens from './tokens.css?inline';
// Контракт-тест токенов: заменяет визуальный гейт с эталонными скриншотами, который отложен
// как флейкующий между платформами (STACK_DECISIONS §3). Числа справа — замер
// references/fleet.png, способ и таблица в docs/FRONTEND_PLAN.md §5.1.
// Значение разошлось с таблицей — это либо новый замер, либо ошибка; тихо править нельзя.
const measured: Record<string, string> = {
'--color-shell': '#090909',
'--color-panel': '#17191a',
'--color-raised': '#27292b',
'--color-selected': '#353739',
'--color-tab-active': '#142f4c',
'--color-current-line': '#152945',
'--color-selection': '#164e8d',
'--color-text': '#dfe1e3',
'--color-text-secondary': '#8a8e91',
'--color-text-muted': '#707479',
'--color-accent': '#746deb',
'--color-danger': '#b82e45',
'--color-note': '#2964ad',
'--color-hint': '#3c7d4f',
'--gap': '8px',
'--radius-panel': '6px',
'--topbar-height': '28px',
'--statusbar-height': '20px',
'--panel-header-height': '26px',
'--panel-padding': '6px',
'--row-height': '26px',
'--row-fill-inset': '1px',
'--line-height-content': '21px',
};
describe('tokens.css', () => {
let style: HTMLStyleElement;
beforeAll(() => {
style = document.createElement('style');
style.textContent = tokens;
document.head.append(style);
});
afterAll(() => style.remove());
it.each(Object.entries(measured))('%s = %s', (name, value) => {
const computed = getComputedStyle(document.documentElement).getPropertyValue(name);
expect(computed.trim()).toBe(value);
});
it('порядок каскада объявлен явно и до первого слоя', () => {
expect(reset.indexOf('@layer reset, vendor;')).toBeGreaterThanOrEqual(0);
expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {'));
});
});

View file

@ -0,0 +1,49 @@
// Гейт «одно место для цвета и размера», половина в CSS (STACK_DECISIONS §3).
// TSX-половина — в eslint.config.js.
//
// Двухслойно: запрет литеральных цветов ловит их в ЛЮБОМ свойстве, включая сокращённые
// записи вроде `border: 1px solid #fff`, а allowed-list добавляет свойства, где литерал
// не цвет (`font-size`, `z-index`). reportDisables делает ошибкой и попытку отключить
// правило комментарием.
const gate = { reportDisables: true };
const token = '/^var\\(--/';
const colorValues = [token, 'inherit', 'currentColor', 'transparent'];
export default {
extends: ['stylelint-config-standard'],
rules: {
'color-no-hex': [true, gate],
'color-named': ['never', gate],
'function-disallowed-list': [
['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'],
gate,
],
'declaration-property-value-allowed-list': [
{
color: colorValues,
'background-color': colorValues,
'border-color': colorValues,
background: [...colorValues, 'none'],
fill: [...colorValues, 'none'],
stroke: [...colorValues, 'none'],
'font-size': [token, 'inherit'],
'z-index': [token],
},
{ ...gate, message: 'Значение только из токена: var(--...) из src/tokens/tokens.css' },
],
// CSS Modules читаются из TS как styles.panelHeader, поэтому классы camelCase.
'selector-class-pattern': ['^[a-z][a-zA-Z0-9]*$', { message: 'Имя класса — camelCase' }],
},
overrides: [
{
// Единственное место, где литеральный цвет легален: сами токены.
files: ['src/tokens/tokens.css'],
rules: {
'color-no-hex': null,
'color-named': null,
'function-disallowed-list': null,
'declaration-property-value-allowed-list': null,
},
},
],
};

30
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"jsx": "react-jsx",
// Ловушка STACK_DECISIONS §7.2: "types" по умолчанию теперь [], а не ["*"]
// без этой строки молча пропадают глобальные типы Node и Vite.
"types": ["node", "vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"erasableSyntaxOnly": true,
"allowJs": true,
"checkJs": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "scripts", "vite.config.ts"]
}

17
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
// Конфиг держим минимальным: в Vite 8 (Rolldown) часть старых опций стала молчаливым no-op,
// поэтому каждая строка ниже добавлена осознанно и с причиной.
export default defineConfig({
plugins: [react()],
// Lightning CSS вместо esbuild на минификации — пин STACK_DECISIONS §2.
build: { cssMinify: 'lightningcss' },
test: {
environment: 'happy-dom',
// По умолчанию Vitest отдаёт вместо CSS пустую заглушку — тогда контракт-тест токенов
// проверял бы пустоту и всегда был бы зелёным.
css: true,
include: ['src/**/*.test.{ts,tsx}'],
},
});