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
This commit is contained in:
parent
804dbc97c6
commit
8be1e41ee1
13 changed files with 414 additions and 115 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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-спред `<div {...props} />`, где `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-спред `<div {...props} />` со `style` внутри объекта · именованные цвета CSS в TSX
|
||||
|
|
@ -346,6 +394,20 @@ JSX-спред `<div {...props} />` со `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 моделируют именно это.
|
||||
|
|
|
|||
|
|
@ -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. Референсы — копировать, а не вдохновляться
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/** Термин банка памяти. Подпись — не правка строки: банк пересобирается на каждом прогоне. */
|
||||
|
|
|
|||
19
frontend/src/generality.test.ts
Normal file
19
frontend/src/generality.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
|
|
@ -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 | Служебная разметка вычищена автоматически
|
||||
//
|
||||
|
|
|
|||
28
frontend/src/routes.test.ts
Normal file
28
frontend/src/routes.test.ts
Normal file
|
|
@ -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());
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ChapterState, { shape?: string; tone?: string; label: string }> = {
|
||||
'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<RunState, { label: string; tone?: string }> = {
|
||||
'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<ChapterState, { shape?: string; tone?: string; labe
|
|||
* ровно так же, как будут брать настоящие экраны: доменных значений в разметке нет.
|
||||
*/
|
||||
export function Showcase() {
|
||||
const books = getBooks();
|
||||
const book = getBook();
|
||||
const chapters = getChapters();
|
||||
const pairs = getPairs();
|
||||
|
|
@ -85,39 +94,64 @@ export function Showcase() {
|
|||
</button>
|
||||
</div>
|
||||
<ul className={styles.panelBody}>
|
||||
<li className={styles.row}>
|
||||
<ChevronDown {...icon} className={styles.chevron} />
|
||||
<BookText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>{book.title}</span>
|
||||
</li>
|
||||
{chapters.map((chapter) => {
|
||||
const marker = chapterMarkers[chapter.state];
|
||||
{books.map((item) => {
|
||||
const open = item.id === book.id;
|
||||
const run = runStates[item.state];
|
||||
return (
|
||||
<li
|
||||
key={chapter.id}
|
||||
className={cx(
|
||||
styles.row,
|
||||
styles.rowNested,
|
||||
chapter.id === openChapter?.id && styles.rowSelected,
|
||||
)}
|
||||
>
|
||||
<FileText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>{chapter.title}</span>
|
||||
{chapter.noteCount > 0 && (
|
||||
<span className={styles.noteCount}>
|
||||
{chapter.noteCount}
|
||||
<span className={styles.visuallyHidden}> замечаний</span>
|
||||
<li key={item.id}>
|
||||
<span className={styles.row}>
|
||||
{open ? (
|
||||
<ChevronDown {...icon} className={styles.chevron} />
|
||||
) : (
|
||||
<ChevronRight {...icon} className={styles.chevron} />
|
||||
)}
|
||||
<BookText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>{item.title}</span>
|
||||
<span className={styles.runState}>
|
||||
{run.tone && <span className={cx(styles.stateDot, run.tone)} />}
|
||||
{run.label}
|
||||
</span>
|
||||
)}
|
||||
<span className={cx(styles.marker, marker.shape, marker.tone)}>
|
||||
<span className={styles.visuallyHidden}>{marker.label}</span>
|
||||
</span>
|
||||
{open && (
|
||||
<ul>
|
||||
{chapters.map((chapter) => (
|
||||
<li
|
||||
key={chapter.id}
|
||||
className={cx(
|
||||
styles.row,
|
||||
styles.rowNested,
|
||||
chapter.id === openChapter?.id && styles.rowSelected,
|
||||
)}
|
||||
>
|
||||
<FileText {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>{chapter.title}</span>
|
||||
{chapter.noteCount > 0 && (
|
||||
<span className={styles.noteCount}>
|
||||
{chapter.noteCount}
|
||||
<span className={styles.visuallyHidden}> замечаний</span>
|
||||
</span>
|
||||
)}
|
||||
{/* У главы показывается ВЫПОЛНЕНИЕ: per-chapter состояния у движка нет,
|
||||
есть счётчик юнитов (`ChapterPassport`, status.go:37-55). */}
|
||||
<span
|
||||
className={styles.progress}
|
||||
style={{ '--progress': chapter.unitsDone / chapter.unitsTotal }}
|
||||
>
|
||||
<span className={styles.progressFill} />
|
||||
<span className={styles.visuallyHidden}>
|
||||
переведено {chapter.unitsDone} из {chapter.unitsTotal}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className={cx(styles.row, styles.rowNested, styles.rowMuted)}>
|
||||
<li className={cx(styles.row, styles.rowMuted)}>
|
||||
<Plus {...icon} className={styles.chevron} />
|
||||
<span className={styles.rowTitle}>Добавить главу</span>
|
||||
<span className={styles.rowTitle}>Добавить книгу</span>
|
||||
</li>
|
||||
</ul>
|
||||
{/* Единственный вход в настройки во всём интерфейсе (промт §3.12) */}
|
||||
|
|
@ -153,10 +187,10 @@ export function Showcase() {
|
|||
className={cx(styles.pair, pair.note && styles[pair.note.severity])}
|
||||
>
|
||||
{pair.note && <p className={styles.note}>{pair.note.text}</p>}
|
||||
<div className={styles.source} lang="zh">
|
||||
<div className={styles.source} lang={book.sourceLanguage}>
|
||||
{pair.source}
|
||||
</div>
|
||||
<div className={styles.target} lang="ru">
|
||||
<div className={styles.target} lang={book.targetLanguage}>
|
||||
{pair.target || <span className={styles.dim}>перевод не получен</span>}
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -189,7 +223,7 @@ export function Showcase() {
|
|||
/>
|
||||
{terms.map((term) => (
|
||||
<div key={term.source} className={styles.bankRow}>
|
||||
<span lang="zh">{term.source}</span>
|
||||
<span lang={book.sourceLanguage}>{term.source}</span>
|
||||
{/* Неподписанный термин приглушён: состояние читается насыщенностью, не цветом */}
|
||||
<span className={term.signed ? styles.rowTitle : styles.dim}>{term.target}</span>
|
||||
</div>
|
||||
|
|
@ -210,8 +244,12 @@ export function Showcase() {
|
|||
<span>
|
||||
{book.title} / {openChapter?.title}
|
||||
</span>
|
||||
{/* Прогресс ПОФАЗНО: наивное «готово N из M» стоит нулём всю черновую волну
|
||||
(единый бэклог, строка 99), а знаменатель — юниты, а не главы. */}
|
||||
<span className={styles.statusRight}>
|
||||
переведено {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)}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
for (const source of Object.values(styleSheets)) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue