diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md
index 16016735..28701944 100644
--- a/frontend/docs/BACKLOG.md
+++ b/frontend/docs/BACKLOG.md
@@ -13,7 +13,7 @@
| ~~Ф-10~~ | ~~**Хвосты структурных тестов**~~ — **закрыты 04.08 в S2.** `cssModules.test.ts` строит пары по ИМПОРТАМ, а не по совпадению имён файлов, видит деструктуризацию `const { name } = styles` и выкидывает `url(...)` и строковые литералы до поиска классов; проверка сиротства стала «модуль, который никто не импортирует». `tokens.test.ts` получил замок полноты: каждый токен обязан попасть либо в замеры, либо в явный список подобранных, и обратно — список не может ссылаться на исчезнувший токен. Плюс новое: числа для виртуализатора живут в `src/tokens/measures.ts` и сверяются с одноимёнными токенами. Семь живых нарушений, перечень — `FRONTEND_PLAN.md` §5.4 | закрыт | адверсариальное ревью S1 |
| Ф-11 | **Контраст ниже порога WCAG на 5 узлах** (приглушённый текст `#707479` и `#8a8e91` на `#17191a`). Это прямое следствие копирования палитры Fleet, которую владелец принял; axe отчитывается о них отдельно и команду не валит. Решение владельца: оставить как есть ради верности референсу или поднять приглушённые тона | решение владельца | ревью S1 №2 |
| Ф-12 | **Масштаб списков** — **главное закрыто 04.08 в S2 замером, а не обещанием.** Заведён маршрут `/scale`: та же оболочка на настоящем масштабе — 2284 раздела и банк на 1200 терминов (`src/mock/scale.ts` через `getScaleFixture()`). Дерево и банк виртуализованы по умолчанию (RAC `Virtualizer` + `ListLayout`), и это измерено в живом браузере: на 2293 узлах дерева в DOM держится **31 строка**, `scrollHeight` 59 618px, кадр при прокрутке — медиана 22 мс, p95 31 мс; банк на 1200 терминах — 30 строк в DOM, медиана 16,7 мс. Прокрутка до конца и обратно счётчик строк не растит. **Замечания намеренно НЕ виртуализованы:** их список пер-главный, а юнитов на главу ≈1,9 (ПТ-21) и флаг у юнита один — 10³ там не бывает по построению; сводный вид по книге, если он появится, придёт с S6 и получит тот же примитив `ui/List`. **Два хвоста строки НЕ сделаны и получают диспозицию, а не зачёркивание** (нашло адверсариальное ревью S2): дерево осталось плоским без группировки по сотням — это работа экрана глав (S4), у оболочки для неё нет ни данных, ни требования; банк без разделения «подписано / ждёт решения» — это работа подписного экрана (S5), справочному виду в 320px разделение только мешает | масштаб закрыт; группировка — S4, разделение банка — S5 | ревью S1 №2 |
-| Ф-14 | **Вход фронта в контракт API (единый бэклог, строка 95)** — собрать одним списком то, что фронт уже знает и обязан подать автору контракта: язык кодами (не именами) · состояние прогона отдельно от выполнения главы · прогресс пофазно по ЮНИТАМ · ревизия/`Last-Event-ID` для докачки SSE · трёхзначный статус подписи термина (`auto|draft|approved`), а не булев `signed` · поля банка, без которых подпись не работает (`type`/`sense`/`since_ch`). **Своим решением фронт это не заводит** (`BACKLOG.md` шапка: запросы к движку идут строками единого бэклога через оркестратора) — строка держит список готовым к дизайн-промту 95 | к дизайн-промту 95 (до S3) | ревью S1 №3 |
+| Ф-14 | **Вход фронта в контракт API (единый бэклог, строка 95)** — собрать одним списком то, что фронт уже знает и обязан подать автору контракта: язык кодами (не именами) · состояние прогона отдельно от выполнения главы · прогресс пофазно по ЮНИТАМ · ревизия/`Last-Event-ID` для докачки SSE · трёхзначный статус подписи термина (`auto|draft|approved`), а не булев `signed` · поля банка, без которых подпись не работает (`type`/`sense`/`since_ch`) · **ключ строки банка** (ревью оркестратора 04.08): у движка термин уникален по (`src`, `sense`, `since_ch`, `until_ch`), то есть один и тот же `src` законно приходит несколькими строками, а список на экране требует различимого ключа — сейчас он собран из (`src`, `dst`, `type`) и это заплата, а не решение. **Своим решением фронт это не заводит** (`BACKLOG.md` шапка: запросы к движку идут строками единого бэклога через оркестратора) — строка держит список готовым к дизайн-промту 95 | к дизайн-промту 95 (до S3) | ревью S1 №3 |
| Ф-15 | **`src/api/` синхронен, состояний загрузки и ошибки в типах нет** — диспозиция: НЕ чинить сейчас. Два независимых скептика показали, что S2 данных не читает вовсе, а асинхронность приезжает на S3 вместе с MSW и TanStack Query, до первого продуктового экрана; самодельный хук сегодня = работа S3, которую S3 выбросит. Строка держит обязательство S3: ветки ожидания/ошибки/пустоты появляются вместе с MSW, а не после первого экрана | S3 | ревью S1 №3 |
| Ф-13 | **`eslint-plugin-jsx-a11y` не встаёт на ESLint 10** (peer `^3..^9`, проверено `--dry-run` → ERESOLVE). Статическую половину доступности держит axe в скриншот-цикле; плагин подключить, когда выйдет совместимый мажор | триггер: мажор плагина | ревью S1 №2 |
| Ф-7 | **Хвосты сведения витрины с референсом** (`FRONTEND_PLAN.md` §5.2). **Часть S2 закрыта 04.08:** `+` стоит в ряду вкладок ЛЕВОЙ панели и запускает «Добавить книгу» (решение владельца 04.08); дублирующая строка «+ Добавить книгу» из дерева убрана — одна вещь, одно место. В центре и справа `+` нет и не будет: наборы вкладок там фиксированные, пользователь их не расширяет, и кнопка вышла бы декоративной — ровно то, что эта строка запрещает. Состояния наведения перестали быть «непроверенными»: они сняты не статичным кадром, а прогоном браузера (`data-hovered`/`data-selected`/`data-focus-visible` у кнопки, вкладки, строки дерева и списка). **Осталось на S6:** приглушённая колонка оригинала — решение, а не замер, пересмотреть на настоящей читалке | ~~S2~~ · S6 | S1, сверка с fleet.png |
diff --git a/frontend/docs/FRONTEND_PLAN.md b/frontend/docs/FRONTEND_PLAN.md
index 82b12443..d0122878 100644
--- a/frontend/docs/FRONTEND_PLAN.md
+++ b/frontend/docs/FRONTEND_PLAN.md
@@ -441,14 +441,36 @@ JSX-спред `
` со `style` внутри объекта ·
в списке подобранных после удаления из `tokens.css` · число в `measures.ts`, разошедшееся
с одноимённым токеном · модуль стилей, который никто не импортирует.
+**Хвосты ревью оркестратора (04.08), 11 форм — один прогон линтера, 7 падений и 4 прохода.**
+Падают: динамический `import('../mock/book')` · динамический `import('../tokens/tokens.css')` ·
+динамический `import('react-aria-components')` вне `src/ui/` · то же из `src/api/` (фикстуры ему
+можно, примитивы — нет) · динамический импорт моков из `src/ui/` · тернарник в значении
+CSS-переменной (`{'--x': flag ? 'red' : 'blue'}`) · шаблонная строка там же.
+Проходят (и обязаны): динамический импорт `*.module.css` · идентификатор в значении переменной ·
+`import('react-aria-components')` внутри `src/ui/` · `import('../mock/book')` внутри `src/api/`.
+Причина всей пачки одна: `no-restricted-imports` разбирает только ОБЪЯВЛЕНИЕ импорта, а
+`await import(…)` — выражение, поэтому каждый шов продублирован селектором и точечные послабления
+у обеих половин совпадают по имени шва.
+
+**Языковая ветка в CSS (`generality.test.ts`, вторая половина).** Падает `:lang(zh) .source { … }`
+в любом файле стилей — проверено живым нарушением с восстановлением файла из памяти, не из git.
+Комментарии из CSS снимаются перед проверкой: правило объясняется в том же файле, где действует,
+и объяснение содержит запрещённую форму дословно.
+
**Чего гейты по-прежнему не видят — перечислено, потому что «закрыто» без границ и есть та самая
ловушка, о которой этот раздел** (найдено адверсариальным ревью S2, каждая граница воспроизведена):
- **вычисленное значение цвета.** Гейт на цветных атрибутах ловит литерал и шаблонную строку;
`const c = 'red'; `, тернарник, вызов функции и JSX-спред проходят. Синтаксически
это не ловится, а типовое правило под такую проверку в `typescript-eslint` отсутствует;
-- **значение CSS-переменной, собранное выражением** (`style={{'--x': compute()}}`) — проверять
- статически нечего;
+- **значение CSS-переменной, собранное вызовом или вынесенное в идентификатор**
+ (`style={{'--x': compute()}}`, `style={{'--x': value}}`) — проверять статически нечего, а запрет
+ идентификатора убил бы само исключение. Тернарник и шаблонная строка с 04.08 ловятся: это
+ носители литерала, и обход через них был буквальным;
+- **значения стилей в тесте общности.** `generality.test.ts` видит разметку и селекторы, но не
+ значения: именованная гарнитура письменности в токене (`--font-cjk: 'Noto Sans CJK SC', …`)
+ действует как `:lang(zh)`, только молча. Ровно такой токен и приехал в S2 — снял его человек
+ на ревью, не гейт; список именованных фейсов в правиле был бы хрупким по построению;
- **императивный стиль** (`node.style.padding = '13px'`, `setAttribute('style', …)`) — в коде
такого нет ни одного случая, но правилом это не запрещено;
- **отключение TSX-гейта комментарием.** Оно обязано называть правило и нести причину, но проходит;
diff --git a/frontend/docs/FRONTEND_SESSION_PROMPT.md b/frontend/docs/FRONTEND_SESSION_PROMPT.md
index cdc3a54f..0088bfb2 100644
--- a/frontend/docs/FRONTEND_SESSION_PROMPT.md
+++ b/frontend/docs/FRONTEND_SESSION_PROMPT.md
@@ -33,20 +33,17 @@
Ты фронтенд-сессия. **Зона записи — только `frontend/`.** `backend/`, `platform/`, `docs/`, `eval/` — read-only; расхождения и вопросы — пингом владельцу, не правкой чужой зоны.
-### ⚠ Коммит-права и git-протокол (решение владельца 02.08, D39.88 — читать ДО первого коммита)
+### ⚠ Зона и git (читать ДО первой строки кода)
-**Право коммита у тебя ЕСТЬ — но ровно на свою зону: «свою часть коммить, чужую не трогать НИКОГДА».**
+**Ты не коммитишь — лендит оркестратор.** Твоё дело: довести дерево до зелёного `npm run check`,
+описать сделанное в `frontend/docs/PROGRESS.md` и передать.
-1. **Коммить только пути внутри `frontend/`.** Ничего за её пределами — ни `docs/`, ни `backend/`, ни `platform/`, ни корневых файлов, ни `.gitignore`. Нужна правка вне зоны — пинг владельцу, её сделает оркестратор.
-2. **Форма коммита — ТОЛЬКО с явным списком путей:** `git commit -- frontend/<файл> frontend/<файл>`. Это не стилистика, а механика: **голый `git commit` уносит ВЕСЬ индекс**, включая файлы, застейдженные параллельной сессией, — и чужая работа уезжает под твоим сообщением. Так уже произошло ДВАЖДЫ 02.08 (`e9a6bb2`, `b98afb5` унесли доки оркестратора). Pathspec-форма чужой индекс не трогает.
-3. **НИКОГДА `git add -A`, `git add .`, `git commit -a`.** Только явные пути.
-4. **Перед каждым коммитом:** `git status` (опознать чужое) → `git diff --cached --name-only`. Увидел в индексе чужой путь — **не «прибирай» его**, просто коммить pathspec-формой, и он останется на месте.
-5. **Не оставляй свои файлы застейдженными** между шагами: пока они в индексе, их может унести чужой коммит. Стейдж и коммит — одной командой.
-6. **НИКАКИХ** `reset --hard`, `amend`/`rebase` несвежих коммитов, `checkout` поверх грязного дерева, перезаписи истории. Параллельные сессии — норма, их незакоммиченные файлы не трогать.
-7. **Чужое всё-таки уехало в твой коммит?** Историю НЕ переписывать — сообщить владельцу; содержимое цело, теряется только атрибуция.
-8. Никогда не коммитить: `START_PROMT.MD` (файл владельца), `.claude/settings.local.json`.
+1. **Писать только внутрь `frontend/`.** Ничего за её пределами — ни `docs/`, ни `backend/`, ни `platform/`, ни корневых файлов, ни `.gitignore`. Нужна правка вне зоны — пинг владельцу, её сделает оркестратор.
+2. **Чужие незакоммиченные файлы в дереве не трогать.** Параллельные сессии — норма: то, что лежит рядом изменённым, принадлежит не тебе.
+3. **НИКАКИХ** `git add -A`, `git add .`, `reset --hard`, `amend`/`rebase`, `checkout` поверх грязного дерева и любой перезаписи истории.
+4. Никогда не готовить к лендингу: `START_PROMT.MD` (файл владельца), `.claude/settings.local.json`.
-Общие правила проекта — в `CLAUDE.md` в корне (там же эта норма в каноническом виде). Коммиты: английский, одно предложение ≤30 слов, без Co-Authored-By.
+Общие правила проекта — в `CLAUDE.md` в корне (там же эта норма в каноническом виде).
## Что за продукт
diff --git a/frontend/docs/PROGRESS.md b/frontend/docs/PROGRESS.md
index 92760608..b1f25c48 100644
--- a/frontend/docs/PROGRESS.md
+++ b/frontend/docs/PROGRESS.md
@@ -17,12 +17,11 @@
В-2 копирайт.
- **Ждёт движка:** манифест глав и пофазный прогресс (единый бэклог, строки 99–100),
к S5 — машиночитаемая таблица подписи банка (строка 101).
-- `npm run check` зелёный (5 шагов, 62 теста), `npm run check:full` зелёный, гейт доступности
- зелёный на обоих маршрутах. Гейтов стало больше: включён Ф-4 (отступы), закрыты Ф-9 и Ф-10.
+- `npm run check` зелёный (5 шагов, 77 тестов), `npm run check:full` зелёный, гейт доступности
+ зелёный на обоих маршрутах. Гейтов стало больше: включён Ф-4 (отступы), закрыты Ф-9 и Ф-10,
+ по ревью оркестратора 04.08 добавлены динамические импорты швов и языковая ветка в CSS.
- **Карта канона для фронт-сессии — `FRONTEND_PLAN.md` §0.1, транспорт — §0.2.** Заведены
после того, как две ошибки S1 пришли из непрочитанного канона; читать до кода, не после.
-- `npm run check` зелёный (5 шагов, 31 тест, тип-осведомлённый линт), `npm run check:full`
- зелёный, гейт доступности в скриншот-цикле зелёный.
- **Гейты стоят и на коммите:** pre-commit хук (02.08, вторая сессия) гоняет `npm run check`
для коммитов с frontend-путями и блокирует смесь зон и файлы «никогда не коммитить»;
ставится сам при `npm install`. Обход — только осознанный `git commit --no-verify`.
@@ -86,8 +85,13 @@
изобретать API. Формы переписаны по настоящим:
- банк — таблица `glossary` (`backend/internal/store/migrate.go:176`): `src · dst · type · sense ·
- status · source · since_ch/until_ch`. Типы: `name|term|title|place|org|nickname|fullname`,
- статусы подписи ровно три: `auto|draft|approved`, провенанс `seed|ruby|auto|mined`;
+ status · source · since_ch/until_ch`. Статусы подписи ровно три: `auto|draft|approved`,
+ провенанс `seed|ruby|auto` плюс `mined` (ставится майнингом, `pipeline/mining.go:424`).
+ **Типы — исправлено 04.08 по ревью оркестратора:** сама колонка `type` в схеме свободный текст,
+ и её комментарий перечень не закрывает («name|term|title|…»), поэтому истина — гейты в Go:
+ классификатор знает четыре (`terminology/classify.go:15`), банкнота принимает их же плюс
+ `nickname` (`pipeline/banknote.go:74`). Итого пять: `name|place|title|term|nickname`.
+ `org` и `fullname` движок не знает вовсе — они приехали в тип фронта из этой самой строки;
- пары — `ChunkExport` из `tmctl export --pairs` (`backend/internal/pipeline/export.go:25`):
`chapter · chunk_idx · disposition · flag_reason · detail · final_text · source`;
- причины замечаний — enum из `backend/internal/pipeline/disposition.go:56-104`.
@@ -201,6 +205,43 @@ NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фрон
## Хроника
+### 04.08 — ревью оркестратора по S2 (`ece94ef`): что дофиксили
+
+Ревью принято целиком; каждая находка проверена по коду движка ДО правки, а не по формулировке
+ревью. Три главных:
+
+1. **Токен `--font-cjk` нарушал собственный пин** «CJK — системный стек + обязательный `lang`»
+ (`STACK_DECISIONS.md` §2): в нём стояли именованные гарнитуры, и все три — упрощённый
+ китайский (`Noto Sans CJK SC`, `PingFang SC`, `Microsoft YaHei`). Именованный фейс перекрывает
+ языковой подбор браузера, то есть японские кандзи получили бы китайские начертания
+ (унификация Хань) при живом и правильном `lang="ja"`. **Из двух предложенных лечений выбрано
+ первое — снять фейсы, а не скоупить `:lang()`:** языковая ветка в CSS — это ровно та
+ пар-специфика в коде, которую запрещает §2 канона, и новой паре понадобилась бы правка стилей.
+ Токен удалён, у колонки оригинала гарнитуры больше нет: подбор делает браузер по `lang`
+ из данных пары.
+2. **`org` выкинут из `BankTerm['kind']`.** Движок его не знает: закрытый словарь —
+ `terminology/classify.go:15` (четыре типа) и `pipeline/banknote.go:74` (те же плюс `nickname`).
+ Исправлена и строка журнала, из которой этот тип приехал: она приписывала перечень
+ `migrate.go:176`, где перечня нет — там свободный текст и открытый комментарий.
+3. **Ключ ряда банка был `term.source`** — это ломается на полисемии: у движка термин уникален
+ по (`src`, `sense`, `since_ch`, `until_ch`), и один иероглиф законно приходит несколькими
+ строками. До приезда полей (Ф-14) ключ составной из того, что есть.
+
+Мелочь оттуда же: ранг `content_filter` в карте вердиктов = 0, не 1 (`status.go:152` — он в одной
+группе с отказами); в текущем состоянии висели два счёта тестов сразу (31 и 62) — снят устаревший;
+у пары `r2-2` замечание «подписанный термин не применён» было фантомным — в тексте промаха не было,
+теперь он там настоящий («Фан Юэнь» против подписанного «Фан Юань»), иначе витрина показывала
+предупреждение о том, чего на экране нет.
+
+**Хвосты в гейты, тоже из ревью** (каждая форма проверена живым нарушением — 7 падений и 4 прохода
+одним прогоном, `FRONTEND_PLAN.md` §5.4.1): `no-restricted-imports` видит только статический
+импорт, поэтому три шва (моки · глобальный CSS · библиотека примитивов) продублированы селекторами
+на `ImportExpression` — с теми же точечными послаблениями, что у статической половины; в гейте
+CSS-переменной закрыты носители литерала — тернарник и шаблонная строка. Плюс `generality.test.ts`
+получил вторую половину: языковая ветка `:lang(…)` в стилях теперь падает тестом, а не остаётся
+на глазах ревьюера, и в самом тесте названа граница — значения стилей он по-прежнему не видит
+(именно там и пряталась находка №1).
+
### 04.08 — третья фронт-сессия (S2): оболочка трёх панелей, слой `src/ui/`, масштаб и гейты
**Построено.**
diff --git a/frontend/docs/S2_SESSION_PROMPT.md b/frontend/docs/S2_SESSION_PROMPT.md
index 630fd6b5..28d974a5 100644
--- a/frontend/docs/S2_SESSION_PROMPT.md
+++ b/frontend/docs/S2_SESSION_PROMPT.md
@@ -1,8 +1,8 @@
# Промт: фронт-сессия S2 — оболочка трёх панелей и слой `src/ui/`
Ты — фронтенд-сессия TextMachine, третья по счёту. Зона записи — **только `frontend/`**;
-право коммита есть, ровно на свою зону, pathspec-формой (протокол — стоящий промт
-`FRONTEND_SESSION_PROMPT.md` §«Коммит-права», он в силе целиком). Пре-коммит хук уже стоит
+сессия не коммитит — лендит оркестратор (протокол — стоящий промт
+`FRONTEND_SESSION_PROMPT.md` §«Зона и git», он в силе целиком). Пре-коммит хук уже стоит
и самоустанавливается при `npm install`: коммит с frontend-путями не пройдёт без зелёного
`npm run check`, смесь зон и файлы «никогда не коммитить» блокируются. Хук — страховка,
не замена дисциплины.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 07794a6b..9f970b3f 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -42,6 +42,27 @@ const primitives = {
message: 'Примитивы живут в src/ui/. Оболочка и экраны ходят в наши обёртки, не в библиотеку.',
};
+// Те же три шва, но для ДИНАМИЧЕСКОГО импорта: `no-restricted-imports` разбирает только
+// объявление `import … from`, а `await import('../mock/book')` — выражение, и мимо шва проходило
+// всё три раза. Ключи совпадают с именами шаблонов выше, чтобы точечное послабление снималось
+// по имени шва, а не переписыванием селектора. `?inline`-суффикс не ловится ни здесь, ни в
+// статической половине — поведение у двух половин одинаковое, и это осознанно.
+const dynamicSeam = {
+ globalCss: {
+ selector: String.raw`ImportExpression > Literal[value=/\.css$/][value!=/\.module\.css$/]`,
+ message: globalCss.message,
+ },
+ mockFixtures: {
+ selector: String.raw`ImportExpression > Literal[value=/(^|\/)mock\//]`,
+ message: mockFixtures.message,
+ },
+ primitives: {
+ selector: "ImportExpression > Literal[value='react-aria-components']",
+ message: primitives.message,
+ },
+};
+const seams = (...names) => names.map((name) => dynamicSeam[name]);
+
const tokenGate = [
...hexLengths.map((n) => ({
selector: `Literal[value=/#[0-9a-fA-F]{${n}}\\b/]`,
@@ -86,6 +107,14 @@ const tokenGate = [
selector: `JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.value=/^--/] > Literal[value!=${String.raw`/^(--|var\(--|[0-9.]+$)/`}]`,
message: inlineStyle,
},
+ // Носители литерала, которые проверка значения не видит насквозь: `cond ? 'red' : 'blue'`
+ // и `` `${x}px` `` кладут в переменную ровно то, что запрещено строкой выше. Идентификатор
+ // (`'--progress': value`) остаётся разрешённым — иначе исключение теряет смысл; вычисленное
+ // значение проходит через именованную переменную, где оно осознанный шаг, а не инлайн.
+ ...['ConditionalExpression', 'TemplateLiteral'].map((node) => ({
+ selector: `JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.value=/^--/] > ${node}`,
+ message: inlineStyle,
+ })),
// Остаточная дыра из §5.4: `
`, где style приезжает внутри объекта. Запрещаем
// само свойство `style` в объектных литералах — тогда носитель ловится там, где он собран.
{ selector: "Property[key.name='style']", message: inlineStyle },
@@ -128,7 +157,11 @@ export default tseslint.config(
globals: { ...globals.browser, ...globals.node },
},
rules: {
- 'no-restricted-syntax': ['error', ...tokenGate],
+ 'no-restricted-syntax': [
+ 'error',
+ ...tokenGate,
+ ...seams('globalCss', 'mockFixtures', 'primitives'),
+ ],
// Отключение обязано называть правило и объяснять причину. ⚠ Это НЕ полная симметрия
// с CSS-половиной: там `reportDisables` делает ошибкой даже именованное отключение,
// а здесь именованное с причиной проходит и гейт цвета на этой строке снимает. Такого
@@ -170,17 +203,26 @@ export default tseslint.config(
{
// Точка сборки глобальных стилей: сброс, токены и шрифты подключаются здесь и больше нигде.
files: ['src/main.tsx'],
- rules: { 'no-restricted-imports': 'off' },
+ rules: {
+ 'no-restricted-imports': 'off',
+ 'no-restricted-syntax': ['error', ...tokenGate, ...seams('mockFixtures', 'primitives')],
+ },
},
{
// Контракт-тест обязан называть замеренные цвета в лицо — иначе ему нечего сверять.
+ // Гейт цвета снимается, швы остаются: их этому тесту нарушать незачем.
files: ['src/tokens/*.test.ts'],
- rules: { 'no-restricted-syntax': 'off' },
+ rules: {
+ 'no-restricted-syntax': ['error', ...seams('globalCss', 'mockFixtures', 'primitives')],
+ },
},
{
// Единственное место, где легальна библиотека примитивов. Остальные запреты в силе.
files: ['src/ui/**'],
- rules: { 'no-restricted-imports': ['error', { patterns: [globalCss, mockFixtures] }] },
+ rules: {
+ 'no-restricted-imports': ['error', { patterns: [globalCss, mockFixtures] }],
+ 'no-restricted-syntax': ['error', ...tokenGate, ...seams('globalCss', 'mockFixtures')],
+ },
},
{
// Единственное место, где легальны и фикстуры, и сеть.
@@ -189,6 +231,7 @@ export default tseslint.config(
'no-restricted-imports': 'off',
'no-restricted-globals': 'off',
'no-restricted-properties': 'off',
+ 'no-restricted-syntax': ['error', ...tokenGate, ...seams('globalCss', 'primitives')],
},
},
prettier,
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 6ca1f072..a0b24d90 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -62,7 +62,13 @@ export interface Chapter {
export interface BankTerm {
source: string;
target: string;
- kind: 'name' | 'nickname' | 'title' | 'place' | 'org' | 'term';
+ /**
+ * Закрытый словарь типов движка: классификатор знает четыре (`terminology/classify.go:15`),
+ * банкнота принимает их же плюс `nickname` (`pipeline/banknote.go:74`). Колонка `type` в схеме —
+ * свободный текст с открытым перечнем в комментарии (`store/migrate.go:176`), поэтому истина
+ * здесь — гейты в Go, а не DDL; `org` и `fullname` движок не знает вовсе.
+ */
+ kind: 'name' | 'nickname' | 'title' | 'place' | 'term';
signed: boolean;
}
diff --git a/frontend/src/generality.test.ts b/frontend/src/generality.test.ts
index b2df9049..bd41013a 100644
--- a/frontend/src/generality.test.ts
+++ b/frontend/src/generality.test.ts
@@ -1,15 +1,29 @@
import { expect, it } from 'vitest';
// Ревью-вопрос канона по умолчанию: «заработает ли пара языков, которой в репо ещё НЕТ,
-// без правки кода?» (CLAUDE.md §2). На фронте эта норма ломается ровно одним способом —
-// код письменности, зашитый в разметку: `lang="zh"` переживёт приезд ja→ru молча, и японские
-// кандзи отрисуются китайскими начертаниями, а скринридер возьмёт китайский голос.
-// Найдено ревью S1 в живом коде витрины, поэтому проверяется машиной, а не глазами.
+// без правки кода?» (CLAUDE.md §2). На фронте эта норма ломается двумя способами: код
+// письменности, зашитый в разметку (`lang="zh"` переживёт приезд ja→ru молча, и японские кандзи
+// отрисуются китайскими начертаниями, а скринридер возьмёт китайский голос), и языковая ветка
+// в стилях (`:lang(zh) { … }` — та же пар-специфика, только записанная в CSS: новой паре
+// понадобилась бы правка стилей). Первое найдено ревью S1 в живом коде витрины, второе — ревью
+// оркестратора S2, поэтому проверяется машиной, а не глазами.
+//
+// ⚠ ГРАНИЦА, названная явно: тест видит разметку и СЕЛЕКТОРЫ, но не ЗНАЧЕНИЯ стилей. Пар-специфика
+// умеет прятаться и в значении — именованная гарнитура письменности («Noto Sans CJK SC» в токене
+// шрифта) действует как `:lang(zh)`, только молча и без селектора. Ровно такой токен и приехал
+// в S2; снял его человек на ревью, не гейт. Перечень таких границ — `FRONTEND_PLAN.md` §5.4.1.
+// Опции — литерал на каждом вызове: `import.meta.glob` разбирается сборщиком статически
+// и вынесенную константу не принимает.
const components = import.meta.glob('./**/*.tsx', {
query: '?raw',
eager: true,
import: 'default',
});
+const styleSheets = import.meta.glob('./**/*.css', {
+ query: '?raw',
+ eager: true,
+ import: 'default',
+});
it.each(Object.keys(components))('%s не зашивает язык в разметку', (file) => {
const literals = [...(components[file] as string).matchAll(/\blang=(["'][^"']*["'])/g)].map(
@@ -17,3 +31,11 @@ it.each(Object.keys(components))('%s не зашивает язык в разм
);
expect(literals, 'lang берётся из данных книги: lang={book.sourceLanguage}').toEqual([]);
});
+
+it.each(Object.keys(styleSheets))('%s не ветвится по языку', (file) => {
+ // Комментарии снимаются: правило обязано объясняться в том же файле, где действует, а
+ // объяснение содержит запрещённую форму дословно — иначе тест ловит собственную документацию.
+ const css = (styleSheets[file] as string).replaceAll(/\/\*[\S\s]*?\*\//g, '');
+ const selectors = [...css.matchAll(/:lang\([^)]*\)/g)].map((m) => m[0]);
+ expect(selectors, 'языковая специфика живёт в данных пары, а не в селекторе').toEqual([]);
+});
diff --git a/frontend/src/mock/book.ts b/frontend/src/mock/book.ts
index 06fb92b9..9fe9c514 100644
--- a/frontend/src/mock/book.ts
+++ b/frontend/src/mock/book.ts
@@ -87,7 +87,7 @@ export const bankTerms: BankTerm[] = [
// Соответствие, с которого сняты фразы ниже, — чтобы автору контракта было от чего оттолкнуться:
//
// движок | важность (status.go:150-175) | продуктовая фраза
-// content_filter = 1 | attention | Блок не переведён: содержимое отклонено
+// content_filter = 0 | attention | Блок не переведён: содержимое отклонено
// sanitizer_defect = 2 | attention | Блок не переведён: ответ пришёл повреждённым
// loop_degenerate = 3 | attention | Перевод зациклился, блок нужно повторить
// glossary_miss = 5 | attention | Подписанный термин не применён в переводе
@@ -116,10 +116,13 @@ export const pairs: Pair[] = [
note: { severity: 'glance', text: 'Служебная разметка вычищена автоматически' },
},
{
+ // ⚠ «Фан Юэнь» — не опечатка фикстуры, а сам промах: 方源 подписан в банке выше как
+ // «Фан Юань», а перевод пришёл с другой транслитерацией. Замечание обязано быть видно
+ // в тексте, иначе витрина показывает предупреждение о том, чего на экране нет.
id: 'r2-2',
source: '但是现在,方源信了,彻底地信了。铁一般的事实摆在他的面前,不容他反驳——他真的重生了!',
target:
- 'Но теперь Фан Юань уверовал полностью и бесповоротно. Перед ним лежал железный факт, не допускающий возражений: он действительно переродился!',
+ 'Но теперь Фан Юэнь уверовал полностью и бесповоротно. Перед ним лежал железный факт, не допускающий возражений: он действительно переродился!',
note: { severity: 'attention', text: 'Подписанный термин не применён в переводе' },
},
{
diff --git a/frontend/src/mock/scale.ts b/frontend/src/mock/scale.ts
index 6727a4b7..1dd73d58 100644
--- a/frontend/src/mock/scale.ts
+++ b/frontend/src/mock/scale.ts
@@ -73,7 +73,7 @@ const syllables = [
['酒', 'Цзю'],
] as const;
-const kinds: BankTerm['kind'][] = ['name', 'nickname', 'title', 'place', 'org', 'term'];
+const kinds: BankTerm['kind'][] = ['name', 'nickname', 'title', 'place', 'term'];
export const scaleTerms: BankTerm[] = Array.from({ length: 1200 }, (_, index) => {
const first = syllables[index % syllables.length] ?? syllables[0];
diff --git a/frontend/src/showcase/Context.tsx b/frontend/src/showcase/Context.tsx
index 08702dca..706b92be 100644
--- a/frontend/src/showcase/Context.tsx
+++ b/frontend/src/showcase/Context.tsx
@@ -93,7 +93,13 @@ const matches = (query: string) => (term: BankTerm) => {
function termRow(term: BankTerm, sourceLanguage: string): ListRow {
return {
- id: term.source,
+ // Ключом ряда не может быть один `source`: у движка термин уникален по
+ // (`src`, `sense`, `since_ch`, `until_ch`) — `sense` там прямо назван разрешителем полисемии
+ // (`store/migrate.go:176-190`), то есть один и тот же иероглиф законно приходит несколькими
+ // строками с разными смыслами и окнами глав. Совпадение ключей у списка не «сливает
+ // дубликат», а роняет ряд: React выбрасывает второй, RAC теряет выделение. Пока полей
+ // различения нет в контракте (BACKLOG Ф-14), ключ собран из того, что есть.
+ id: [term.source, term.target, term.kind].join('|'),
text: `${term.source} ${term.target}`,
content: (
<>
diff --git a/frontend/src/showcase/Reader.module.css b/frontend/src/showcase/Reader.module.css
index b076923c..f7cc5717 100644
--- a/frontend/src/showcase/Reader.module.css
+++ b/frontend/src/showcase/Reader.module.css
@@ -24,10 +24,13 @@
}
/* Приглушённая колонка оригинала — решение, а не замер: во Fleet на этом месте подсвеченный код.
- Пересмотреть на настоящей читалке (BACKLOG Ф-7). */
+ Пересмотреть на настоящей читалке (BACKLOG Ф-7).
+ Гарнитуру оригиналу НЕ назначаем: подбор по языку делает браузер по `lang` из данных пары
+ (`STACK_DECISIONS.md` §2). Любой именованный фейс здесь пар-специфичен — стек SC отрисовал бы
+ японские кандзи китайскими начертаниями (унификация Хань), а корейскому и латинице не подошёл
+ бы вовсе; `:lang()`-ветка была бы той же пар-спецификой, только записанной в CSS. */
.source {
color: var(--color-text-secondary);
- font-family: var(--font-cjk);
}
.target {
diff --git a/frontend/src/tokens/tokens.css b/frontend/src/tokens/tokens.css
index 2754d19a..06d99d8c 100644
--- a/frontend/src/tokens/tokens.css
+++ b/frontend/src/tokens/tokens.css
@@ -61,10 +61,6 @@
/* --- Типографика; кегли подобраны по высоте глифов, померить их больше негде --- */
--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;
diff --git a/frontend/src/tokens/tokens.test.ts b/frontend/src/tokens/tokens.test.ts
index 89086d35..724dcdf0 100644
--- a/frontend/src/tokens/tokens.test.ts
+++ b/frontend/src/tokens/tokens.test.ts
@@ -66,7 +66,6 @@ const chosen = [
'--space-6',
'--font-ui',
'--font-mono',
- '--font-cjk',
'--font-size-ui',
'--font-size-small',
'--font-size-content',