From 8be1e41ee19f488fe013cdcb2ccba1ba755332f0 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 2 Aug 2026 18:04:06 +0300 Subject: [PATCH] Move run state from chapters to books, derive language and phased progress from data, extend the network gate to every transport, and lock route and lang literals with tests --- frontend/docs/BACKLOG.md | 2 + frontend/docs/FRONTEND_PLAN.md | 72 ++++++++++++- frontend/docs/FRONTEND_SESSION_PROMPT.md | 2 + frontend/docs/PROGRESS.md | 58 +++++++++- frontend/eslint.config.js | 27 ++++- frontend/src/api/index.ts | 14 ++- frontend/src/api/types.ts | 39 +++++-- frontend/src/generality.test.ts | 19 ++++ frontend/src/mock/book.ts | 67 ++++++++---- frontend/src/routes.test.ts | 28 +++++ frontend/src/showcase/Showcase.module.css | 70 +++++++----- frontend/src/showcase/Showcase.tsx | 126 ++++++++++++++-------- frontend/src/tokens/tokens.test.ts | 5 +- 13 files changed, 414 insertions(+), 115 deletions(-) create mode 100644 frontend/src/generality.test.ts create mode 100644 frontend/src/routes.test.ts diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md index e8dd17f8..ea18cafc 100644 --- a/frontend/docs/BACKLOG.md +++ b/frontend/docs/BACKLOG.md @@ -13,6 +13,8 @@ | Ф-10 | **Хвосты структурных тестов**: `cssModules.test.ts` строит пары по имени файла (компонент, импортирующий соседний модуль, из проверки выпадает), не видит деструктуризацию `const { shell } = styles`, а `classesOf` тянет в «объявленные классы» расширения из `url(...)`. `tokens.test.ts` сверяет 23 токена из 38 и не имеет замка полноты — новый токен приезжает без гейта по построению | до S2 | адверсариальное ревью S1 | | Ф-11 | **Контраст ниже порога WCAG на 5 узлах** (приглушённый текст `#707479` и `#8a8e91` на `#17191a`). Это прямое следствие копирования палитры Fleet, которую владелец принял; axe отчитывается о них отдельно и команду не валит. Решение владельца: оставить как есть ради верности референсу или поднять приглушённые тона | решение владельца | ревью S1 №2 | | Ф-12 | **Масштаб списков.** Раскладка нарисована под 10 глав и 11 терминов, а статус-строка того же экрана говорит 2284 раздела и 7,78 млн знаков. Дерево — плоский список без группировки и виртуализации, банк — без разделения «подписано / ждёт решения», замечания — без сводного вида. Требование к S2: каждый список получает поведение при 10³ элементов ДО того, как рисуется | S2 | ревью S1 №2 | +| Ф-14 | **Вход фронта в контракт API (единый бэклог, строка 95)** — собрать одним списком то, что фронт уже знает и обязан подать автору контракта: язык кодами (не именами) · состояние прогона отдельно от выполнения главы · прогресс пофазно по ЮНИТАМ · ревизия/`Last-Event-ID` для докачки SSE · трёхзначный статус подписи термина (`auto|draft|approved`), а не булев `signed` · поля банка, без которых подпись не работает (`type`/`sense`/`since_ch`). **Своим решением фронт это не заводит** (`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) · трактовка колонки оригинала приглушённым цветом — решение, а не замер, пересмотреть на настоящей читалке (S6) · состояния наведения витриной не проверены, снимок статичен | S2 · S6 | S1, сверка с fleet.png | | Ф-5 | **Tauri 2.x вторым шагом** — по явным триггерам (трей · глобальные горячие клавиши · распространяемый .exe · офлайн · хранилище учёток ОС); до того — установимое PWA, только браузерные API | триггеры названы | STACK_DECISIONS §6, D39.84 | diff --git a/frontend/docs/FRONTEND_PLAN.md b/frontend/docs/FRONTEND_PLAN.md index 472e12de..d93e1db9 100644 --- a/frontend/docs/FRONTEND_PLAN.md +++ b/frontend/docs/FRONTEND_PLAN.md @@ -12,6 +12,47 @@ Зона записи — только `frontend/`. `backend/`, `platform/`, `docs/`, `eval/` — read-only. +### 0.1. Канон: что читать перед чем + +Фронт живёт в проекте с ратифицированным контрактом, и половина ответов на вопросы «как правильно» +уже написана — не здесь. Опыт S0/S1: два дефекта пришли ровно из непрочитанного канона (мокался +не тот уровень контракта; смысл двух вердиктов взят по имени причины вместо доккоммента), оба +нашлись бы за десять минут чтения. Ссылки, а не пересказ — пересказ протухает: + +| Читать | Зачем фронту | Когда | +|---|---|---| +| [../../CLAUDE.md](../../CLAUDE.md) | зоны, гардрейлы, git-протокол (коммит только pathspec-формой) | первым делом | +| [../../docs/README.md](../../docs/README.md) | карта: где что лежит и что чем перекрыто | первым делом | +| [../../docs/product-requirements.md](../../docs/product-requirements.md) | реестр ПТ-1..ПТ-34; **ПТ-33 и ПТ-34 — жёсткие инварианты интерфейса**, ПТ-21 задаёт якорь чтения | до первого экрана | +| [../../docs/research/23-engine-platform-seam.md](../../docs/research/23-engine-platform-seam.md) | форма шва движок↔платформа; `docs/README.md` требует читать его **перед любым кодом стыка** | до любого кода данных | +| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет: строки **95** (контракт API) · **99–103** (прогресс, манифест, таблица подписи, trace, эмиттер) · **49** (annot-v1) · **54** (масштаб) | перед планированием этапа | +| [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных | +| [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове | + +Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности +в шапке + живая голова с хвоста, корпус D1–D38 — grep по D-номеру, целиком не читать. +Фронта касаются **D39.81** (SaaS; движок = процесс-на-прогон, сервер в `backend/` не пишется), +**D39.84** (стек + восемь решений владельца + зонные бэклоги), **D39.85** (шов), **D39.88** (git). + +### 0.2. Транспорт: с кем фронт разговаривает + +**С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс +на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится +(`research/23` §4, §0). Между фронтом и движком стоит платформа, у которой пока ноль строк кода +(`../../platform/BACKLOG.md` П-1). + +Проводов два, и их легко перепутать: + +| Шов | Формат | Где ратифицировано | +|---|---|---| +| движок → платформа | версионированный **NDJSON**-поток событий (объект на строку, первая строка — version-хендшейк), плюс артефакты границ стадий, плюс `tmctl status --json` для ре-синка | D39.85, `research/23` §2–§3 | +| платформа → фронт | **JSON поверх HTTP** для чтений из Postgres read-модели + **SSE** для живого прогресса; события пушит воркер, фронт read-модель не опрашивает; WebSocket отвергнут | D39.84, `STACK_DECISIONS.md` §5 | + +Фронту принадлежит только вторая строка. NDJSON в коде фронта появиться не может; если появился — +кто-то полез не на тот уровень. Дисциплина стрима, которую обязан выдержать слой данных: +монотонный `id` + `Last-Event-ID` (докачка после обрыва), heartbeat ~20 с, `EventSource` в браузере +и `Authorization: Bearer` с построчным разбором для десктопа. + --- ## 1. Пины @@ -88,7 +129,7 @@ CJK-шрифт не ставим: иероглифы отдаём системн | `ui/` | глупые примитивы на токенах: кнопка, поле, вкладки, строка дерева, таблица, выноска. Единственное место, где разрешён импорт `react-aria-components` | запросы, знание о доменных сущностях | | `shell/` | оболочка: три панели, верхняя полоса, статус-полоса, вкладки | доменная логика экранов | | `features/` | `books`, `chapters`, `bank`, `reader`, `settings` — по экрану на папку; запрос живёт здесь, на уровне экрана | цвета и размеры мимо токенов | -| `api/` | единственный вход к данным: типы + функции. Сегодня внутри фикстура, завтра HTTP. Единственное место, где легальны импорт из `mock/` и `fetch` | React-компоненты | +| `api/` | единственный вход к данным: типы + функции. Сегодня внутри фикстура, на S3 — MSW и асинхронность, потом HTTP и SSE к платформе (§0.2). Единственное место, где легальны импорт из `mock/` и любой сетевой вызов | React-компоненты | | `mock/` | фикстуры. В день появления API папка удаляется целиком, экраны не трогаются | логика; фикстуры — данные | | `showcase/` | витрина примитивов: маршрут `/showcase`, живой каталог для сверки с `references/fleet.png` | продуктовые экраны | @@ -127,7 +168,7 @@ CJK-шрифт не ставим: иероглифы отдаём системн |---|---|---| | 1 | Цвет и размер только из `tokens.css` | **машинно**: stylelint `declaration-property-value-allowed-list` (`/^var\(--/` на `color`, `background-color`, `border-color`, `fill`, `stroke`, `font-size`, `z-index`) + ESLint `no-restricted-syntax` на `#hex`/`rgb(`/`hsl(`/`oklch(` в TSX. Исключение по пути — только `tokens/` | | 2 | Отключить CSS-гейт комментарием нельзя | **машинно**: `reportDisables: true` на правилах + `reportUnscopedDisables: true` в корне конфига. Одного `reportDisables` НЕ хватает: он ловит только именованное отключение, а голое `/* stylelint-disable */` в шапке снимало гейт молча — проверено. В TSX то же самое делают правила `@eslint-community/eslint-comments`: `no-unlimited-disable` требует назвать правило, `require-description` — написать причину, `disable-enable-pair` — закрыть область. Голое `/* eslint-disable */` даёт три ошибки; точечное `// eslint-disable-next-line react-hooks/exhaustive-deps -- причина` проходит. Бинарный `noInlineConfig` не берём: он запрещает и то, что рекомендует сам React | -| 3 | Данные только через `src/api/` | ревью-вопрос: есть ли `fetch`/`axios`/импорт из `mock/` вне `src/api/`? Должно быть «нет» | +| 3 | Данные только через `src/api/` | **машинно**, см. правило 14 (было ревью-вопросом — не сработало) | | 4 | Файл = один компонент + свой `.module.css`, больше ~150 строк — делить | ревью глазами при лендинге пакета | | 5 | Состояние ровно в двух местах: TanStack Query (серверное), Zustand (интерфейсное) | ревью-вопрос: есть ли `useState` с копией серверных данных? Должно быть «нет» | | 6 | Глобальных стилей два файла | **машинно**: любой `.css` вне `src/tokens/`, не являющийся `*.module.css`, — ошибка сборки правилом ESLint на импорт | @@ -137,8 +178,9 @@ CJK-шрифт не ставим: иероглифы отдаём системн | 10 | Каждый экран открывается в изоляции: свой маршрут, своя фикстура | **машинно** косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется | | 11 | `styles.имяКласса` ссылается на существующий класс | **машинно**: `src/cssModules.test.ts`. Vite типизирует модуль как `{ [key: string]: string }`, поэтому опечатка даёт `className="undefined"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась | | 12 | Имя токена в `var(--…)` и в строках кода объявлено в `tokens.css` | **машинно**: `src/tokens/tokens.test.ts`. Тот же класс тихой ошибки: stylelint проверяет только форму обёртки `var(--…)`, тайпчек видит обычную строку, браузер отдаёт пустое значение — элемент гаснет в фон, и на скриншоте это 124 пикселя из 3,9 млн. Локальные переменные, задаваемые через `style`, перечислены в тесте явным списком | -| 14 | Данные — только через `src/api/`; сеть — только там же | **машинно**: `no-restricted-imports` на `**/mock/**` и `no-restricted-globals` на `fetch`, с исключением для `src/api/**`. Раньше правило было записано в плане и не существовало в коде — компонент импортировал фикстуру напрямую | -| 15 | Доступность проверяется машиной, а не глазами владельца | **машинно**: axe-core прогоняется в `npm run shot` по каждому маршруту и валит команду. Контраст вынесен в отчёт: палитра снята с Fleet замером и принята владельцем, менять её под порог WCAG — отдельное решение (`BACKLOG.md` Ф-11) | +| 14 | Данные — только через `src/api/`; сеть — только там же | **машинно**: `no-restricted-imports` на `**/mock/**` плюс `no-restricted-globals`/`no-restricted-properties` на ВСЕ транспорты (`fetch`, `EventSource`, `WebSocket`, `XMLHttpRequest` и они же через `window`/`globalThis`/`self`, плюс `navigator.sendBeacon`), с исключением для `src/api/**`. Одного `fetch` не хватало: живой прогресс по ратифицированному стеку приходит через `EventSource` (`STACK_DECISIONS.md` §5) — то есть шов расползся бы именно тем транспортом, который гейт не видел | +| 15 | Доступность проверяется машиной, а не глазами владельца | **машинно**: axe-core прогоняется в `npm run shot` по каждому маршруту и валит команду; список маршрутов скрипта сверяется с `routes.tsx` тестом (`src/routes.test.ts`) — иначе новый экран просто не попадал в прогон, а команда возвращала успех. Контраст вынесен в отчёт: палитра снята с Fleet замером и принята владельцем, менять её под порог WCAG — отдельное решение (`BACKLOG.md` Ф-11) | +| 17 | Пара языков живёт в данных, а не в разметке | **машинно**: `src/generality.test.ts` запрещает литерал `lang="…"` в TSX. Это ревью-вопрос канона в исполняемой форме («заработает ли пара, которой в репо ещё нет, без правки кода?» — `CLAUDE.md` §2). Найдено ревью S1 в живом коде: `lang="zh"` пережил бы приезд ja→ru молча, и кандзи отрисовались бы китайскими начертаниями | | 16 | Плавающий промис не проходит | **машинно**: `tseslint.configs.recommendedTypeChecked` с `projectService`. Проверено: `load()` без `await` роняет линт | | 13 | Инлайновый стиль — только литерал объекта прямо в атрибуте, и только с ключами-CSS-переменными | **машинно**: `no-restricted-syntax` запрещает сам атрибут `style` и разрешает единственную форму. Перечислять формы записи оказалось бесполезно: из одиннадцати способов записать то же самое ловилось три. Остаточная дыра — JSX-спред `
`, где `style` приезжает внутри объекта; без типовой информации не ловится | @@ -327,8 +369,14 @@ rm -f *.deb && cd .. вычисляемый ключ, JSX-спред, смешанный литерал) · импорт глобального CSS мимо `main.tsx` · предупреждение линта при `--max-warnings 0`. +**Сеть (все восемь форм ловятся, в `src/api/**` все восемь проходят):** `fetch` · `EventSource` · +`WebSocket` · `XMLHttpRequest` · `window.fetch` · `globalThis.fetch` · `self.fetch` · +`navigator.sendBeacon`. Проверялось файлом на диске, а не через `--stdin`: тип-осведомлённый +линт отказывается разбирать stdin («was not found by the project service»). + **Структурные тесты:** опечатка в имени класса CSS Module · то же при переименованном импорте -модуля · опечатка в имени токена в CSS · опечатка в имени токена в данных TS. +модуля · опечатка в имени токена в CSS · опечатка в имени токена в данных TS · маршрут добавлен +в `routes.tsx` и забыт в `scripts/shot.mjs` · литерал `lang="zh"` возвращён в разметку. **Что НЕ ловится и остаётся известной дырой:** `/* eslint-disable */` в TSX (см. правило 2) · JSX-спред `
` со `style` внутри объекта · именованные цвета CSS в TSX @@ -346,6 +394,20 @@ JSX-спред `
` со `style` внутри объекта · черновые строки всех членов, и строка редактуры: во время черновой волны сквозной счётчик стоит на нуле почти всё время. Тип прогресса — две пары `{ done, total }` (черновик ∥ редактура), а не одно число. Пользователю всё равно показывается одна полоса без названий фаз (§4.1). + ⚠ Это правило было записано здесь ещё в S0 — и витрина S1 всё равно вышла с наивным + счётчиком глав, потому что фикстура выдумала поле `translatedChapters`, которого read-модель + не отдаёт. Лечение сильнее правила: фикстура поставлена в состояние «идёт черновая волна», + где наивный счётчик дал бы ноль, и скриншот-цикл видит трудный случай, а не удобный. +1а. **Состояние — у ПРОГОНА над книгой, у главы его нет.** Подпись банка — один стоп между + черновой и редакторской волнами на всю книгу (`mining.go:199`), поэтому «глава ждёт подписи, + пока соседняя финализируется» — картина, которую движок породить не может. У главы есть + выполнение: `ChapterPassport` (`status.go:37-55`) несёт `units_total/done/in_progress/pending` + и вердикт. В интерфейсе: лестница из девяти состояний — на строке книги, полоса выполнения — + на строке главы. Девять состояний точкой не различаются (проверено снимком: три пары + совпадали) — метка словом, цвет только дублирует и только для внимания и отказа. +1б. **Язык — код, а не слово.** Движок держит `source_lang`/`target_lang` кодами (`book.go:26-27`), + ключ пары — `zh-ru`; контракт отдаст то же самое. Человеческое имя считается на экране + через `Intl.DisplayNames`, `lang` на элементе берётся из данных книги (правило 17). 2. **Подпись термина — не UPDATE строки.** Банк пересобирается из файлов на каждом прогоне, прямая запись стирается следующим прогоном. Контракт подписи — отправка набора решений, а не правка записи; фикстуры S3 моделируют именно это. diff --git a/frontend/docs/FRONTEND_SESSION_PROMPT.md b/frontend/docs/FRONTEND_SESSION_PROMPT.md index 0bf3a8b3..cdc3a54f 100644 --- a/frontend/docs/FRONTEND_SESSION_PROMPT.md +++ b/frontend/docs/FRONTEND_SESSION_PROMPT.md @@ -54,6 +54,8 @@ **Бэкенда пока нет** — точнее, движок перевода есть, но HTTP-API к нему ещё не построено. Ты работаешь на моках (см. §6). Это не помеха: контракт данных зафиксирован ниже, замена моков на реальные запросы будет точечной. +> ⚠ **Поправка канона (фронт-сессия S1, 02.08).** Формулировка выше читается как «однажды у движка появится HTTP-API». Это не так, и ошибка дорогая: **API будет у платформы, не у движка**, а движок фронту не виден вообще. Прежде чем писать слой данных, прочитай `FRONTEND_PLAN.md` §0.1–0.2 (карта канона и два провода) и первоисточник `docs/research/23-engine-platform-seam.md`. Там же цена ошибки: моки, снятые не с того уровня контракта, разойдутся с API — ровно то, ради предотвращения чего заведена строка 95 единого бэклога. + --- ## 1. Референсы — копировать, а не вдохновляться diff --git a/frontend/docs/PROGRESS.md b/frontend/docs/PROGRESS.md index b986b072..036f8946 100644 --- a/frontend/docs/PROGRESS.md +++ b/frontend/docs/PROGRESS.md @@ -14,7 +14,9 @@ `docs/architecture/14-api-contract.md`): файла нет, а без него моки и API разойдутся — ровно то, ради чего строка заведена как ранняя вставка.** Дальше — манифест глав и пофазный прогресс (строки 99–100), к S5 — машиночитаемая таблица подписи банка (строка 101). -- `npm run check` зелёный (5 шагов, 27 тестов, тип-осведомлённый линт), `npm run check:full` +- **Карта канона для фронт-сессии — `FRONTEND_PLAN.md` §0.1, транспорт — §0.2.** Заведены + после того, как две ошибки S1 пришли из непрочитанного канона; читать до кода, не после. +- `npm run check` зелёный (5 шагов, 31 тест, тип-осведомлённый линт), `npm run check:full` зелёный, гейт доступности в скриншот-цикле зелёный. ## Решения владельца по продукту @@ -77,10 +79,20 @@ Заведено: `src/api/` — типы и функции, единственный вход; `src/mock/` — фикстура, которую видит только `api/`. Правило теперь машинное, а не на совести ревьюера: `no-restricted-imports` -на `**/mock/**` и `no-restricted-globals` на `fetch`, с исключением для `src/api/**`. -Доменных значений в разметке не осталось — их было восемь, и grep их не находил. -**В день появления HTTP правится тело функций в `api/` и удаляется одна папка; экраны -не трогаются.** +на `**/mock/**` и запрет сетевых вызовов, с исключением для `src/api/**`. + +**Две поправки к этой записи, обе от ревью 02.08 (№3) — исходные формулировки были сильнее +сделанного:** + +- «Доменных значений в разметке не осталось» — **было неверно**: в JSX оставались четыре + литерала языка (`lang="zh"`, `lang="ru"`, подпись пары `zh → ru`), и тот же проход, + который вынес числа в `book.*`, прошёл мимо них в той же строке. Починено: язык — код + в данных, имя считается `Intl.DisplayNames`, `lang` берётся из книги; правило закрыто + тестом (`src/generality.test.ts`), а не обещанием. +- «В день HTTP правится тело функций, экраны не трогаются» — **переобещание**. Верно только + про день замены MSW настоящим сервером. Асинхронность приезжает раньше, на S3, вместе + с MSW и TanStack Query — то есть ДО первого продуктового экрана (S4–S7), поэтому + переписывать экраны и правда не придётся. Но четыре вызова витрины на S3 изменятся. ### 02.08 — что дал разбор канона: фронт мокает не тот контракт @@ -120,6 +132,36 @@ причины, и рядом кладётся цитата. Соответствие целиком — в шапке `src/mock/book.ts`; его настоящий дом — контракт API (строка 95), не фронт. +### 02.08 — ревью №3 на полном каноне: что оно поменяло + +Владелец разрешил читать весь корпус доков и потребовал прогнать ревью после чтения. +Прочитано: `CLAUDE.md` · `docs/README.md` · CURRENT-STATE и единый бэклог · `research/23` · +`product-requirements.md` · карта D-лога. **Ответ на вопрос владельца о способе связи: +NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фронту принадлежит JSON поверх HTTP плюс SSE +от платформы (D39.84). Разведено в `FRONTEND_PLAN.md` §0.2, потому что путал их я сам.** + +Ревью: 5 линз × находки, на каждую независимый скептик с установкой опровергать. +15 находок → 12 проверено → **5 выжило, 7 опровергнуто**. Хардблокеров нет; S2 не блокирован ничем. + +| Что | Диспозиция | +|---|---| +| Язык зашит в разметку (`lang="zh"`), а в типе — русское слово | починено: коды в данных + `Intl.DisplayNames` + тест-замок | +| Девять состояний нарисованы на ГЛАВАХ, хотя это состояния ПРОГОНА над книгой | починено: состояние → книга, у главы выполнение по юнитам; библиотека из нескольких книг | +| Прогресс — наивное «N из M глав», прямо запрещённое собственным планом | починено: две пары по юнитам, фикстура поставлена в середину черновой волны (наивный счётчик дал бы 0) | +| Сетевой гейт ловил только `fetch` — `EventSource` (ратифицированный транспорт) проходил молча | починено: восемь форм, все проверены живым нарушением | +| `npm run shot` зелен, когда экран не попал в список маршрутов скрипта | починено: тест сверяет `routes.tsx` и `shot.mjs` | + +**Опровергнуто (и это ценнее половины находок):** гипотеза «синхронный `src/api/` — хардблокер, +нужен свой хук уже сегодня». Два независимых скептика показали, что S2 данных не читает вовсе, +а асинхронность приезжает на S3 вместе с MSW и TanStack Query — то есть самодельный хук был бы +работой S3, которую S3 же и выбросит. Правку я не делал; переобещание в журнале поправил. + +**Что нашла не ревьюшница, а снимок:** после переноса состояний на книгу три пары из девяти +рисовались одинаково (готова ≡ не начата, перевод ≡ финал, ошибка ≡ не разобрана) — точка +не различает девять значений. Метка стала словом; цвет остался вторым каналом и живёт +на точке, а не на тексте (цвета замечания и отказа не проходят порог контраста для текста — +проверено axe: было 5 узлов, стало 14, вернулось к 5). + ## Открытые вопросы к владельцу | # | Вопрос | Почему это его вопрос | @@ -166,3 +208,9 @@ **Git-инцидент:** два первых коммита (`e9a6bb2`, `b98afb5`) сделаны голым `git commit` и унесли застейдженные файлы оркестратора. Содержимое цело, потеряна атрибуция; историю не переписывали. Норма ратифицирована владельцем как D39.88 — коммит только pathspec-формой с явным списком путей. + +**Второй git-урок, 02.08:** откатывая пробное нарушение гейта, я сделал `git checkout --` по +файлу, в котором лежали НЕЗАКОММИЧЕННЫЕ правки, — и стёр их все. Восстановил из контекста, +чужого не задело. Запрет «никакого `checkout` поверх грязного дерева» (`CLAUDE.md`) существует +ровно для этого случая и относится к своим файлам тоже. Пробу отката делать копией +(`cp` до, `cp` после), а не через git. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index cd236c3e..11535faf 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -13,6 +13,7 @@ const colorLiteral = const inlineStyle = 'Инлайновый стиль запрещён: размеры и цвета берутся из .module.css на токенах. ' + "Единственное исключение — передача CSS-переменной: style={{ '--progress': value }}."; +const network = 'Сеть живёт в src/api/ и больше нигде — включая подписку на поток событий.'; // Длины перечислены все четыре: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. Ловить «3..8 подряд» одним // шаблоном нельзя — граница слова не сработает внутри более длинной последовательности. @@ -105,9 +106,27 @@ export default tseslint.config( }, ], // Сеть — тоже только за швом: иначе запрос расползётся по экранам и его не выкорчевать. + // Перечислены ВСЕ транспорты, а не один fetch: живой прогресс по ратифицированному + // стеку приходит через EventSource (STACK_DECISIONS §5), и именно он естественнее + // всего пишется прямо в экране — то есть мимо шва. 'no-restricted-globals': [ 'error', - { name: 'fetch', message: 'Запросы живут в src/api/ и больше нигде.' }, + ...['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest'].map((name) => ({ + name, + message: network, + })), + ], + // Тот же запрет через объект-носитель: `window.fetch(...)` обходит no-restricted-globals. + 'no-restricted-properties': [ + 'error', + ...['window', 'globalThis', 'self'].flatMap((object) => + ['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest'].map((property) => ({ + object, + property, + message: network, + })), + ), + { object: 'navigator', property: 'sendBeacon', message: network }, ], }, }, @@ -124,7 +143,11 @@ export default tseslint.config( { // Единственное место, где легальны и фикстуры, и сеть. files: ['src/api/**'], - rules: { 'no-restricted-imports': 'off', 'no-restricted-globals': 'off' }, + rules: { + 'no-restricted-imports': 'off', + 'no-restricted-globals': 'off', + 'no-restricted-properties': 'off', + }, }, prettier, ); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f1fa85eb..19aceae1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,9 +1,11 @@ // ЕДИНСТВЕННЫЙ вход к данным. Компоненты зовут отсюда и не знают, что внутри моки: -// сегодня фикстура, завтра HTTP к платформе. В день появления API правится тело этих -// функций и удаляется папка src/mock/ — экраны не трогаются вовсе. +// сегодня фикстура, завтра HTTP и SSE к ПЛАТФОРМЕ (движок фронту не виден никогда — +// FRONTEND_PLAN.md §0.2). Асинхронность и состояния загрузки приезжают на S3 вместе +// с MSW и TanStack Query, до первого продуктового экрана; в день замены MSW настоящим +// сервером правятся тела этих функций и удаляется папка src/mock/. // // Правило машинное, а не на совести ревьюера: eslint.config.js запрещает импорт из -// src/mock/ и вызов fetch где-либо, кроме src/api/. +// src/mock/ и любой сетевой вызов (fetch · EventSource · WebSocket · XHR) вне src/api/. import * as fixture from '../mock/book'; @@ -11,8 +13,12 @@ import type { BankTerm, Book, Chapter, Pair } from './types'; export type * from './types'; +export function getBooks(): Book[] { + return fixture.books; +} + export function getBook(): Book { - return fixture.book; + return fixture.openBook; } export function getChapters(): Chapter[] { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a96b2f13..6ca1f072 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -4,7 +4,13 @@ // (единый бэклог, строка 95 — `docs/architecture/14-api-contract.md` ещё не написан), // эти типы — рабочая гипотеза фронта, и при появлении контракта правятся здесь и только здесь. -export type ChapterState = +/** + * Состояние ПРОГОНА над книгой, не главы: подпись банка — один стоп между черновой + * и редакторской волнами на всю книгу (`backend/internal/pipeline/mining.go:199`), + * поэтому «глава ждёт подписи, пока соседняя финализируется» — невозможная картина. + * Словарь продуктовый, ратифицирован как ПТ-33; внутренние стадии сюда не протекают. + */ +export type RunState = | 'not-started' | 'parsing' | 'translating' @@ -15,24 +21,41 @@ export type ChapterState = | 'rejected' | 'failed'; -export interface Chapter { - id: string; - title: string; - state: ChapterState; - /** Есть замечания в главе — отдельная ось от состояния прогона. */ - noteCount: number; +/** Счётчик волны. Знаменатель — ЮНИТЫ редактуры, а не главы: юнит ≈ 1.9 на главу (ПТ-21). */ +export interface Progress { + done: number; + total: number; } export interface Book { id: string; title: string; + /** Коды BCP-47, как их держит движок (`book.yaml: source_lang/target_lang`), не имена. */ sourceLanguage: string; targetLanguage: string; genre: string; chapterCount: number; characterCount: number; addedAt: string; - translatedChapters: number; + state: RunState; + /** + * Прогресс ПОФАЗНО. Наивное «готово N из M» показывает ноль всю черновую волну: + * юнит становится done только когда есть и черновые строки всех членов, и строка + * редактуры (`backend/internal/pipeline/status.go:332-336`), а редактура не стартует + * до стопа банка. На книге в 2284 раздела это часы нуля. Единый бэклог, строка 99. + */ + draft: Progress; + edit: Progress; +} + +export interface Chapter { + id: string; + title: string; + /** У главы нет состояния — у неё есть выполнение (`ChapterPassport`, status.go:37-55). */ + unitsDone: number; + unitsTotal: number; + /** Есть замечания в главе — отдельная ось от выполнения. */ + noteCount: number; } /** Термин банка памяти. Подпись — не правка строки: банк пересобирается на каждом прогоне. */ diff --git a/frontend/src/generality.test.ts b/frontend/src/generality.test.ts new file mode 100644 index 00000000..b2df9049 --- /dev/null +++ b/frontend/src/generality.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; + +// Ревью-вопрос канона по умолчанию: «заработает ли пара языков, которой в репо ещё НЕТ, +// без правки кода?» (CLAUDE.md §2). На фронте эта норма ломается ровно одним способом — +// код письменности, зашитый в разметку: `lang="zh"` переживёт приезд ja→ru молча, и японские +// кандзи отрисуются китайскими начертаниями, а скринридер возьмёт китайский голос. +// Найдено ревью S1 в живом коде витрины, поэтому проверяется машиной, а не глазами. +const components = import.meta.glob('./**/*.tsx', { + query: '?raw', + eager: true, + import: 'default', +}); + +it.each(Object.keys(components))('%s не зашивает язык в разметку', (file) => { + const literals = [...(components[file] as string).matchAll(/\blang=(["'][^"']*["'])/g)].map( + (m) => m[1], + ); + expect(literals, 'lang берётся из данных книги: lang={book.sourceLanguage}').toEqual([]); +}); diff --git a/frontend/src/mock/book.ts b/frontend/src/mock/book.ts index 617c40f3..06fb92b9 100644 --- a/frontend/src/mock/book.ts +++ b/frontend/src/mock/book.ts @@ -8,31 +8,62 @@ import type { BankTerm, Book, Chapter, Pair } from '../api/types'; -export const book: Book = { +// Книга в СЕРЕДИНЕ ЧЕРНОВОЙ ВОЛНЫ — намеренно трудный случай: наивный счётчик «готово N из M» +// показал бы здесь ноль (редактура ещё не начиналась), и скриншот-цикл увидит именно это, +// а не удобную картинку почти готовой книги. Юнитов 4276 на 2284 раздела (замер, D39.84). +export const openBook: Book = { id: 'guzhenren', title: 'Гу Чжэньжэнь', - sourceLanguage: 'китайский', - targetLanguage: 'русский', + sourceLanguage: 'zh', + targetLanguage: 'ru', genre: 'сянься', chapterCount: 2284, characterCount: 7_780_000, addedAt: '28.07.2026', - translatedChapters: 118, + state: 'translating', + draft: { done: 214, total: 4276 }, + edit: { done: 0, total: 4276 }, }; -// Все девять состояний из §6 промта присутствуют: экран обязан выдерживать каждое, -// а не только те три, что удобно нарисовать. +// Девять состояний живут на КНИГАХ, а не на главах: подпись банка — один стоп на всю книгу, +// поэтому «глава ждёт подписи, пока соседняя финализируется» физически не бывает. Библиотека +// из нескольких книг — единственное место, где все девять видны одним кадром честно. +const other = (id: string, title: string, state: Book['state']): Book => ({ + ...openBook, + id, + title, + state, + chapterCount: 0, + characterCount: 0, + draft: { done: 0, total: 0 }, + edit: { done: 0, total: 0 }, +}); + +export const books: Book[] = [ + openBook, + other('b2', 'Мастер божественных пилюль', 'ready'), + other('b3', 'Второе рождение мечника', 'awaiting-bank'), + other('b4', 'Хроники северной заставы', 'finalizing'), + other('b5', 'Тысяча ступеней', 'parsing'), + other('b6', 'Записки о лисах и духах', 'not-started'), + other('b7', 'Небесный чиновник', 'stopped'), + other('b8', 'Сломанный меч', 'failed'), + other('b9', 'Повреждённая выгрузка', 'rejected'), +]; + +// У главы не состояние, а выполнение: сколько её юнитов прошло волну (`ChapterPassport`). +// Черновая волна идёт — часть глав пройдена, одна в работе, остальные ждут. export const chapters: Chapter[] = [ - { id: 'r1', title: 'Раздел 1. Нет раскаяния', state: 'ready', noteCount: 0 }, - { id: 'r2', title: 'Раздел 2. Прозрение пятисот лет', state: 'ready', noteCount: 2 }, - { id: 'r3', title: 'Раздел 3. Церемония открытия', state: 'translating', noteCount: 0 }, - { id: 'r4', title: 'Раздел 4. Класс А', state: 'awaiting-bank', noteCount: 0 }, - { id: 'r5', title: 'Раздел 5. Деревня Гуюэ', state: 'finalizing', noteCount: 1 }, - { id: 'r6', title: 'Раздел 6. Первый гу', state: 'not-started', noteCount: 0 }, - { id: 'r7', title: 'Раздел 7. Аптека', state: 'parsing', noteCount: 0 }, - { id: 'r8', title: 'Раздел 8. Кровь на снегу', state: 'stopped', noteCount: 0 }, - { id: 'r9', title: 'Раздел 9. Утраченный свиток', state: 'failed', noteCount: 0 }, - { id: 'r10', title: 'Раздел 10. Испорченный файл', state: 'rejected', noteCount: 0 }, + { id: 'r1', title: 'Раздел 1. Нет раскаяния', unitsDone: 2, unitsTotal: 2, noteCount: 0 }, + { id: 'r2', title: 'Раздел 2. Прозрение пятисот лет', unitsDone: 4, unitsTotal: 4, noteCount: 2 }, + { id: 'r3', title: 'Раздел 3. Церемония открытия', unitsDone: 1, unitsTotal: 3, noteCount: 0 }, + { id: 'r4', title: 'Раздел 4. Класс А', unitsDone: 0, unitsTotal: 2, noteCount: 0 }, + { id: 'r5', title: 'Раздел 5. Деревня Гуюэ', unitsDone: 0, unitsTotal: 1, noteCount: 1 }, + { id: 'r6', title: 'Раздел 6. Первый гу', unitsDone: 0, unitsTotal: 2, noteCount: 0 }, + { id: 'r7', title: 'Раздел 7. Аптека', unitsDone: 0, unitsTotal: 2, noteCount: 0 }, + { id: 'r8', title: 'Раздел 8. Кровь на снегу', unitsDone: 0, unitsTotal: 3, noteCount: 0 }, + { id: 'r9', title: 'Раздел 9. Утраченный свиток', unitsDone: 0, unitsTotal: 1, noteCount: 0 }, + { id: 'r10', title: 'Раздел 10. Горная тропа', unitsDone: 0, unitsTotal: 2, noteCount: 0 }, ]; // Значения — из настоящего банка `minirun/guzhenren-minirun.db` (таблица `glossary`). @@ -57,8 +88,8 @@ export const bankTerms: BankTerm[] = [ // // движок | важность (status.go:150-175) | продуктовая фраза // content_filter = 1 | attention | Блок не переведён: содержимое отклонено -// sanitizer_defect = 2 | attention | Блок не переведён: в ответе был мусор -// loop_degenerate = 3 | attention | Перевод зациклился, блок надо перевести заново +// sanitizer_defect = 2 | attention | Блок не переведён: ответ пришёл повреждённым +// loop_degenerate = 3 | attention | Перевод зациклился, блок нужно повторить // glossary_miss = 5 | attention | Подписанный термин не применён в переводе // sanitizer_stripped = 7 | glance | Служебная разметка вычищена автоматически // diff --git a/frontend/src/routes.test.ts b/frontend/src/routes.test.ts new file mode 100644 index 00000000..b1cd90c8 --- /dev/null +++ b/frontend/src/routes.test.ts @@ -0,0 +1,28 @@ +import { isValidElement } from 'react'; + +import { Navigate } from 'react-router'; +import { expect, it } from 'vitest'; + +import { routes } from './routes'; + +// scripts/shot.mjs держит СВОЙ список маршрутов — Node не умеет загрузить TSX без сборки, +// и это сознательный размен на простоту. Цена размена: новый экран, забытый в shot.mjs, +// не снимается и не проверяется axe, а `npm run shot` при этом возвращает успех. +// Замок дешевле дисциплины: расхождение двух списков падает тестом. +const shot = Object.values( + import.meta.glob('../scripts/shot.mjs', { query: '?raw', eager: true, import: 'default' }), +)[0] as string; + +it('список маршрутов в scripts/shot.mjs совпадает с routes.tsx', () => { + const declared = /const KNOWN_ROUTES = \[(.*?)\];/s.exec(shot)?.[1]; + expect(declared, 'KNOWN_ROUTES не найден в scripts/shot.mjs').toBeDefined(); + + const known = [...(declared as string).matchAll(/'([^']+)'/g)].map((m) => m[1]); + // Редиректы снимать нечего: своего экрана у них нет. + const screens = routes + .filter((route) => !(isValidElement(route.element) && route.element.type === Navigate)) + .map((route) => route.path) + .filter((path) => path !== undefined); + + expect(known.toSorted()).toEqual(screens.toSorted()); +}); diff --git a/frontend/src/showcase/Showcase.module.css b/frontend/src/showcase/Showcase.module.css index af661abc..906a11ee 100644 --- a/frontend/src/showcase/Showcase.module.css +++ b/frontend/src/showcase/Showcase.module.css @@ -159,39 +159,34 @@ color: var(--color-text-muted); } -.marker { +/* Состояние прогона — словом: девять значений точкой не различить. Слово тусклое и не + спорит с названием книги; название при нехватке ширины усекается первым — так и надо, + состояние важнее хвоста заголовка. */ +.runState { + display: flex; + gap: var(--space-2); + align-items: center; margin-left: auto; - width: 7px; - height: 7px; + padding-left: var(--space-2); + + /* Не --color-text-muted: состояние — это ИНФОРМАЦИЯ, а приглушённый тон не проходит порог + контраста (axe считает узлы, Ф-11). Тусклость набирается кеглем, а не темнотой. */ + color: var(--color-text-secondary); + font-size: var(--font-size-small); + white-space: nowrap; +} + +/* Цвет — второй канал, и означает только внимание и отказ (промт §4.2). Он ДУБЛИРУЕТ слово, + а не заменяет его, и живёт на точке, а не на тексте: тексту цвета замечания и отказа + не хватает контраста, а точке порог для текста не предъявляется. */ +.stateDot { + width: 6px; + height: 6px; flex: none; -} - -/* Форма — первый канал: она различает состояния и без цвета. */ -.markerFilled { border-radius: 50%; background-color: currentcolor; } -.markerRing { - border: 1px solid currentcolor; - border-radius: 50%; -} - -.markerDash { - height: 1px; - margin-block: 3px; - background-color: currentcolor; -} - -.markerNone { - visibility: hidden; -} - -/* Цвет — второй канал, и означает только внимание и отказ (промт §4.2). */ -.toneMuted { - color: var(--color-text-muted); -} - .toneNote { color: var(--color-note); } @@ -207,6 +202,27 @@ font-size: var(--font-size-small); } +/* У главы показывается выполнение, а не состояние: состояние прогона одно на книгу. + Доля приходит CSS-переменной — единственный санкционированный вид инлайнового стиля. */ +.progress { + margin-left: auto; + width: 24px; + height: 3px; + flex: none; + border-radius: 2px; + background-color: var(--color-separator); +} + +/* Заполнение заметно светлее дорожки: иначе «пройдена» и «не начата» на 24px неразличимы — + проверено на снимке, первая проба с --color-text-muted читалась одинаково. */ +.progressFill { + display: block; + width: calc(var(--progress) * 100%); + height: 100%; + border-radius: inherit; + background-color: var(--color-text-secondary); +} + .iconButton { display: flex; align-items: center; diff --git a/frontend/src/showcase/Showcase.tsx b/frontend/src/showcase/Showcase.tsx index 81aef021..26906ea2 100644 --- a/frontend/src/showcase/Showcase.tsx +++ b/frontend/src/showcase/Showcase.tsx @@ -1,6 +1,7 @@ import { BookText, ChevronDown, + ChevronRight, Download, FileText, PanelLeft, @@ -10,7 +11,7 @@ import { Settings, } from 'lucide-react'; -import { getBankTerms, getBook, getChapters, getPairs, type ChapterState } from '../api'; +import { getBankTerms, getBook, getBooks, getChapters, getPairs, type RunState } from '../api'; import styles from './Showcase.module.css'; // Тонкие линейные монохромные иконки как во Fleet (STACK_DECISIONS §2). @@ -20,19 +21,26 @@ const icon = { size: 16, strokeWidth: 1.5 } as const; // через отсев пустых, а не через шаблонную строку: иначе в class уезжает «undefined». const cx = (...names: (string | undefined | false)[]) => names.filter(Boolean).join(' '); -// Состояние кодируется ДВУМЯ каналами: формой метки и цветом. Одним цветом девять состояний -// в монохромном интерфейсе не развести, а цвет по §4.2 означает только внимание и отказ. -// Новое состояние — одна запись здесь. -const chapterMarkers: Record = { - 'not-started': { shape: styles.markerNone, label: 'не начата' }, - parsing: { shape: styles.markerRing, tone: styles.toneMuted, label: 'разбирается' }, - translating: { shape: styles.markerFilled, tone: styles.toneMuted, label: 'переводится' }, - 'awaiting-bank': { shape: styles.markerRing, tone: styles.toneNote, label: 'ждёт подписи банка' }, - finalizing: { shape: styles.markerFilled, tone: styles.toneMuted, label: 'финальный проход' }, - ready: { shape: styles.markerNone, label: 'готова' }, - stopped: { shape: styles.markerDash, tone: styles.toneMuted, label: 'остановлена' }, - rejected: { shape: styles.markerFilled, tone: styles.toneDanger, label: 'не удалось разобрать' }, - failed: { shape: styles.markerFilled, tone: styles.toneDanger, label: 'прервана ошибкой' }, +// Язык приходит кодом, а не словом, поэтому имя пары считается, а не пишется руками: +// вторая пара продукта (ja→ru) обязана заработать без правки разметки. Интерфейс русский. +const languageNames = new Intl.DisplayNames(['ru'], { type: 'language' }); + +// Состояние ПРОГОНА — свойство книги, не главы (подпись банка одна на всю книгу), поэтому +// метка стоит на строке книги. Метка СЛОВОМ, а не значком: девять значений точкой не +// развести — предыдущая проба кодировала их формой и тоном, и на снимке три пары совпали +// (готова ≡ не начата, перевод ≡ финал, ошибка ≡ не разобрана). Слово ещё и продуктовое: +// это ровно тот словарь, который интерфейсу разрешено показывать (ПТ-33). Цвет остаётся +// вторым каналом и означает только внимание и отказ (§4.2). +const runStates: Record = { + 'not-started': { label: 'в очереди' }, + parsing: { label: 'разбор' }, + translating: { label: 'перевод' }, + 'awaiting-bank': { label: 'нужна подпись', tone: styles.toneNote }, + finalizing: { label: 'финал' }, + ready: { label: 'готова' }, + stopped: { label: 'остановлена' }, + rejected: { label: 'не разобрана', tone: styles.toneDanger }, + failed: { label: 'ошибка', tone: styles.toneDanger }, }; /** @@ -40,6 +48,7 @@ const chapterMarkers: Record
    -
  • - - - {book.title} -
  • - {chapters.map((chapter) => { - const marker = chapterMarkers[chapter.state]; + {books.map((item) => { + const open = item.id === book.id; + const run = runStates[item.state]; return ( -
  • - - {chapter.title} - {chapter.noteCount > 0 && ( - - {chapter.noteCount} - замечаний +
  • + + {open ? ( + + ) : ( + + )} + + {item.title} + + {run.tone && } + {run.label} - )} - - {marker.label} + {open && ( +
      + {chapters.map((chapter) => ( +
    • + + {chapter.title} + {chapter.noteCount > 0 && ( + + {chapter.noteCount} + замечаний + + )} + {/* У главы показывается ВЫПОЛНЕНИЕ: per-chapter состояния у движка нет, + есть счётчик юнитов (`ChapterPassport`, status.go:37-55). */} + + + + переведено {chapter.unitsDone} из {chapter.unitsTotal} + + +
    • + ))} +
    + )}
  • ); })} -
  • +
  • - Добавить главу + Добавить книгу
{/* Единственный вход в настройки во всём интерфейсе (промт §3.12) */} @@ -153,10 +187,10 @@ export function Showcase() { className={cx(styles.pair, pair.note && styles[pair.note.severity])} > {pair.note &&

{pair.note.text}

} -
+
{pair.source}
-
+
{pair.target || перевод не получен}
@@ -189,7 +223,7 @@ export function Showcase() { /> {terms.map((term) => (
- {term.source} + {term.source} {/* Неподписанный термин приглушён: состояние читается насыщенностью, не цветом */} {term.target}
@@ -210,8 +244,12 @@ export function Showcase() { {book.title} / {openChapter?.title} + {/* Прогресс ПОФАЗНО: наивное «готово N из M» стоит нулём всю черновую волну + (единый бэклог, строка 99), а знаменатель — юниты, а не главы. */} - переведено {book.translatedChapters} из {book.chapterCount} · zh → ru + черновик {book.draft.done}/{book.draft.total} · редактура {book.edit.done}/ + {book.edit.total} · {languageNames.of(book.sourceLanguage)} →{' '} + {languageNames.of(book.targetLanguage)}
diff --git a/frontend/src/tokens/tokens.test.ts b/frontend/src/tokens/tokens.test.ts index 78e637d1..36e6ad16 100644 --- a/frontend/src/tokens/tokens.test.ts +++ b/frontend/src/tokens/tokens.test.ts @@ -72,8 +72,9 @@ describe('tokens.css', () => { const declared = new Set( tokens.match(/^\s*(--[\w-]+)\s*:/gm)?.map((m) => m.trim().slice(0, -1)), ); - // Задаются на элементе через style, а не в tokens.css; список исчерпывающий и явный. - const local = new Set(['--dot']); + // Не токены, а входы компонента: задаются на элементе через style. Список явный, чтобы + // «переменная просто не объявлена» не пряталась за общим правилом. + const local = new Set(['--progress']); const used = new Set(); for (const source of Object.values(styleSheets)) {