From 02872b6cc1c12ff1896a23a00a444c635c00f0fa Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 4 Aug 2026 02:59:24 +0300 Subject: [PATCH] Close S2 review findings: pair-agnostic source font via lang, bank kinds match engine vocabulary, composite bank row keys, dynamic-import seams and literal-carrier selectors gated, generality test covers CSS --- frontend/docs/BACKLOG.md | 2 +- frontend/docs/FRONTEND_PLAN.md | 26 +++++++++++- frontend/docs/FRONTEND_SESSION_PROMPT.md | 19 ++++----- frontend/docs/PROGRESS.md | 53 +++++++++++++++++++++--- frontend/docs/S2_SESSION_PROMPT.md | 4 +- frontend/eslint.config.js | 51 +++++++++++++++++++++-- frontend/src/api/types.ts | 8 +++- frontend/src/generality.test.ts | 30 ++++++++++++-- frontend/src/mock/book.ts | 7 +++- frontend/src/mock/scale.ts | 2 +- frontend/src/showcase/Context.tsx | 8 +++- frontend/src/showcase/Reader.module.css | 7 +++- frontend/src/tokens/tokens.css | 4 -- frontend/src/tokens/tokens.test.ts | 1 - 14 files changed, 180 insertions(+), 42 deletions(-) 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',