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:
parent
84e2fe0fd7
commit
b98afb577d
27 changed files with 6342 additions and 18 deletions
123
docs/BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md
Normal file
123
docs/BACKEND_EFFORT_HANDLE_SESSION_PROMPT.md
Normal 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. **«Не появилось ли второго способа сказать то же самое?»** Один шов, одно место решения, никаких параллельных путей.
|
||||
|
||||
**Дисциплина:** никаких хаков и ворэраундов; каждый фикс — поддерживаемое мультиязычное решение с тестом. **Архитектурная дыра → СТОП+пинг с диагнозом и вариантами, не чинить на месте.** Комментарии — 1–2 строки «почему», улики — в отчёт (норма владельца 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 вызовов пригодны: размышление (15–21 тыс. симв.) съедает общий бюджет, до ответа модель не доходит. Удвоение потолка НЕ помогает (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), латентность 9–44 с. Цена: эхо 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-санити · доля ⟨проверить⟩), вскрой ГЛАЗАМИ 2–3 черновика, сверь деньги двумя путями. Всё объяснимо → продолжай с НЕподписанным банком (пере-запуск `translate` без `--verify-bank`). Редакторская волна → гейты/чекеры → экспорт → вскрой 2–3 финала глазами.
|
||||
- **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.01–0.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
|
|
@ -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; остальные 01–16 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе).
|
||||
- `research/` — фактура ресёрчей 01–22; у принятых — ревью-шапки, часть тел под ⚠ 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/) — веб-интерфейс: промт фронт-сессий S0–S7 + [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).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.86 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07)
|
||||
# Журнал решений оркестратора — контракт D1–D39.87 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1–D39.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
6
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
||||
# Снимки скриншот-цикла и системные библиотеки Chromium — бинарники в репозиторий не едут.
|
||||
.shots/
|
||||
.tooling/
|
||||
8
frontend/.prettierignore
Normal file
8
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.shots/
|
||||
.tooling/
|
||||
package-lock.json
|
||||
docs/
|
||||
references/
|
||||
README.md
|
||||
4
frontend/.prettierrc.json
Normal file
4
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
@ -1,6 +1,20 @@
|
|||
# frontend — веб-интерфейс
|
||||
|
||||
Зона записи новой сессии «Фронт». Пусто: заведено под будущий интерфейс, кода ещё нет.
|
||||
Зона записи сессий «Фронт». Пройдены этапы S0 (план) и S1 (инструменты, скриншот-цикл, токены,
|
||||
витрина); продуктовых экранов ещё нет — они на S2–S7 (`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).
|
||||
|
||||
## Источник внешнего вида — один
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
| Ф-1 | **Этапы S2–S7** (`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 |
|
||||
|
|
|
|||
|
|
@ -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
78
frontend/eslint.config.js
Normal 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
14
frontend/index.html
Normal 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
4999
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
frontend/package.json
Normal file
48
frontend/package.json
Normal 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
60
frontend/scripts/shot.mjs
Normal 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();
|
||||
47
frontend/src/cssModules.test.ts
Normal file
47
frontend/src/cssModules.test.ts
Normal 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
19
frontend/src/main.tsx
Normal 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
10
frontend/src/routes.tsx
Normal 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 /> },
|
||||
];
|
||||
245
frontend/src/showcase/Showcase.module.css
Normal file
245
frontend/src/showcase/Showcase.module.css
Normal 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);
|
||||
}
|
||||
161
frontend/src/showcase/Showcase.tsx
Normal file
161
frontend/src/showcase/Showcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
frontend/src/showcase/showcaseData.ts
Normal file
71
frontend/src/showcase/showcaseData.ts
Normal 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: 'переводится' },
|
||||
];
|
||||
84
frontend/src/tokens/reset.css
Normal file
84
frontend/src/tokens/reset.css
Normal 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);
|
||||
}
|
||||
}
|
||||
66
frontend/src/tokens/tokens.css
Normal file
66
frontend/src/tokens/tokens.css
Normal 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;
|
||||
}
|
||||
56
frontend/src/tokens/tokens.test.ts
Normal file
56
frontend/src/tokens/tokens.test.ts
Normal 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 {'));
|
||||
});
|
||||
});
|
||||
49
frontend/stylelint.config.js
Normal file
49
frontend/stylelint.config.js
Normal 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
30
frontend/tsconfig.json
Normal 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
17
frontend/vite.config.ts
Normal 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}'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue