From 267aa3569ca5c978576da0129a409f37ae49ca53 Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 15 Aug 2026 19:25:25 +0300 Subject: [PATCH] Land the accepted frontend S4 pack: book upload and run start on contract 0.2.3, library at scale, the nine S3.7 findings closed --- frontend/README.md | 7 +- frontend/docs/BACKLOG.md | 20 +- frontend/docs/FRONTEND_PLAN.md | 19 +- frontend/docs/FRONTEND_SESSION_PROMPT.md | 3 +- frontend/docs/STACK_DECISIONS.md | 10 +- frontend/docs/api-contract/openapi.yaml | 120 ++++- frontend/docs/frontend-PROGRESS.md | 347 ++++++++++++- frontend/eslint.config.js | 49 +- frontend/scripts/scenes.mjs | 530 +++++++++++++++++++- frontend/scripts/shot.mjs | 1 + frontend/src/api/client.ts | 3 +- frontend/src/api/contract.test.ts | 9 + frontend/src/api/contract.ts | 18 +- frontend/src/api/index.ts | 11 +- frontend/src/api/queries.ts | 28 ++ frontend/src/api/scenarios.ts | 1 + frontend/src/api/schema.ts | 120 ++++- frontend/src/api/upload.test.ts | 254 ++++++++++ frontend/src/api/upload.ts | 143 ++++++ frontend/src/api/vocabulary.ts | 64 ++- frontend/src/i18n/catalogue.test.ts | 12 +- frontend/src/i18n/ru.ts | 74 ++- frontend/src/mock/book.ts | 4 +- frontend/src/mock/events.ts | 35 +- frontend/src/mock/handlers.ts | 32 +- frontend/src/mock/intake.ts | 377 ++++++++++++++ frontend/src/mock/live.ts | 43 +- frontend/src/mock/worlds.ts | 94 ++++ frontend/src/showcase/About.module.css | 11 + frontend/src/showcase/About.tsx | 113 +++-- frontend/src/showcase/AddBook.module.css | 63 ++- frontend/src/showcase/AddBook.tsx | 304 +++++++++-- frontend/src/showcase/Context.tsx | 5 +- frontend/src/showcase/Library.module.css | 19 + frontend/src/showcase/Library.tsx | 102 +++- frontend/src/showcase/RunStart.module.css | 41 ++ frontend/src/showcase/RunStart.tsx | 189 +++++++ frontend/src/showcase/Showcase.tsx | 39 +- frontend/src/showcase/format.ts | 30 ++ frontend/src/showcase/languages.ts | 16 + frontend/src/showcase/units.ts | 17 + frontend/src/showcase/useIntakeEnd.ts | 33 ++ frontend/src/showcase/useRunStream.test.tsx | 127 ++++- frontend/src/showcase/useRunStream.ts | 54 +- frontend/src/tokens/tokens.css | 10 +- frontend/src/tokens/tokens.test.ts | 47 +- frontend/src/ui/Checkbox.module.css | 55 ++ frontend/src/ui/Checkbox.tsx | 29 ++ frontend/src/ui/ProgressBar.module.css | 36 ++ frontend/src/ui/ProgressBar.tsx | 32 ++ frontend/src/ui/Select.module.css | 69 +++ frontend/src/ui/Select.tsx | 58 +++ frontend/src/ui/Slider.module.css | 68 +++ frontend/src/ui/Slider.tsx | 72 +++ frontend/src/ui/Tree.tsx | 16 +- 55 files changed, 3877 insertions(+), 206 deletions(-) create mode 100644 frontend/src/api/upload.test.ts create mode 100644 frontend/src/api/upload.ts create mode 100644 frontend/src/mock/intake.ts create mode 100644 frontend/src/showcase/RunStart.module.css create mode 100644 frontend/src/showcase/RunStart.tsx create mode 100644 frontend/src/showcase/languages.ts create mode 100644 frontend/src/showcase/units.ts create mode 100644 frontend/src/showcase/useIntakeEnd.ts create mode 100644 frontend/src/ui/Checkbox.module.css create mode 100644 frontend/src/ui/Checkbox.tsx create mode 100644 frontend/src/ui/ProgressBar.module.css create mode 100644 frontend/src/ui/ProgressBar.tsx create mode 100644 frontend/src/ui/Select.module.css create mode 100644 frontend/src/ui/Select.tsx create mode 100644 frontend/src/ui/Slider.module.css create mode 100644 frontend/src/ui/Slider.tsx diff --git a/frontend/README.md b/frontend/README.md index d34d500f..5f9d3104 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -61,9 +61,10 @@ npm run scenes # сценарии интеракций: клик → ка вход в контракт, написанный до него. Всё содержимое ушло в спеку и спутник ниже; остаётся как аудиторский след (как каждая просьба выводилась из кода движка), место ему в `docs/archive/`. - [`docs/api-contract/openapi.yaml`](docs/api-contract/openapi.yaml) — **сам контракт v0, - OpenAPI 3.1: нормативная поверхность.** Линтуется spectral шестым шагом `npm run check`, - из неё генерится `src/api/schema.ts`. Черновик на ратификацию: дом готовой копии — - `docs/architecture/14-api-contract/`, зона оркестратора. + OpenAPI 3.1: нормативная поверхность.** Линтуется spectral шагом `npm run check`, из неё + генерится `src/api/schema.ts`. ⚠ **Не черновик** (исправлено S4 14.08): контракт ратифицирован + D39.99, эта копия — байт-зеркало канона `docs/architecture/14-api-contract/`; правит её + фронт-сессия, переносит в канон диффом оркестратор при лендинге (прецедент Ф-47/0.2.2). - [`docs/API_CONTRACT_DRAFT.md`](docs/API_CONTRACT_DRAFT.md) — спутник спеки: откуда взято каждое решение (выведено из кода движка · предложено фронтом · открыто), обоснования, зависимости и двенадцать открытых вопросов. При расхождении по форме побеждает YAML, diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md index 4786088e..ca8127e2 100644 --- a/frontend/docs/BACKLOG.md +++ b/frontend/docs/BACKLOG.md @@ -12,7 +12,7 @@ | ~~Ф-9~~ | ~~**Хвосты гейтов после адверсариального ревью S1**~~ — **все четыре закрыты 04.08 в S2**, каждая живым нарушением: именованные и системные цвета в TSX ловятся разрешающим списком на цветных атрибутах (`fill`/`stroke`/`color`/`stopColor`/`floodColor`/`lightingColor` — только `none`, `currentColor`, `inherit`, `transparent`, `var(--…)`) · значение CSS-переменной в `style={{…}}` обязано быть числом или токеном · носитель `style` внутри объектного литерала запрещён, и тем самым закрыт JSX-спред · системные цветовые ключевые слова закрыты `declaration-property-value-disallowed-list` по ВСЕМ свойствам, то есть и в сокращённых записях `outline`/`box-shadow`/`border`. Перечень проверенных форм (15 TSX + 14 CSS) — `FRONTEND_PLAN.md` §5.4.1. **Граница названа честно и там же:** гейт цветных атрибутов ловит литерал и шаблон, но не вычисленное значение (`const c = 'red'; `) — синтаксически это не берётся, и объявлять строку «закрытой без остатка» было бы ровно тем переобещанием, из-за которого она и заведена | закрыт для литеральных форм, граница названа | адверсариальное ревью S1 | | ~~Ф-10~~ | ~~**Хвосты структурных тестов**~~ — **закрыты 04.08 в S2.** `cssModules.test.ts` строит пары по ИМПОРТАМ, а не по совпадению имён файлов, видит деструктуризацию `const { name } = styles` и выкидывает `url(...)` и строковые литералы до поиска классов; проверка сиротства стала «модуль, который никто не импортирует». `tokens.test.ts` получил замок полноты: каждый токен обязан попасть либо в замеры, либо в явный список подобранных, и обратно — список не может ссылаться на исчезнувший токен. Плюс новое: числа для виртуализатора живут в `src/tokens/measures.ts` и сверяются с одноимёнными токенами. Семь живых нарушений, перечень — `FRONTEND_PLAN.md` §5.4 | закрыт | адверсариальное ревью S1 | | ~~Ф-11~~ | ~~**Контраст ниже порога WCAG на 4 узлах на маршрут**~~ — **ЗАКРЫТА 09.08 в S3.5 замером: axe печатает НОЛЬ узлов контраста на всех семи маршрутах.** Решение владельца тут не понадобилось, потому что вопрос был поставлен неверно: узлы давал не «весь приглушённый тон», а КОНКРЕТНАЯ роль — `--color-text-muted` (#707479 на панели = 3.85:1) стоял под текстом, несущим смысл. В S3.5 весь такой текст переведён на `--color-text-secondary` (5.36:1 до перекраски, 5.6:1 после), а `--color-text-muted` остался только под НЕтекстом — иконками и дорожками индикаторов, к которым порог для текста не предъявляется. Заодно палитра стала тёплой (замечание 16), и это подняло числа ещё немного. Верности референсу это не стоило ничего: приглушённая ступень как ступень осталась | закрыт | ревью S1 №2 → S3.5 | -| Ф-12 | **Масштаб списков** — **главное закрыто 04.08 в S2 замером, а не обещанием.** Заведён маршрут `/scale`: та же оболочка на настоящем масштабе — 2284 раздела и банк на 1200 терминов (`src/mock/scale.ts` через `getScaleFixture()`). Дерево и банк виртуализованы по умолчанию (RAC `Virtualizer` + `ListLayout`), и это измерено в живом браузере: на 2293 узлах дерева в DOM держится **31 строка**, `scrollHeight` 59 618px, кадр при прокрутке — медиана 22 мс, p95 31 мс; банк на 1200 терминах — 30 строк в DOM, медиана 16,7 мс. Прокрутка до конца и обратно счётчик строк не растит. **Замечания намеренно НЕ виртуализованы:** их список пер-главный, а юнитов на главу ≈1,9 (ПТ-21) и флаг у юнита один — 10³ там не бывает по построению; сводный вид по книге, если он появится, придёт с S6 и получит тот же примитив `ui/List`. **Два хвоста строки НЕ сделаны и получают диспозицию, а не зачёркивание** (нашло адверсариальное ревью S2): дерево осталось плоским без группировки по сотням — это работа экрана глав (S4), у оболочки для неё нет ни данных, ни требования; банк без разделения «подписано / ждёт решения» — это работа подписного экрана (S5), справочному виду в 320px разделение только мешает | масштаб закрыт; группировка — S4, разделение банка — S5 | ревью S1 №2 | +| Ф-12 | **Масштаб списков** — **главное закрыто 04.08 в S2 замером, а не обещанием.** Заведён маршрут `/scale`: та же оболочка на настоящем масштабе — 2284 раздела и банк на 1200 терминов (`src/mock/scale.ts` через `getScaleFixture()`). Дерево и банк виртуализованы по умолчанию (RAC `Virtualizer` + `ListLayout`), и это измерено в живом браузере: на 2293 узлах дерева в DOM держится **31 строка**, `scrollHeight` 59 618px, кадр при прокрутке — медиана 22 мс, p95 31 мс; банк на 1200 терминах — 30 строк в DOM, медиана 16,7 мс. Прокрутка до конца и обратно счётчик строк не растит. **Замечания намеренно НЕ виртуализованы:** их список пер-главный, а юнитов на главу ≈1,9 (ПТ-21) и флаг у юнита один — 10³ там не бывает по построению; сводный вид по книге, если он появится, придёт с S6 и получит тот же примитив `ui/List`. **Два хвоста строки НЕ сделаны и получают диспозицию, а не зачёркивание** (нашло адверсариальное ревью S2): дерево осталось плоским без группировки по сотням — это работа экрана глав (S4), у оболочки для неё нет ни данных, ни требования; банк без разделения «подписано / ждёт решения» — это работа подписного экрана (S5), справочному виду в 320px разделение только мешает | ⚠ **Группировка ЗАКРЫТА 14.08 в S4:** дерево свыше 200 разделов складывается по СОТНЯМ (`Library.tsx`, `groupSize`/`groupFrom`), 2284 раздела дают 23 строки вместо 2284. Число не из вкуса: главу адресуют порядковым номером, и сотня — шаг, которым их считает человек; замер говорит то же с другой стороны — плоское дерево рисуется дёшево (31 строка в DOM), а вот полоса прокрутки на 2284 строках даёт 59 618px хода и треть пикселя на строку. Ниже порога дерево остаётся ПЛОСКИМ: книге из дюжины разделов складка только мешает. Группа, в которой стоит выбранный раздел, раскрывается сама — и остаётся закрываемой рукой (управляемое раскрытие, `ui/Tree.tsx`). Осталось: разделение банка — S5 | масштаб и группировка закрыты; разделение банка — S5 | ревью S1 №2 → S4 14.08 | | Ф-14 | **Вход фронта в контракт API (единый бэклог, строка 95)** — **доведён до артефакта 04.08 в S3: [`API_CONTRACT_INPUT.md`](API_CONTRACT_INPUT.md)**, перечень с обоснованием на каждый пункт, каждое обоснование грунтовано `file:line` движка (прочитано, не пересказано). Внутри: язык кодами · идентификатор главы отдельно от номера (номер производный от чанкера, `chunker.go:101-118` — пустая глава не потребляет номер, правка исходника сдвигает нумерацию) · единица пары = ЮНИТ с ключом лидера `(chapter, firstChunkIdx)`, `export.go:88-93` · состояние у прогона, у главы выполнение · прогресс пофазно по юнитам · `run_id` · трёхзначный `auto\|draft\|approved` вместо булева `signed` · непрозрачный ключ строки банка (автоинкремент движка стабильным НЕ является — `migrate.go:172-174`, банк заменяется целиком каждый прогон) · поля `type`/`sense`/`since_ch`/`source` с точным словарём · подпись = набор решений promote/decline, стоп снимается ТОЛЬКО полным набором (`mining.go:200-202`) · продуктовый словарь вердиктов и запрет `detail` на стороне контракта · монотонный `id` + `Last-Event-ID` + ревизия на чтениях из того же счётчика · **разрешающий список полей вместо вычитания** (инвентарь протечек `StatusReport`/`ChapterPassport`: деньги · `routing`/модели · снапшот/дрифт/ре-билл) · правило эволюции словарей под Ф-22. Плюс 6 вопросов (К-1..К-6), на которые фронт ответа не имеет. **Найдено при сборке — четвёртый экземпляр класса «фикстура выдумала форму»:** титульная политика pack-13 рендерит заголовок ДЕТЕРМИНИСТИЧЕСКИ («Глава {n}» из `configs/langpacks/zh-ru/heading.txt`), вырезает исходный маркер из текста для модели (`chunker.go:110-118`) и ВКЛЕИВАЕТ титул внутрь финального текста первого юнита (`export.go:209-215`), оставляя колонку исходника без него — а фикстура рисует «Раздел N. …» в дереве и неснятый «第二节:» в оригинале. Не чинится до ответа на К-2: правка формы заголовка без контракта даст пятую выдумку. **По слову владельца тем же днём написан и ЧЕРНОВИК самого контракта — [`API_CONTRACT_DRAFT.md`](API_CONTRACT_DRAFT.md)** (12 разделов + приложение-карта вердиктов; каждое место помечено «выведено из кода» / «предложено фронтом» / «открыто»; двухфазный поток ВЫВЕДЕН из `--verify-bank`, а не спроектирован; в конце — прогон ревью-вопроса строки 95 по шести изменениям движка). Самопроверка черновика нашла дыру в самой лестнице статусов строки 95: между «разбор» и «перевод» нет состояния покоя, а библиотека обязана показывать разобранную книгу, которую не запускали ⇒ заведено `not_started`, и сегодняшние девять значений типов разводятся на десять. **Дофикс по ревью оркестратора (04.08) исполнен, форма сменена по решению владельца:** нормативная поверхность контракта — теперь **OpenAPI 3.1 `docs/api-contract/openapi.yaml`** (spectral шестым шагом `check`, openapi-typescript генерит `src/api/schema.ts`, тест дрифта + ветка неизвестного значения на шве `src/api/contract.ts`, MSW-хендлер как proof-of-form); md стал спутником — провенанс, обоснования, К-вопросы. Закрыты оба блокера ревью (состояние пары выводится из ПАРЫ «вердикт + наличие текста», а не из вердикта — флагнутый юнит легально приходит С текстом; чтение банка помечено зависимостью «канала нет»), восемь ✓-инфляций пере-размечены на ◆, внесены все фактические правки (фантом `auto` в провенансе убран по путям записи · `upstream_not_ok` добавлен · ПТ-34 заведён пунктом · стоп по потолку разведён с `failed` · правило разрыва `Last-Event-ID` · смягчён §12 про чанкер). Открытых вопросов стало девять: добавлен К-9 — **отказ прескрина (строка 94) не выразим ни одним из десяти статусов**. **Ратифицировать и перенести в `docs/architecture/14-api-contract/` — оркестратор; фронт этого не может** (шапка этого файла). **Подрезана 04.08 по слову владельца:** из описаний спеки убран провенанс и грунты `file:line` — они дублировали спутник и разошлись бы с ним при первой правке; осталось норматив плюс та оговорка «почему», что предотвращает конкретную ошибку. Спека переведена на английский, потому что её описания приезжают доккомментариями в генерённый `src/api/schema.ts` (Ф-25). Адверсариальная проверка подрезки нашла в спеке три расхождения с самим спутником (провенанс `mined` подан как «никем не подписано» — это ось `status`; ревизия кадра объявлена фактом при открытом К-4; `Run.status` берёт книжный словарь целиком) и два вопроса, которых не было: **К-10** пофазность выполнения главы и **К-11** условная обязательность полей ⇒ открытых стало одиннадцать. **Бумага сокращена тем же днём:** `API_CONTRACT_INPUT.md` под ⚠-баннером «исполнено, не поддерживается» — его 18 разделов лежат один-в-один в §2 спутника, и как живой документ он был третьим местом правки; не удалён (untracked, удаление невосстановимо), перенос в `docs/archive/` — оркестратору. Из спутника убран §1 (пины и отклонение по пиру дублировали `STACK_DECISIONS.md` §3 и Ф-23/Ф-24). **Второй пасс ревью (round-2) исполнен:** чтение выгрузки + К-12 · условие `if action=promote → dst` непустой (spectral валидирует, генератор ИГНОРИРУЕТ — записано в К-11 измерением) · `kind` nullable с правилом пустого, пометка ✓→◆ · `resume` после потолка помечен зависимостью на платформенный механизм поднятия · §2.7 переписан: «текст + замечание» это класс, но `glossary_miss` в него не входит (проверено по `memberDrops`/`waverun`), фикстура `r2-2` переведена на c-lite drop · ветка неизвестного добита до шести словарей. Открытых вопросов двенадцать. **08.08 (S3): РАТИФИЦИРОВАН и ПРАВЛЕН.** Контракт ратифицирован оркестратором как D39.99; S3 внесла в зонную копию весь список правок своего промта и бампнула версию до **0.2.0** — `paused`, `eta_seconds`, метка главы из данных, пер-книжная ревизия с правилом `>= R` и обязательным `resync_required` после полной замены, keyset-курсор на ВСЕХ списках, `X-TM-Client`, `Run.paused_reason`, `GET /usage`, `GET /books/{id}/run-options` с потолком прогона в ГЛАВАХ, same-origin. Байт-сверка с каноном намеренно даёт расхождение: канон правит оркестратор, диффом. Открытых вопросов стало **семь** (закрыты К-1/К-3/К-4/К-5/К-7/К-8/К-12, добавлен К-13 — Ф-31). Что переносить в канон — канон `docs/architecture/14-api-contract/` (патч ПРИМЕНЁН и удалён, D39.115) | правки внесены · ждут пере-ратификации диффом | ревью S1 №3 · сборка S3 04.08 · дофикс ревью 04.08 · подрезка 04.08 | | ~~Ф-15~~ | ~~**`src/api/` синхронен, состояний загрузки и ошибки в типах нет**~~ — **ЗАКРЫТА 08.08 в S3.** `src/api/` асинхронен и разговаривает HTTP: MSW как СЕТЕВОЙ мок (а не подменённый модуль), поэтому приложение делает настоящий `fetch` и настоящий `EventSource`, и ветки ожидания/ошибки/пустоты — это код, который работает в бою, а не второй его экземпляр. Ветки живут в одном месте (`showcase/Loaded.tsx`) и приезжают ВМЕСТЕ со слоем данных, до первого продуктового экрана, как строка и требовала. Типы сведены с контрактом: `types.ts` удалён, экраны живут на суженных типах `api/contract.ts`. **Что нашёл сам скриншот-цикл, а не рассуждение:** выключенный запрос TanStack Query остаётся `pending` НАВСЕГДА, и на пустой библиотеке правая панель вечно показывала «Идёт загрузка» — различение «ждём» и «выключено» (`fetchStatus === 'idle'`) добавлено после того, как это увидели на кадре. Фикстуры всех состояний — шесть миров, по маршруту на каждый | закрыт | ревью S1 №3 | | Ф-13 | **`eslint-plugin-jsx-a11y` не встаёт на ESLint 10** (peer `^3..^9`, проверено `--dry-run` → ERESOLVE). Статическую половину доступности держит axe в скриншот-цикле; плагин подключить, когда выйдет совместимый мажор | триггер: мажор плагина | ревью S1 №2 | @@ -23,11 +23,11 @@ | Ф-20 | **Мёртвый шрифт и мёртвые токены в поставке.** `@fontsource-variable/jetbrains-mono` грузится в бандл, но `--font-mono` не выбран ни одним правилом: художественную прозу моноширинным не набирают, а во Fleet на этом месте был код. Туда же несколько токенов, которые ни разу не используются (`--color-current-line`, `--color-hint`). Замок полноты токенов (S2) требует классифицировать токен, но не требует его использовать. Решать на S6, когда читалка станет настоящей: либо моноширинный находит работу, либо импорт и токены уходят | S6 | адверсариальное ревью S2 | | ~~Ф-22~~ | ~~**У закрытых юнионов нет ветки неизвестного значения**~~ — **ЗАКРЫТА 08.08 в S3, и механизмом, а не дисциплиной.** Ветка стоит НА ШВЕ (`src/api/vocabulary.ts` + нормализация в `contract.ts`): значение, пришедшее с провода, сужается один раз, там, где входит, и незнакомое становится `null`, а не роняет дерево на `undefined.tone`. Словарь объявлен как `Record<Значение, Смысл>`, поэтому список значений ВЫВОДИТСЯ из записей — разойтись с типом ему нечем, а новое значение контракта роняет `tsc` с именем пропавшего значения в тексте ошибки (проверено живым нарушением, `FRONTEND_PLAN.md` §5.4.3). Тем же ходом закрыт названный в строке второй дефект того же класса: незнакомая ступень замечания больше НЕ уходит в спокойную ветку — неизвестная ступень берёт ветку внимания, потому что единственное, чего ось важности делать не вправе, это прятать важное. Продуктовое слово ступени по-прежнему за владельцем (Ф-21), и оно тут не выдумано | закрыт | адверсариальное ревью S2 → ревью оркестратора 04.08 → S3 08.08 | | Ф-21 | **Ступень замечания различается только цветом полоски** (`attention` синяя, `glance` серая). Зрячему разница читается ещё и светлотой, но скринридеру приоритет не достаётся вовсе. Лечение — слово, как это уже сделано для состояния прогона книги; **какое именно слово, решает владелец**: словарь замечаний продуктовый (ПТ-33), а §3.8 требует, чтобы экран не выглядел тревожным, так что «важно/неважно» не годится | решение владельца | адверсариальное ревью S2 | -| Ф-18 | **Бандл 545 КБ (168 КБ gzip) одним куском** — замер 04.08 после установки `react-aria-components`; Rolldown предупреждает про порог 500 КБ. Для десктопного SPA за логином это не авария (индексации нет, сеть локальная), но одним куском едут и экраны, которых пользователь ещё не открыл. Лечение — разбиение по маршрутам, и делать его осмысленно после S4–S7, когда экраны появятся: сейчас резать нечего | после S7 либо при жалобе на первый экран | S2, замер | +| Ф-18 | **Бандл едет одним куском, и кусок растёт.** Замеры главного чанка: 545 КБ (04.08, S2) → 724 130 байт (S3.7) → **800 219 байт, 243 446 gzip (S4 14.08, `npx vite build` + `gzip -c`)**. Прирост пака S4 — +76 089 байт: четыре примитива формы (`Select` тянет слой поповера, `Slider` — свой пакет react-aria), экраны интейка и запуска, мир фикстуры и текст каталога. Для десктопного SPA за логином это не авария (индексации нет, сеть локальная), но одним куском едут и экраны, которых пользователь ещё не открыл — включая мок-сеть (Ф-29), которая в бою не нужна вовсе. Лечение прежнее — разбиение по маршрутам после S7, когда экраны появятся; ⚠ добавилось второе, дешёвое и раннее: выкинуть `src/mock/` из продакшн-сборки в день живой платформы (это Ф-29, и она теперь ещё и про вес) | после S7 либо при жалобе на первый экран | S2, замер; пере-замерено S3.7 и S4 | | Ф-23 | **`openapi-typescript` держится на точечном `overrides`, а не на честном пире.** Генератор объявляет `typescript@^5.x` (7.13.0 вышла 11.02.2026, до релиза TS 6), у нас намеренный TS 6 — Ф-3. `--legacy-peer-deps` **отвергнут проверкой исполнением:** после него обычный `npm install` падает, то есть дерево ломается у каждой следующей сессии и у `prepare`-инсталлера pre-commit хука. Взят `overrides` на ОДИН пакет (`{"openapi-typescript":{"typescript":"$typescript"}}`) вместо глобального ослабления проверки пиров; работоспособность доказана: `npm ci` с нуля зелёный (0 уязвимостей), генератор запускается на TS 6, тест дрифта проходит. Снять, когда генератор расширит пир либо когда приедет TS 7.1 и Ф-3 закроется | триггер: пир генератора расширен ИЛИ TS 7.1 (Ф-3) | S3, дофикс контракта 04.08 | | Ф-24 | **AsyncAPI рассмотрен и отложен.** Он описал бы SSE-канал строже, чем нынешняя таблица «имя события → схема» в описании `EventEnvelope`. Отложен, потому что это второй нормативный артефакт и второй линтер ради ОДНОГО эндпоинта, при том что полезные нагрузки уже описаны схемами OpenAPI и попадают в генерённые типы. Пересмотреть, когда каналов станет больше одного либо когда понадобится генерировать клиент потока, а не разбирать кадры руками | триггер: второй канал ИЛИ генерация клиента потока | S3, дофикс контракта 04.08 | | ~~Ф-25~~ | ~~**`src/` смешанный по языку комментариев.**~~ — **ЗАКРЫТА 10.08 в S3.7, и шире, чем строка просила.** Правило владельца по ходу пака ужесточено до «в КОДЕ никакого русского»: комментарии, имена тестов, сообщения ошибок, логи скриптов и служебные строки сценариев — английские. Свод сделан одним проходом по ВСЕМУ дереву зоны (54 файла: `src/**`, `scripts/**`, `eslint.config.js`, `stylelint.config.js`, `vite.config.ts`, `tsconfig.json`, `index.html`), а не по списку тронутых сессией. Держит это не дисциплина, а гейт: `src/i18n/catalogue.test.ts` подметает кириллицу построчно по ts/tsx/css/mjs/py (идентификаторы вида Ф-49 исключены явно — это имена строк бэклога, а не проза). Граница прежняя и теперь машинная: русский легален ровно в двух местах, и оба — данные (каталог сообщений `src/i18n/ru.ts` и фикстуры `src/mock/`). Комментариев, оказавшихся ВРАНЬЁМ против кода, свод не нашёл ни одного — агенты сверяли каждый по коду | закрыт | слово владельца 04.08 → S3.7 10.08 | -| Ф-26 | **Выгрузка и загрузка книги в слое данных не заведены — намеренно.** `POST /books` (multipart с прогрессом отправки) и пара `POST/GET /books/{id}/exports` (опрос по `Retry-After`) несут МЕХАНИЗМ, а не поверхность: прогресс загрузки — часть экрана S4, цикл опроса — часть экрана S7. Строить их сейчас значило бы построить механизм без задачи, что проектный норматив прямо запрещает, и выбросить его при первом же настоящем экране. Контракт обе поверхности объявляет, генерённые типы для них есть — недостающего кода ровно на десяток строк, когда появится, кому их звать | S4 (загрузка) · S7 (экспорт) | S3 08.08 | +| Ф-26 | **Загрузка книги ПОСТРОЕНА (S4), выгрузка ждёт S7.** Исходная формулировка: «выгрузка и загрузка книги в слое данных не заведены — намеренно». `POST /books` (multipart с прогрессом отправки) и пара `POST/GET /books/{id}/exports` (опрос по `Retry-After`) несут МЕХАНИЗМ, а не поверхность: прогресс загрузки — часть экрана S4, цикл опроса — часть экрана S7. Строить их сейчас значило бы построить механизм без задачи, что проектный норматив прямо запрещает, и выбросить его при первом же настоящем экране. Контракт обе поверхности объявляет, генерённые типы для них есть — недостающего кода ровно на десяток строк, когда появится, кому их звать | ⚠ **Половина загрузки ЗАКРЫТА 14.08 в S4:** `POST /books` живёт в `src/api/upload.ts` — XHR, а не `fetch`, потому что стандарт Fetch не отдаёт прогресс ТЕЛА запроса вовсе (поток в запросе — только Chromium и только HTTP/2), а книга это минуты тишины. Порядок частей формы — правило провода (PD-172, `file` последним) и держится тестом; отказы 400/404/408/413 — состояния формы. Экспорт (`POST/GET /books/{id}/exports`) по-прежнему ждёт S7 | S7 (экспорт) | S3 08.08 → S4 14.08 | | Ф-27 | **`GET /usage` читается слоем данных, но ни один экран его не показывает.** Так и задумано: страница статуса использования живёт в настройках (ПТ-35/П-5), а настройки строит S7. Функция и её тест существуют, чтобы поверхность контракта была покрыта целиком и чтобы S7 не пришлось трогать шов; если S7 сдвинется далеко, строку пересмотреть — непрочитанный код тоже стоит денег | S7 | S3 08.08 | | Ф-28 | **Три high-уязвимости в дереве, все транзитивные и все в инструментах сборки** (замер 08.08): `js-yaml` через `@redocly/openapi-core` ← `openapi-typescript`, `js-yaml` через `cosmiconfig` ← `stylelint`, `nanoid` через `postcss` ← `stylelint`. **К новому пину S3 отношения не имеют** — проверено `npm ls`, `@tanstack/react-query` ни одну из них не тянет; это адвайзори, опубликованные после 04.08, когда журнал фиксировал «0 уязвимостей». В браузер не едет ничего из перечисленного. Пины ратифицированы (`STACK_DECISIONS` §3), и двигать их посреди сессии, которая на этих же инструментах держит гейт, было бы разменом безопасности сборки на риск красного дерева у всех следующих сессий — поэтому измерено и передано, а не пропатчено молча | решение оркестратора/владельца: бампнуть пины отдельным проходом | S3 08.08, `npm audit` | | Ф-29 | **Мок-сеть едет в продакшн-бандл.** `startMocking()` подтягивает `src/mock/` динамическим импортом, но скриншот-цикл гоняет `vite build` + `preview`, то есть продакшн-режим, и без мока там нечего показывать. Пока платформы на том же origin нет, это цена работоспособности; в день, когда фронт заговорит с настоящей платформой, папка `mock/` удаляется целиком и импорт уходит вместе с ней — ровно так, как обещал шов. Строка нужна, чтобы это удаление кто-то сделал ОСОЗНАННО, а не обнаружил мок в бою | триггер: живая платформа на том же origin | S3 08.08 | @@ -38,14 +38,14 @@ | Ф-6 | **`@tanstack/react-virtual`** — в резерве, подключать только по замеру (дефолт виртуализации — RAC Virtualizer). **Замер сделан 04.08 (S2) и триггер НЕ наступил:** RAC `Virtualizer` держит 2284 раздела на 31 строке в DOM при медиане кадра 22 мс и 1200 терминов на 30 строках при 16,7 мс. Вторая библиотека виртуализации сейчас была бы платой без выигрыша; пересматривать — если появится список с переменной высотой строки (читалка S6) и `estimatedRowSize` не справится | по замеру; замер 04.08 отрицательный | STACK_DECISIONS §2 | | Ф-33 | **Поиска по ТЕКСТУ книги в контракте нет вовсе.** Замечание владельца 4 («две кнопки поиска, делают одно и то же») разведено так: лупа в правом верхнем углу стала настоящей палитрой быстрого перехода к разделу или термину, а вкладка-заглушка «Поиск» из левой панели СНЯТА. Снята потому, что построить её нечем: у контракта пятнадцать ручек и ни одной поисковой (`GET /books/{id}/…` даёт списки, не запросы), то есть вкладка была обещанием без адресата. Вопрос не фронта: полнотекстовый поиск по книге — это либо ручка платформы, либо индекс на клиенте поверх выгрузки, и то и другое решает автор контракта. До ответа поиск по книге живёт как хвост читалки | S6 либо ручка контракта | S3.5, замечание 4 | | Ф-34 | **Светлая тема: цена честно замерена, дёшево не выходит.** Проверено по коду, а не на глаз. Дёшево — только половина: значения цвета лежат в одном месте (`tokens.css`), второй набор под `:root[data-theme='light']` механичен. Дорого — вторая половина: подпись оболочки, снятая с Fleet, это «карточки на почти чёрном фоне», и она не инвертируется значениями — в светлой теме отношения поверхностей приходится ПЕРЕПРИДУМАТЬ (что светлее, панель или геп; чем отделять карточку без теней; что делать с вырезом активной вкладки, который держится на паре «хром ↔ полотно»). Плюс переключатель и его персист, плюс пере-прогон axe по семи маршрутам на второй теме, плюс замок токенов на второй набор. Оценка: отдельный проход масштаба S7, а не хвост фикс-пака. Пункт назван в каркасе настроек честно: «сейчас доступна только тёмная» | S7 либо отдельный пакет | S3.5, замечание 15 | -| Ф-35 | **Язык интерфейса: код-половина ЗАКРЫТА 10.08 в S3.7, продуктовая — на владельце.** Сделано: словарь строк `src/i18n/ru.ts` (157 ключей) — единственный дом слов интерфейса; механизм отраслевой, не самописка — `@internationalized/string` 3.2.10 (i18n-слой Adobe, на котором react-aria отдаёт свои строки; сравнение с react-intl и lingui — `STACK_DECISIONS.md` §1); ключ типизирован ИЗ каталога, поэтому опечатка роняет `tsc`; выбор языка — стор `src/i18n/language.ts` с персистом, от него же берут локаль `I18nProvider` и весь `Intl` (`showcase/format.ts`); второй язык = второй файл того же вида плюс строка в `language.ts`, ни один компонент не трогается. Гейт против нового хардкода стоит и проверен живым нарушением ДВАЖДЫ: ESLint запрещает текст в разметке по МЕСТУ (ловит и `title="Настройки"`, и `title="Settings"` — алфавит ни при чём), `catalogue.test.ts` держит «код зоны английский» по комментариям, CSS, python и корневым конфигам. Цена замерена: бандл 716131 → 723102 байта (+6971, из них ~7 КБ — сам текст каталога; библиотека уже была в дереве транзитивом react-aria-components, `npm ls` — одна копия). **Осталось владельцу:** какие языки продукт обещает (пока каркасный пункт настроек честно говорит «сейчас интерфейс только русский»), и переводы на них — их фронт не выдумывает | решение владельца: список языков | S3.5 → S3.7 10.08 | +| Ф-35 | **Язык интерфейса: код-половина ЗАКРЫТА 10.08 в S3.7, продуктовая — на владельце.** Сделано: словарь строк `src/i18n/ru.ts` — единственный дом слов интерфейса; механизм отраслевой, не самописка — `@internationalized/string` 3.2.10 (i18n-слой Adobe, на котором react-aria отдаёт свои строки; сравнение с react-intl и lingui — `STACK_DECISIONS.md` §1); ключ типизирован ИЗ каталога, поэтому опечатка роняет `tsc`; выбор языка — стор `src/i18n/language.ts` с персистом, от него же берут локаль `I18nProvider` и весь `Intl` (`showcase/format.ts`); второй язык = второй файл того же вида плюс строка в `language.ts`, ни один компонент не трогается. Гейт против нового хардкода стоит и проверен живым нарушением ДВАЖДЫ: ESLint запрещает текст в разметке по МЕСТУ (ловит и `title="Настройки"`, и `title="Settings"` — алфавит ни при чём), `catalogue.test.ts` держит «код зоны английский» по комментариям, CSS, python и корневым конфигам. ⚠ **Числа приведены к ЗАМЕРУ 14.08 (S4), арбитраж двух носителей:** ключей каталога было **157** (прогон `node -e "import('./src/i18n/ru.ts')…"` на дереве ДО пака — совпало с этой строкой и с живым `ru.ts`; «147» записи S3.7 неверно, но записи хроники не правятся); бандл до пака — **724130 байт** (`npx vite build`, главный чанк), то есть верна запись S3.7 (+7999 от 716131), а стоявшее здесь «723102 (+6971)» ошибочно. После пака S4: каталог **202 ключа**, главный чанк **800 219 байт**, 243 446 gzip (+76 089 к до-пакному — новые примитивы формы, интейк, фикстура мира и сам текст). **Осталось владельцу:** какие языки продукт обещает, и переводы на них — их фронт не выдумывает | решение владельца: список языков | S3.5 → S3.7 10.08 → числа сверены S4 14.08 | | Ф-36 | **Физический масштаб интерфейса на мониторе — ручки нет.** Замечание владельца 1 говорит «великоваты, особенно на 27″ 2K». Замер при вьюпорте референса 1280×764 расхождения не подтвердил: промежутки, ширины панелей, шаг строки и высота вкладки совпадают с Fleet (FRONTEND_PLAN §5.5). Значит речь про ФИЗИЧЕСКИЙ размер на конкретном экране, а он зависит от масштабирования ОС, и лечится он не сдвигом токенов вслепую (это разъедет витрину с референсом), а ручкой «плотность интерфейса» в настройках. Цена ручки названа честно: все размеры сегодня в px, для одного множителя их надо перевести в rem и задать `font-size` на корне — механически, но трогает КАЖДЫЙ размерный токен и все замеренные числа, поэтому отдельным проходом с пере-замером. ⚠ **Дополнено 10.08:** предел меры набора (`--reader-width`), заведённый S3.5 «пока нет ручки», владелец отверг словами «жёстко приклеен к центру, до этого было намного лучше» — токен удалён, набор снова во всю ширину панели. Значит длина строки на 2K остаётся открытой ровно до ручки плотности, и подпирать её геометрией больше нельзя | владелец (В-7) + отдельный проход | S3.5, замечание 1 | | Ф-37 | **Пометка черновика висит на статусе КНИГИ, а не главы.** Замечание 24 сделано данными контракта: пока прогон не довёл книгу до `ready`, текст помечен как черновой. Но у главы есть своё выполнение (`units_done/units_total`), и глава, уже прошедшая обе волны в незавершённой книге, получает ту же пометку, что и непереведённая. Точнее было бы по главе, и для этого в контракте не хватает одного: `units_done` не разведён по фазам (открытый вопрос К-10 спутника). До ответа взята безопасная сторона — предупредить лишний раз дешевле, чем не предупредить | контракт (К-10) → S6 | S3.5, замечание 24 | | Ф-38 | **Вкладка «Замечания» защищена сводкой по книге — слово за владельцем.** Замечание 8 («непонятно зачем, всё рисуется в тексте») по пер-ГЛАВНОМУ списку было верным: он дублировал читалку. Вкладка переделана в пер-КНИЖНУЮ сводку со ссылкой в раздел — сценарий «пройтись по всем местам, требующим внимания», который без неё не живёт и который не стоит ни одного лишнего запроса (контракт даёт замечания пер-книжно, Ф-32). Если владелец сценария не признает — вкладка снимается одной строкой в `Context.tsx`, вместе с ней уходит и `notesQuery` с экрана | решение владельца | S3.5, замечание 8 | | Ф-39 | **Банку не хватает полей для проверки термина, и выдумывать их фронт не стал.** Замечание 7/13 просит на термин род/пол, описание-обоснование и варианты перевода; в контракте (`BankTerm`) их нет — есть `src`, `dst`, `kind`, `status`, `origin`, `sense`, окно `since_chapter`/`until_chapter`. Показано всё, что есть; недостающее оформлено ЧЕРНОВИКОМ правки спеки с пометкой «предложено» (`frontend-PROGRESS.md`, запись S3.5) и не заведено ни в фикстуру, ни в типы — класс ошибки «фикстура выдумала форму» ловился четырежды (Ф-14). Ратифицирует оркестратор | оркестратор + владелец | S3.5, замечания 7 и 13 | | ~~Ф-40~~ | ~~**Вид поля ввода описан в двух модулях.**~~ — **ЗАКРЫТА 10.08 в S3.7.** Общий модуль `src/ui/Field.module.css` несёт `.input`, `::placeholder`, `:focus` и модификатор `.framed` (граница в покое — отличие формы от фильтра); примитивы остались РАЗНЫМИ, у каждого свой модуль со своей ролью (`TextField` — подпись и подсказка, `FilterField` — раскладка фильтра и гашение родной кнопки очистки). Обе половины лежат в ОДНОМ модуле намеренно: порядок между двумя CSS-модулями — это порядок их импортов, а модификатор обязан идти после базы | закрыт | S3.5 → S3.7 10.08 | | Ф-41 | **Спойлер банка раскрывается только наведением.** Клавиатура и тач его не раскроют, а перевод остаётся в DOM (скринридер прочитает). Кнопка раскрытия внутри строки списка невозможна: у роли `option` дети презентационные, и вложенный интерактив роняет гейт axe (та же ловушка, что Ф-17). Настоящее лечение — раскрытие на уровне СПИСКА (один переключатель «показывать термины из будущих разделов» над ним) либо экран подписи S5, где строка перестаёт быть строкой списка. ⚠ **Уточнено 10.08 (S3.6):** спойлер снят с ПЕРЕВОДА и поставлен на описание (`sense`) — закрытый перевод делал строку непроверяемой, а таблица существует ради проверки. Второй хвост строки («состояние замещается объяснением с N-го») ЗАКРЫТ: в таблице окно применимости и состояние — разные столбцы. Остался ровно один: раскрытие только наведением | S5 либо раньше по слову владельца | S3.5, ревью точности | -| Ф-42 | **У keep-alive нет потолка.** Вкладки центра не размонтируются (в этом и лекарство Ф-19), поэтому каждая закреплённая глава держит смонтированную читалку и свой запрос: замерено адверсариальным ревью — семь вкладок дают семь запросов и семь читалок. На фикстурных главах это ничего не стоит, на настоящих (глава бывает одним блоком в тысячи знаков) — стоит. Лечение из отраслевой практики: вытеснять самые давние закреплённые вкладки сверх N смонтированных, оставляя их в ряду. Порог называть по замеру на настоящей книге, а не на глаз | S4 (экран глав) либо при первой жалобе на память | S3.5, ревью корректности | +| Ф-42 | **У keep-alive нет потолка.** Вкладки центра не размонтируются (в этом и лекарство Ф-19), поэтому каждая закреплённая глава держит смонтированную читалку и свой запрос: замерено адверсариальным ревью — семь вкладок дают семь запросов и семь читалок. На фикстурных главах это ничего не стоит, на настоящих (глава бывает одним блоком в тысячи знаков) — стоит. Лечение из отраслевой практики: вытеснять самые давние закреплённые вкладки сверх N смонтированных, оставляя их в ряду. Порог называть по замеру на настоящей книге, а не на глаз | ⚠ **ЗАМЕРЕНО 14.08 в S4, потолок НЕ ставится.** Прогон в браузере с принудительной сборкой мусора (`page.requestGC()`), `/scale`, 12 закреплённых вкладок: куча 34,4 → 36,7 МБ (**+2,3 МБ, ≈190 КБ на вкладку**), узлов в DOM 1351 → 1571, чтений юнитов 2 → 11. Первый замер без GC давал +62 МБ и был мусором аллокаций — назван, чтобы никто не повторил. Значит память не авария: на настоящей книге (юнит в сотни раз длиннее фикстурного) это сотни килобайт на вкладку. Настоящая цена — СЕТЕВАЯ: каждая открытая вкладка перечитывается на границе стадии (`useRunStream`, кадр `status`), то есть 20 вкладок это 20 чтений на кадр. Потолок ставить не стал: он меняет поведение модели вкладок VS Code (закреплённая вкладка молча теряет содержимое — новый класс сюрприза), а порог всё равно называть по замеру на НАСТОЯЩЕЙ книге, которого у фикстуры нет. Пересмотр — при первой жалобе на память или когда прогон пойдёт на живой платформе | замерено, отложено с числами | S3.5, ревью корректности → S4 14.08 | | Ф-43 | **У чтения банка нет канала — вся витрина банка стоит на моке.** Банк живёт в приватном SQLite движка, платформе читать его запрещено (D39.85), артефакта экспорта банка нет; компаньон контракта (`14-api-contract/README.md` §3) прямо пишет «строки нет — заводит оркестратор», и в живом `docs/PROGRESS.md` я её не нашёл. Пока канала нет, таблица банка (замечание владельца второго круга 7) работает только на фикстуре, а подписной экран S5 строить не на чем | **оркестратор: завести строку единого бэклога** → бэкенд (экспорт банка) → платформа (проекция) | S3.6 10.08, разбор движка | | Ф-44 | **Действие «подписать банк» не нарисовано — намеренно.** Модель владельца и движка совпадают: подписывается банк ЦЕЛИКОМ, стоп снимается только когда решён каждый термин (`pipeline/mining.go:201`), в контракте это `POST /runs/{id}/resume` с `409` при неполном наборе. Но за действием стоит механизм подписи (S5), а под ним — отсутствующий канал чтения (Ф-43). Кнопка без действия запрещена нормой зоны, поэтому в S3.6 построен ВИД и честное состояние. ⚠ **Уточнено 10.08 по слову владельца:** счётчики «подписано N / ждут решения N» с экрана СНЯТЫ вместе со столбцом состояния строки — они и были той самой путаницей «подписываем банк или термины» из замечания 20 первого круга. В подвале — размер банка, при сужении «показано N из M», и состояние ВСЕГО банка («банк ждёт подписи») из статуса книги. Рисовать действие — когда есть чем его исполнить | S5, после Ф-43 | S3.6 10.08 | | Ф-45 | **Править термин в таблице нельзя — только читать.** Владелец: «разумеется в банке можно подправить какой то термин». Контракт это выражает (`BankDecision`: `promote` с непустым `dst` либо `decline`, частичное накопление на сервере), то есть редактирование строки — это отправка решения, а не PATCH. Экран решений = S5; в S3.6 таблица читающая, и это названо честно, а не замаскировано неактивными полями | S5 | S3.6 10.08, замечание 7 | @@ -55,7 +55,15 @@ | ~~Ф-49~~ | ~~**Текст открытой главы не подписан ни на один сигнал изменения.**~~ — **ЗАКРЫТА 10.08 в S3.7.** Кадр `chapter` инвалидирует чтение юнитов ИМЕННО той главы, которую называет (`keys.units`), кадр `status` — юниты всей книги (`keys.bookUnits`): границы стадий это единственный сигнал, пока `units_done` остаётся единым счётчиком (К-10). Тест — `showcase/useRunStream.test.tsx`, два случая: «кадр главы перечитывает ТУ главу и не трогает соседнюю» (счёт запросов к MSW) и «кадр статуса перечитывает текст всех открытых глав»; оба падают на до-фиксном коде | закрыт | S3.6 10.08 → S3.7 | | ~~Ф-50~~ | ~~**Кадр потока, пришедший до появления чтения в кэше, теряется молча.**~~ — **ЗАКРЫТА 10.08 в S3.7, обе половины.** (1) Кадр, не нашедший снимка, зовёт `resetQueries` по своему ключу. Именно reset, и это ЗАМЕР, а не вкус: `invalidateQueries` на запросе без данных ничего не делает — библиотека перезапускает чтение в полёте только когда данные уже есть (`Query.fetch`: `state.data !== undefined && cancelRefetch`), то есть возвращает промис того самого устаревшего чтения. (2) Ревизия многостраничного списка — МИНИМУМ из страниц (`client.ts`): рваный список свеж настолько, насколько стара его первая страница. Тесты: «кадр без снимка оставляет след» (счёт запросов) и «рваный список штампуется старшей страницей» (две страницы 1800/2000 → 1800, и `freshest` роняет его против кадра 1900); оба падают на до-фиксном коде | закрыт | S3.6 10.08 → S3.7 | | ~~Ф-51~~ | ~~**`resync` сбрасывает один гард из двух.**~~ — **ЗАКРЫТА 10.08 в S3.7.** На `resync_required` зовётся `resetQueries` по скоупу КНИГИ (`bookScope` — предикат по второму элементу ключа: id книги стоит там у каждого книжного чтения и больше нигде), то есть отметка ревизии уходит вместе со снимком, а библиотека, usage и другие книги не трогаются. Тесты: «resync выбрасывает книгу из кэша вместе с отметкой» (новая эпоха с НИЗШИМ номером принимается — на до-фиксном коде гард её ронял) и «resync одной книги не трогает чужие скоупы»; оба падают на до-фиксном коде | закрыт | S3.6 10.08 → S3.7 | -| Ф-52 | **`--color-danger` не дотягивает до порога нетекста ровно как `--color-note` дотягивал.** Замер S3.7 при починке находки приёмки 8.5: `#b82e45` на полотне панели `#181818` даёт **2.97:1** при пороге 3:1 для нетекстовых элементов — тот же класс и почти то же число, что у `--color-note` (2.98:1), который эта же сессия подняла до 3.07:1 по прямому указанию промта. Danger трогать не стала: он замерен с `fleet.png` дословно и в списке правок не стоял, а двигать принятый владельцем тон сверх задания — ровно та самодеятельность, которую аудит S3.6 уже наказал. Решение — оркестратору/владельцу: поднять тон на ступень (как note) либо принять число как есть и записать исключение | оркестратор + владелец | S3.7 10.08, замер | +| ~~Ф-52~~ | ~~**`--color-danger` не дотягивает до порога нетекста**~~ — **ЗАКРЫТА 14.08 в S4 по слову владельца, и не одним числом, а ПРАВИЛОМ.** Тон поднят минимальным сдвигом при сохранённых тоне и насыщенности (350°, S 0.60): `#b82e45` → **`#bd2f47`**, на полотне панели `#181818` это **3.10:1** против прежних 2.97 (порог нетекста 3:1; для сравнения `--color-note` стоит на 3.07). Токен переехал из «замеренных» в «подобранные по референсу» с числом референса в комментарии — тем же ходом, что `--color-note` в S3.7. Сверх задания поставлен гейт: `tokens.test.ts` считает контраст КАЖДОГО тона состояния (`note`/`ok`/`warn`/`danger`) о полотно и требует 3:1 — axe этого не ловит вовсе (контраст нетекста он не проверяет), и обе просадки нашлись руками, с разницей в пакет. Проверено живым нарушением: на прежнем значении гейт краснеет | закрыт | S3.7 10.08, замер → S4 14.08 | | Ф-53 | **Находка приёмки 8.1 (HIGH) НЕ ВОСПРОИЗВОДИТСЯ — оставлен сторож, а не механизм.** Утверждение «под `content-visibility: hidden` геометрия нулевая, замер ширин таблицы молча проваливается» проверено прямым замером в браузере: таблица, смонтированная в скрытой keep-alive вкладке `/scale`, держит 116 ячеек и ширины `121·122·133·232` — БАЙТ В БАЙТ те же, что у вкладки, с которой не уходили, и на до-фиксном коде тоже. Причина: запрос геометрии внутри пропущенного поддерева заставляет браузер посчитать раскладку, поэтому `read()` набирает столбцы. Первый заход сессии добавил под эту находку `IntersectionObserver` — механизм СНЯТ, потому что чинить нечего. Сценарий `npm run scenes hidden` (уход с вкладки ДО прихода банка и возврат) оставлен: сам путь реален и не был покрыт ничем. Если владелец увидит дефект руками — здесь лежит и замер, и готовый сценарий, куда его повторить | закрыто замером; пересмотр — по факту от владельца | S3.7 10.08, замер в браузере | | Ф-54 | **При зуме от 125% центральная панель обрезает СВОЁ содержимое, а не сжимается.** Замер сцены `zoom` (вьюпорт в CSS-пикселях = зум браузера): при 1152×720 центр режет 55px своего содержимого, при 960×600 — 98px, при 720×450 — 170px; окно при этом не переливается, три панели стоят в ряд, боковые честно сжимаются (левая доходит до 141px при своём минимуме 220). То есть карточка центра держит ширину, которой у слота уже нет, и первой уходит вторая колонка читалки — перевод. Кадр `--size 720x450 --dpr 1` это показывает глазом. Не чинил и не считаю это своим решением: ширина центра — часть вопроса «плотность интерфейса» (Ф-36, В-7 на владельце), а менять раскладку сверх задания пака — та самая самодеятельность, за которую аудит S3.6 уже наказал. Сцена ЧИСЛО печатает, но не утверждает: гейт, красный по построению, учит пропускать команду | владелец (В-7) + Ф-36 | S3.7 10.08, замер сцены `zoom` | | Ф-55 | **Стор языка есть, переключателя нет — и это не забывчивость.** `src/i18n/language.ts` держит выбор с персистом и отдаёт локаль каталогу, `I18nProvider` и `Intl`; `choose()` при этом не зовёт ни один экран, потому что содержимое настроек строит S7, а промт пака прямо запрещает его трогать. Пункт «Язык интерфейса» в каркасе настроек честно говорит «сейчас интерфейс только русский». Строка держит хвост, чтобы S7 не завёл ВТОРОЕ хранилище выбора рядом с этим | S7 | S3.7 10.08 | +| Ф-56 | **Конца интейка никто не объявляет — клиент опрашивает.** Живой канал принадлежит ПРОГОНУ (`GET /runs/{runId}/events`), а книга в `uploading`/`parsing` прогона не имеет вовсе: значит «разбор закончился» не приходит ничем. S4 закрыла дыру опросом (`src/api/queries.ts`, `intakePollMs = 3000`, только пока книга в интейке — вместе со статусом останавливается и опрос) плюс перечитыванием дерева на выходе из интейка (`useIntakeEnd.ts`). Это лечение, а не решение: правильным был бы кадр (или отдельный канал) о конце разбора, и это вопрос владельцу контракта, а не фронту. Пока опрос стоит, у него есть цена: чтение библиотеки раз в три секунды на всё время разбора (минуты на настоящей книге) | оркестратор → контракт | S4 14.08, построено на опросе | +| Ф-57 | **Какие пары языков продукт принимает — контракт не говорит, и форма загрузки предлагает свой список.** `src/showcase/languages.ts` держит `zh·ja·en → ru` как ДАННЫЕ клиента, имена считает `Intl.DisplayNames`. Это честная слабость интейка: что деплой умеет переводить — свойство деплоя (движок читает пару из langpack), и список на клиенте соврёт в тот день, когда деплой поедет с другим набором. Кода на замену немного (одна ручка + чтение вместо константы), но ручки в контракте нет: `POST /books` принимает любой `LangCode`, а отказ по неподдерживаемой паре не выражен ни одним классом ответа. Черновик правки спеки не писала, потому что решение продуктовое: обещать пары списком или отвечать отказом | оркестратор + владелец | S4 14.08, форма интейка | +| Ф-58 | **RTL не проверен ни разу, а триггер наступил.** Условие, записанное S1 («когда появится `I18nProvider`»), исполнилось в S3.7: провайдер стоит, локаль берётся из стора, `` следует выбору. Чего нет — ни одного прогона с языком письма справа налево: примитивы react-aria логическое направление поддерживают, а НАША раскладка (фиксированное место бейджа, обрезка вкладок, инсеты панелей, стрелка `zh → ru` в статус-полосе) написана в физических терминах местами. Цена вопроса неизвестна до замера; сам замер дешёвый — второй каталог-заглушка плюс `dir="rtl"` и прогон сцен. Делать, когда владелец назовёт второй язык (В-9): до того это проверка обещания, которого продукт не давал | владелец (В-9) → отдельный проход | аудит-свип 14.08 → S4 | +| Ф-59 | **Спойлер банка достаётся поиском по странице — ЗАМЕРЕНО.** Размытие глифов (`filter: blur`) прячет описание от глаза, но текст остаётся текстом страницы. Прогон в браузере (`/showcase`, вкладка «Банк»): закрытое описание «главный герой» лежит в `document.body.innerText`, и `window.find()` его находит — то есть Ctrl+F подсвечивает закрытую ячейку. Скринридер отрезан честно (`aria-hidden`), выделение мышью пусто, и под `@media print` размытие СОХРАНЯЕТСЯ (замер: `blur(3px)`), так что глазами в PDF описание тоже закрыто — но текстовый слой PDF строится из DOM, и там строка остаётся (это следствие, а не мой замер). Настоящее лечение продуктовое: спойлер это ЗАЩИТА (тогда описание не должно приезжать в DOM до раскрытия — нужен признак «показывать смыслы» на чтении банка, то есть правка контракта) или ВЕЖЛИВОСТЬ (тогда всё правильно, и цена названа). Вопрос владельцу: **В-10** | владелец (В-10) → контракт | аудит-свип 14.08 → замер S4 14.08 | +| Ф-60 | **Канвас-замер ширин не знает языка строки.** Ширины столбцов банка меряются канвасом одним шрифтом на весь набор (`ui/Table.tsx`), а при ДВУХ CJK-лицах в стеке один и тот же код-пойнт рисуется по-разному: японская и китайская гарнитуры дают разную метрику для общего иероглифа. Сегодня это спит: пара одна (`zh→ru`), лицо одно. Сработает в день, когда в наборе окажутся и `zh`, и `ja` — то есть ровно на второй паре, которую движок уже умеет. Лечение: мерить с тем же `lang`, что у ячейки (канвасу `lang` не передашь — придётся мерить в DOM либо держать по замерщику на язык), и это отдельный проход с пере-замером цены | триггер: вторая пара в одной библиотеке | аудит-свип 14.08 → S4 | +| Ф-61 | **Платформа отвечает по-английски, а интерфейс русский.** Контракт кладёт фразу отказа на СЕРВЕР (`Problem.title`/`detail` — «product language», клиент показывает как пришло), и клиент так и делает. Но живая платформа шлёт `"The file is larger than this service accepts"`, а рядом стоит наш русский совет — на одном экране два языка. Ни языка запроса (`Accept-Language`), ни требования локализовать эти фразы контракт не несёт. Три пути, и выбор не фронта: (а) платформа локализует по `Accept-Language`; (б) контракт даёт машинный код класса отказа, а фразу пишет клиент (как уже сделано для `RejectReason`); (в) продукт принимает английские фразы отказов. Фронт временно берёт свою фразу только когда `problem+json` не пришёл вовсе | оркестратор + владелец | S4 14.08, интейк | +| Ф-62 | **Переименовать книгу нечем — у книги на всей поверхности контракта нет ни одной записи.** `POST /books` кладёт название один раз (рукой или из имени файла, 0.2.3), дальше только чтения: `GET /books/{bookId}` и ни одного `PATCH`/`PUT`. Найдено адверсариальным ревью пака S4: подсказка формы обещала «название можно будет поправить позже» — обещание, которого контракт не даёт; фраза исправлена, обещание снято. Вопрос не фронта: нужна ли правка метаданных книги вообще (название, жанр, пара языков) и в какой форме — ручка или пере-загрузка. Пока её нет, ошибку в названии лечит только удаление и повторная загрузка, а ручки удаления в контракте тоже нет | оркестратор + владелец | S4 14.08, адверсариальное ревью | +| Ф-63 | **Рамка поля ввода на модальной поверхности — 1.20:1, ниже порога 1.4.11.** Замер S4 (найдено адверсариальным ревью на новом чекбоксе, у которого рамка — единственный носитель границы; там починено локально на `--color-text-muted`, 3.29:1): `--color-border` `#2e2e2e` о `--color-elevated` `#202020` даёт **1.20:1** при пороге 3:1 для границ элементов управления. Тот же токен несёт рамку ВСЕХ полей формы (`ui/Field.module.css`, модификатор `.framed`), рамку самого модального окна и разделители секций, поэтому одним значением это не чинится: поднять токен — перекрасить всю оболочку, принятую владельцем по референсу (S3.6). Правильный ход — отдельный токен границы для того, что является КОНТРОЛОМ (поле, чекбокс), и он у него уже есть в частном виде. Отложено сознательно: перекраска рамок — это вид, а вид принимает владелец | владелец + отдельный проход | S4 14.08, адверсариальное ревью | diff --git a/frontend/docs/FRONTEND_PLAN.md b/frontend/docs/FRONTEND_PLAN.md index 59ac4579..5de59768 100644 --- a/frontend/docs/FRONTEND_PLAN.md +++ b/frontend/docs/FRONTEND_PLAN.md @@ -25,9 +25,9 @@ | [../../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** (масштаб) | перед планированием этапа | +| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет. ⚠ **Актуализировано S4 14.08:** строка 95 (контракт API) ЗАКРЫТА ратификацией D39.99, строки 99/100/103 построены (D39.122/131) — таблица ниже больше не подаёт их как «чего нет»; живые для фронта — **101** (машиночитаемая таблица подписи, вход S5) и **169** (экспорт банка) | перед планированием этапа | | [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных | -| [API_CONTRACT_INPUT.md](API_CONTRACT_INPUT.md) | что фронт просит у контракта API v0 и почему — с грунтом `file:line` по движку; там же шесть вопросов, на которые у фронта ответа нет | до слоя данных; при появлении контракта — сверить построчно | +| [api-contract/openapi.yaml](api-contract/openapi.yaml) + компаньон рядом с каноном (`../../docs/architecture/14-api-contract/README.md`) | **ратифицированный контракт** — форма данных, коды ответов, словари. ⚠ Заменил `API_CONTRACT_INPUT.md`: тот под баннером «исполнено» и живым входом больше не является | до слоя данных и при каждой правке формы | | [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове | Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности @@ -39,8 +39,10 @@ **С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится -(`research/23` §4, §0). Между фронтом и движком стоит платформа, у которой пока ноль строк кода -(`../../platform/BACKLOG.md` П-1). +(`research/23` §4, §0). Между фронтом и движком стоит платформа. ⚠ **Актуализировано S4 14.08:** +«у платформы пока ноль строк кода» устарело — P5 принята D39.130, живы вход OIDC, библиотека, +кредитный леджер, раннер и `POST /books`; чего у неё нет, смотреть в `../../platform/BACKLOG.md`, +а не здесь. Проводов два, и их легко перепутать: @@ -204,9 +206,12 @@ CJK-шрифт не ставим: иероглифы отдаём системн добавление нового состояния главы или нового вида замечания правит **один** файл. Правит три — структура неверна, переделать до движения дальше. -Ответ этой структуры: состояние главы живёт как строка типа в `src/api/types.ts` и -как одна запись в карте отображения `src/features/chapters/`. Цвет индикатора — токен. -Новый вид замечания — запись в карте видов, компонент выноски не трогается. +Ответ этой структуры (⚠ актуализировано S4 14.08 — `src/api/types.ts` удалён ещё в S3, типы +приходят из спеки): состояние книги живёт **одной записью в `src/api/vocabulary.ts`** — там же его +слово, тон и то, что с ним можно делать (`startable`, `intake`); экраны эту запись читают и ничего +о списке значений не знают. Цвет — токен. Новый вид замечания — запись в карте видов, компонент +выноски не трогается. Проверено S4: добавление свойства «книгу в этом состоянии можно запустить» +тронуло один файл, а новое состояние без слова роняет `tsc` на этой же карте. --- diff --git a/frontend/docs/FRONTEND_SESSION_PROMPT.md b/frontend/docs/FRONTEND_SESSION_PROMPT.md index 9e641365..9ffef568 100644 --- a/frontend/docs/FRONTEND_SESSION_PROMPT.md +++ b/frontend/docs/FRONTEND_SESSION_PROMPT.md @@ -59,7 +59,8 @@ **Бэкенда пока нет** — точнее, движок перевода есть, но HTTP-API к нему ещё не построено. Ты работаешь на моках (см. §6). Это не помеха: контракт данных зафиксирован ниже, замена моков на реальные запросы будет точечной. -> ⚠ **Поправка канона №2 (оркестратор, 04.08, D39.99/100/101):** контракт API v0 РАТИФИЦИРОВАН (`docs/architecture/14-api-contract/` — OpenAPI 3.1, типы генерятся из спеки), строка 95 закрыта; скелет платформы P0 существует (ждёт приёмки); слой данных S3 строится ПО КОНТРАКТУ, не по гипотезам. +> ⚠ **Поправка канона №2 (оркестратор, 04.08, D39.99/100/101):** контракт API v0 РАТИФИЦИРОВАН (`docs/architecture/14-api-contract/` — OpenAPI 3.1, типы генерятся из спеки), строка 95 закрыта; слой данных S3 строится ПО КОНТРАКТУ, не по гипотезам. +> ⚠ **Поправка S4 (14.08):** «скелет платформы P0 существует, ждёт приёмки» устарело — **P5 принята (D39.130)**, живой `POST /books` построен, и путь загрузки книги S4 стоит на нём. > ⚠ **Поправка канона (фронт-сессия S1, 02.08).** Формулировка выше читается как «однажды у движка появится HTTP-API». Это не так, и ошибка дорогая: **API будет у платформы, не у движка**, а движок фронту не виден вообще. Прежде чем писать слой данных, прочитай `FRONTEND_PLAN.md` §0.1–0.2 (карта канона и два провода) и первоисточник `docs/research/23-engine-platform-seam.md`. Там же цена ошибки: моки, снятые не с того уровня контракта, разойдутся с API — ровно то, ради предотвращения чего заведена строка 95 единого бэклога. --- diff --git a/frontend/docs/STACK_DECISIONS.md b/frontend/docs/STACK_DECISIONS.md index 51e98cea..4a47400b 100644 --- a/frontend/docs/STACK_DECISIONS.md +++ b/frontend/docs/STACK_DECISIONS.md @@ -141,10 +141,14 @@ ### Одна команда проверки ``` -npm run check # prettier --check → eslint → tsc --noEmit → vitest run -npm run check:full # + vite build + e2e +npm run check # prettier → eslint → stylelint → spectral (контракт) → tsc → vitest +npm run check:full # + vite build + npm run shot (маршруты и axe) + npm run scenes (сценарии) ``` +⚠ Состав сверен S4 14.08 по `package.json` (`node -e` по `scripts.check`): **шесть** шагов у +`check` и три добавочных у `check:full`. Раньше здесь стояли четыре — список отстал на два пака. +⚠ Промт S4 §7 назвал семь; это ошибка промта, и первая редакция этой правки её повторила. + CI вызывает **именно их**, а не дублирует список инструментов. Path-фильтры на уровне job'ов (правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой `contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled')`. ~~Git-хуков в MVP нет~~ — @@ -176,7 +180,7 @@ CI вызывает **именно их**, а не дублирует списо | Что | Пин | Заметка | |---|---|---| -| Go | `1.26.4` в `go.mod`, ⚠ тулчейн сборки платформы **≥1.26.5** (`platform/Makefile` `GO_MIN_VERSION` — security-фиксы crypto/tls и os в сетевом модуле); пин живёт в Makefile, здесь только указатель | | +| Go | `1.26.4` в `go.mod`, ⚠ тулчейн сборки платформы **≥1.26.6** (`platform/Makefile` `GO_MIN_VERSION`, ратифицирован D39.130; сверено S4 14.08 по самому Makefile); пин живёт в Makefile, здесь только указатель | | | HTTP | стандартный `net/http` + `ServeMux` | роутер-библиотеку не тянуть | | Postgres | `pgx v5.10.0` | нижняя допустимая граница 5.9.2, **не** 5.9.0 | | Миграции | `goose v3.27.3` | как библиотека, `embed.FS`, `WithLocker` | diff --git a/frontend/docs/api-contract/openapi.yaml b/frontend/docs/api-contract/openapi.yaml index 842f1a44..8900e68f 100644 --- a/frontend/docs/api-contract/openapi.yaml +++ b/frontend/docs/api-contract/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: TextMachine API - version: 0.2.2 + version: 0.2.3 summary: Ratified contract between the frontend and the TextMachine platform (D39.99). description: | **RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in @@ -114,8 +114,40 @@ paths: operationId: createBook summary: Add a book. description: | - Accepts the file and the properties declared by the user. Responds immediately; the book - enters `uploading` and parsing is a separate visible step. + Accepts the file and the properties declared by the user. + + **The `file` part MUST come LAST in the form** (0.2.3). The platform reads the form as a + STREAM, part by part, and stops at the file: the book's row — the record that makes an + upload visible while it arrives and findable when it dies halfway — cannot be written + before the languages that row requires. + + A part sent after the file is therefore NOT READ AT ALL. What that costs depends on the + part: a required one (`source_lang`, `target_lang`) is answered `400`, because to a reader + that streams "the languages came late" and "the languages never came" are the same thing; + an optional one is silently lost, and the book is created without it. A client that puts + anything after the file is a client whose form is partly ignored. + + **The `201` carries `parsing`, not `uploading`** (0.2.3). The answer is written after the + last byte has landed, so by the time a client can read it the file is in and the book has + moved on. `uploading` is a real state and is observable — by a SECOND read of the library + while the upload is still on the wire — but never as the answer to this call. + + Parsing is a separate visible step after that, and it has no numeric progress: no counter of + it exists on this contract, and a percentage would have to be invented. + + Refusals of the intake, all of them product states of the form rather than failures of the + service: + + - `400` — the form could not be read: more than 16 parts, a text field longer than a + kilobyte, a REQUIRED field that arrived after the file (or never), or no file at all; + - `404` — this deployment does not accept books at all. An instance with no place to put a + file and no engine to cut it with serves the library and refuses this path, which is a + property of the DEPLOYMENT and not of the request; + - `408` — the body did not finish inside the route's deadline: a slow client on a large + book. Retrying is the remedy, which is what separates it from `413`; + - `413` — the body is over the intake cap. The threshold belongs to the deployment and is + not stated here: a number in the contract would be a second copy of it, and the two would + disagree the day it changes. requestBody: required: true content: @@ -123,12 +155,14 @@ paths: schema: { $ref: '#/components/schemas/BookIntake' } responses: '201': - description: Book accepted. + description: Book accepted; it is being parsed. content: application/json: schema: { $ref: '#/components/schemas/Book' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '408': { $ref: '#/components/responses/RequestTimeout' } '413': { $ref: '#/components/responses/TooLarge' } /books/{bookId}: @@ -412,6 +446,11 @@ paths: **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a complete set. + + **503 answers a deployment that cannot run at all** (0.2.3). Continuing a run is starting a + process, so it needs exactly what a start needs — the seam that tells the engine its ceiling + and the one that records how a run ended. 0.2.1 named that state for `startRun` only, and + the omission was in the SPEC and not in the wire: the two calls share one refusal. responses: '202': description: Resume accepted. @@ -421,6 +460,7 @@ paths: '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } /usage: get: @@ -601,11 +641,20 @@ components: content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } + RequestTimeout: + description: | + The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on + a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the + remedy — which is what a `413` and a `500` in its place would both hide. + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } ServiceUnavailable: description: | - The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a - run requires the engine seam to be fully configured, and answering with any other code - would misname the state. Temporary by nature — retry later; no Retry-After is promised. + The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting or + continuing a run requires the engine seam to be fully configured, and answering with any + other code would misname the state. Temporary by nature — retry later; no Retry-After is + promised. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } @@ -725,6 +774,31 @@ components: - rejected - failed + RejectReason: + type: string + description: | + Machine reason a book was rejected (0.2.3). The platform's own closed vocabulary; as with + `PausedReason` the API carries STATE and the phrase the user reads is drawn by the client, + so no wording appears here. + + - `source_unreadable` — the file was read and is not a book this service can cut: it yields + no sections at all, or the reader refused it. TERMINAL, and the source does not survive + it — there is no path in this contract that re-reads a rejected book, so the remedy is to + add the book again; + - `not_configured` — this deployment has nothing to read the book AGAINST. It is a state of + the service, never of the file, and retrying by itself does not clear it; + - `parser_unavailable` — the service could not process the file, repeatedly, until it gave + up. A state of the service as well, and a temporary one. + + The three are two different NEXT ACTIONS for the user, and a client that told them apart by + colour alone would be telling them apart by nothing: the first means "the file is not one we + can read", the other two mean "not us, not now". + + A client MUST tolerate an unknown value arriving under a minor bump, and MUST render a + rejected book that carries no reason at all: a deployment older than this minor answers + exactly that. + enum: [source_unreadable, not_configured, parser_unavailable] + Book: type: object description: A book in the library. @@ -743,6 +817,18 @@ components: description: Size in characters. Not an engine field; the platform knows it from intake. added_at: { type: string, format: date-time } status: { $ref: '#/components/schemas/BookStatus' } + reject_reason: + oneOf: + - $ref: '#/components/schemas/RejectReason' + - type: 'null' + description: | + Why the book was rejected; meaningful only while `status` is `rejected` (0.2.3). + + OPTIONAL, unlike `Run.paused_reason`, and the asymmetry is deliberate: a rejection + reason is meaningful in exactly one of eleven states, and a deployment that predates + this minor sends nothing here at all. "Absent" and "null" therefore mean one and the + same thing — the reason is not known — and a client renders both the same way. That is + the opposite of the `sense` case (0.2.2), where the two shapes carried DIFFERENT facts. progress: { $ref: '#/components/schemas/Progress' } note_count: { type: integer, minimum: 0 } @@ -810,13 +896,29 @@ components: BookIntake: type: object - description: Add-a-book form. + description: | + Add-a-book form. + + ⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part, and + every other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the + rule is stated in prose because it cannot be stated in the schema. required: [file, source_lang, target_lang] properties: + title: + type: string + maxLength: 200 + description: | + Title given by hand (0.2.3). OPTIONAL, and the two cases are told apart by the user + rather than guessed at: absent or empty means "the parse will name it" — today the + platform takes the name of the uploaded file — while a value present means the person + named the book themselves, and no later parse overwrites it. + + Bounded like the title the platform derives: the library lists it, and an unbounded + string on that screen is the client's problem to draw, not the server's to store. file: type: string format: binary - description: Book file. + description: Book file. The LAST part of the form. source_lang: { $ref: '#/components/schemas/LangCode' } target_lang: { $ref: '#/components/schemas/LangCode' } genre: { type: string } diff --git a/frontend/docs/frontend-PROGRESS.md b/frontend/docs/frontend-PROGRESS.md index 20efbfee..b5209da3 100644 --- a/frontend/docs/frontend-PROGRESS.md +++ b/frontend/docs/frontend-PROGRESS.md @@ -124,6 +124,29 @@ > платформы, в границах ПТ-33, на утверждение владельцем по отчёту; (3) Ф-52 — `--color-danger` > осветлить на ступень до ≥3:1 силами S4 (закрыть замером). +> **Запись фронт-сессии S4, 14.08.** Путь «у меня есть файл → книга переводится» построен и прожит +> в мире моков: форма загрузки отправляет книгу (XHR — `fetch` прогресса ТЕЛА запроса не даёт вовсе), +> 201 приносит `parsing`, разбор — отдельный видимый шаг БЕЗ выдуманного процента, отказы интейка +> (400/404/408/413) и отказ книги (три причины PD-173 с разными советами) — состояния формы и +> карточки, а не алерты. Форма запуска даёт потолок в ГЛАВАХ из `CeilingBounds`; потолочный стоп +> прожит по контракту (`EventCeiling` + `paused`), и оповещение — не тост, а несъезжающая фраза в +> статус-полосе и на карточке (аргумент в записи). Библиотека: дерево свыше 200 разделов +> складывается по сотням, книга в дереве стала ВЫБИРАЕМОЙ (без этого половина статусов была +> недостижима на карточке), все одиннадцать состояний прожиты. Контракт зонной копии бампнут до +> **0.2.3** (PD-172/173/174/180 + `BookIntake.title`); ⚠ по коду платформы `not_configured` на +> отклонённую книгу сегодня не попадает вовсе — названо в записи. Девять несущих находок приёмки +> S3.7 разобраны, и одна из них ОПРОВЕРГЛА клейм S3.7 замером (resync рвал живой поток, когда +> чтение медленное). +> +> ⚠ **Три ошибки в самом промте S4, найденные вторым проходом ревью и исправленные** (промт писала +> нейросеть, и это ровно тот случай): (1) «поле после файла = 400» — платформа так НЕ делает, она +> перестаёт читать на файле, и необязательное поле за ним просто теряется (спека и фикстура +> исправлены, два теста); (2) §7 велит записать «7 шагов у `npm run check`» — их шесть, и первая +> редакция моей правки повторила ошибку промта; (3) §4 сводит 409 на старте к «уехал потолок», а +> платформа отвечает им на пять разных состояний — совет на экране сделан причинно-нейтральным. +> Ждут владельца: **В-10** (спойлер — защита или вежливость) и **В-11** (фразы отказа PD-173 на +> утверждение). + > **Запись фронт-сессии S3.7, 10.08.** Долги слоя данных закрыты тестами, падающими на до-фиксном > коде (Ф-49…Ф-51), контракт зонной копии бампнут до **0.2.2** (`BankTerm.sense` обязателен), > строки интерфейса уехали в КАТАЛОГ (`src/i18n/`, 147 ключей, механизм — `@internationalized/string`, @@ -167,7 +190,8 @@ S3.5 (фикс-пак оболочки: 24 замечания владельца, сценарии интеракций, пере-замер полос), **S3.6 (второй круг: палитра по замеру референсов, модель вкладок fleet_2, банк таблицей, ручка из vojo дословно)**, **S3.7 (долги слоя данных: контракт 0.2.2, каталог i18n 157 ключей, - код зоны на английском; D39.129)**. + код зоны на английском; D39.129)**, **S4 (загрузка книги и запуск прогона на контракте 0.2.3, + библиотека на масштабе, находки приёмки S3.7 — ждёт приёмки)**. - ⚠ **АБЗАЦ НИЖЕ ИСТОРИЧЕСКИЙ (04.08), читать как хронику:** замок с тех пор открыт (контракт ратифицирован D39.99), S3 исполнен и принят D39.115. Оставлен, потому что объясняет, ПОЧЕМУ S3 однажды остановилась, а не как диспозиция. - **S3 НЕ сделана: замок на входе закрыт, проверено исполнением 04.08.** Контракта API v0 по-прежнему нет (`docs/architecture/14-api-contract.md` отсутствует; строка 95 единого @@ -390,12 +414,333 @@ NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фрон | В-8 | **Какое слово о состоянии книги стоит в дереве.** Замечание 23 потребовало бейджу ФИКС-место, и оно сделано; но две метки словаря в него не помещаются и усекаются с тултипом — «остановлена: лимиты» (19 знаков) и ветка неизвестного «неизвестное состояние» (21). Укоротить их сессия НЕ вправе: фраза паузы зарезервирована за владельцем (В-6), и первый заход S3.5 её укоротил и был откачен собственным ревью. Варианты: (а) оставить усечение с тултипом, (б) владелец даёт короткое слово для дерева, а полная фраза остаётся в статус-полосе, (в) расширить колонку бейджа за счёт названия книги | продуктовая формулировка, которую видит пользователь | | В-7 | **Физический масштаб интерфейса на мониторе.** Замечание 1 говорит «масштабы великоваты, особенно на 27″ 2K, на 24″ терпимее». Замер при вьюпорте референса расхождения с Fleet не нашёл (§5.5 плана): промежутки, ширины панелей, шаг строки и высота вкладки совпадают. Значит речь про физический размер на конкретном экране, а он зависит от масштабирования ОС, и «просто уменьшить токены» означало бы уехать от референса, который владелец принял. Правильное лекарство — ручка «плотность интерфейса» в настройках (3 ступени), но она требует перевода ВСЕХ размерных токенов в rem и пере-замера, то есть отдельного прохода (Ф-36). Вопрос: нужна ли ручка, и какой масштаб ОС стоит на 27″ 2K — по нему видно, во сколько раз расходится физика | продуктовое решение и цена отдельного пакета | | В-9 | **Какие языки интерфейса продукт обещает.** Механизм переводимости построен (S3.7, Ф-35): слова интерфейса живут в каталоге `src/i18n/ru.ts`, второй язык — второй файл того же вида плюс строка в `src/i18n/language.ts`, ни один компонент при этом не трогается. Чего фронт сделать не может и не должен: решить, какие языки продукт обещает, и выдумать сами переводы. Пока список из одного значения (`ru`), и каркасный пункт настроек честно говорит «сейчас интерфейс только русский» | продуктовое решение и обещание пользователю | +| В-10 | **Спойлер банка — это защита или вежливость?** Замер S4: закрытое описание лежит в тексте страницы, `window.find()` его находит, то есть Ctrl+F подсвечивает закрытую ячейку; под `@media print` размытие сохраняется, но текстовый слой PDF строится из DOM. Если спойлер — ЗАЩИТА от спойлера сюжета, описание не должно приезжать в браузер до раскрытия, а это правка контракта (признак «показывать смыслы» на чтении банка). Если ВЕЖЛИВОСТЬ («не мозолит глаза, но достать можно») — сделанного достаточно, и цена названа. Строка Ф-59 | продуктовое решение и правка контракта | +| В-11 | **Фразы отказа книги (PD-173) — на утверждение.** Контракт 0.2.3 несёт машинную причину (`source_unreadable` · `not_configured` · `parser_unavailable`), а слова пишет клиент — их сочинила эта сессия по слову владельца 14.08 и они ждут его «да»: «Файл не удалось разобрать на разделы» / «Проверьте файл — возможно, это не текст книги — и добавьте её заново»; «Сервис пока не готов принять эту книгу» / «Файл здесь ни при чём: повторная загрузка поможет не раньше, чем мы это наладим»; «Не удалось обработать файл на нашей стороне» / «Файл здесь ни при чём: попробуйте добавить книгу позже»; неизвестная причина — «Книгу не удалось принять» / «Причина не названа. Попробуйте добавить книгу заново или позже» | продуктовая формулировка, которую видит пользователь | | В-2 | **Авторское право на текст фикстур.** Витрина использует короткую выдержку из настоящего прогона (蛊真人). Правило проекта уже есть: `eval/.gitignore:4` держит копирайтные тексты вне git. Сейчас взят объём под проверку плотности и не больше; нужно ли убрать совсем и заменить синтетикой — решать владельцу | юридика | | В-4 | **Чем подписывать главу в дереве, если заголовок — ровно «Глава N».** Титул движок рендерит детерминистически из шаблона пары (`configs/langpacks/zh-ru/heading.txt`: `template Глава {n}`), а подзаголовок остаётся телом текста и названием НЕ является. На 2284 разделах это дерево из 2284 одинаковых по форме строк. Оставить так, показывать номер иначе или просить у контракта что-то ещё — продуктовое решение. Артефакт §1.4 и К-3 | продуктовая формулировка, которую видит пользователь | | В-5 | **Показывать ли оценку времени до конца прогона.** У движка поле есть (`status.go` `eta_seconds`, «secondary: mean fresh-call throughput × remaining»), запретом §4.8 оно не покрыто (это не деньги), но и не запрошено ни одним разделом промта. Решать ДО формы прогресса, иначе поле придётся вводить задним числом в уже нарисованную полосу. Артефакт К-6, резерв строки 54 единого бэклога | продуктовое: обещание срока пользователю | ## Хроника +### 14.08 — девятая фронт-сессия (S4): загрузка книги, запуск прогона, библиотека на масштабе, контракт 0.2.3 + +Вход: промт `S4_SESSION_PROMPT.md` (семь задач). Дом прежний — мир моков по контракту; живого стенда +платформы не поднимала (§10 промта — опция, не гейт), и это отдельной строкой в «чего НЕ сделала». + +#### 1. Контракт 0.2.3 — пять правок в зонной копии + +`info.version` 0.2.2 → **0.2.3**, `npm run contract` (spectral, порог warn) зелёный, типы +перегенерены (`npm run contract:types`), тест дрейфа держит равенство спеки и `schema.ts`. + +| Правка | Что записано | Чем грунтовано | +|---|---|---| +| **PD-172** | у `createBook` — часть `file` идёт ПОСЛЕДНЕЙ, поле после файла = 400; причина одной строкой (платформа читает форму потоково и обязана увидеть языки до тела) | `platform/internal/httpapi/v0.go:311-378` — потоковый `MultipartReader`, ветка «языки пусты на приходе файла → 400» | +| **PD-173** | новая схема `RejectReason` (`source_unreadable` · `not_configured` · `parser_unavailable`) и **необязательное** `Book.reject_reason` | `platform/internal/books/parse.go:51-66` — те же три имени + четвёртое `storage_unavailable`, которое на книге не хранится вовсе | +| **PD-174** | названы классы, которые провод уже даёт: **404** на `POST /books` у инстанса без интейка, **503** у `resume` на деплое без маркер-команды/шаблона потолка | 404 — `v0.go:72-77` (маршрут не монтируется при `Intake == nil`); 503 — `reconcile.go:715-722` зовёт `s.runnable()`, а `v0.go:540-547` мапит его ошибки в 503 общей для старта и resume функцией `fail` | +| **PD-180** | описание `createBook` честное: **201 несёт `parsing`**, `uploading` наблюдаем только параллельным чтением библиотеки; перечень отказов — 400 (>16 частей, поле >1 КиБ, поле после файла, нет файла), 404, 408, 413 (числа порогов в спеку не зашиты — они принадлежат деплою) | `books.go:110-180`: строка книги пишется ДО тела (оттого `uploading` виден вторым чтением), а ответ формируется после `StartParsing`, то есть уже в `parsing`; пороги — `v0.go:296-302`, 408 — `v0.go:410-417` | +| **`BookIntake.title`** | необязательное поле: пусто/нет = «название даст разбор» (сегодня платформа берёт имя файла), заполнено = задано рукой и разбор не перепишет | `books.go:136` `titleFrom(in.Filename)`; ратификация — оркестратор 14.08 (исполнение замечания 12 S3.5) | + +**Расхождение с промтом, названное вслух (код первичен).** Промт описывает enum PD-173 как +«словарь платформы из трёх значений». Проверка по коду: значений в словаре платформы **четыре**, а +на книгу сегодня попадают **два**. `storage_unavailable` не хранится никогда (комментарий +`parse.go:62-65`), `not_configured` в `books.reject_reason` не доходит тоже — `defer_` для него +никогда не терминален (`parse.go:180-205`, `waitsForTheDeployment`), книга остаётся в `parsing` и +ждёт человека. В спеку внесены все три имени промта: enum — это словарь ВЕРСИИ, клиент обязан +терпеть незнакомое значение, а `not_configured` — состояние, которое платформа уже умеет назвать и +которое станет терминальным ровно в тот день, когда владелец контракта решит, что упавшая на +деплой книга должна умирать, а не ждать. Фраза для него написана, и это цена, которую я назвала: +одна из трёх фраз сегодня недостижима на живой платформе. + +**`reject_reason` сделан необязательным, а не required+nullable, как `Run.paused_reason`.** Причина +записана в самой спеке: причина отказа осмысленна ровно в одном состоянии из одиннадцати, а деплой +старше этого минора не пришлёт её вовсе — значит «поля нет» и «поле `null`» означают ОДНО И ТО ЖЕ +(«причина неизвестна») и рисуются одинаково. Это противоположность случая `sense` (0.2.2), где две +формы несли РАЗНЫЕ факты; прецедент внутри той же спеки — `Usage.paused_reason`. + +#### 2. Загрузка книги (Ф-26, половина) — механизм и почему он такой + +- **XHR, а не `fetch`, и это не вкус.** Стандарт Fetch отдаёт прогресс ОТВЕТА и не имеет + эквивалента для тела запроса; единственный стандартный способ следить за отправкой — тело-поток + с `duplex: 'half'`, он только HTTP/2 и только Chromium, то есть на Firefox и Safari загрузка бы + просто не работала. `XMLHttpRequest.upload` — совместимый механизм, и он легален ровно в одном + месте: сетевой гейт ESLint пускает его только в `src/api/`. Шов не протёк: экран получает промис + и поток чисел (`src/api/upload.ts`). +- **Порядок частей формы — правило провода**, а не привычка клиента: `title · source_lang · + target_lang · genre · file`. Держится с двух сторон — тест сверяет ключи формы, которые ушли на + провод, а фикстура ОТКАЗЫВАЕТ форме, где файл не последний (та же проверка, что у платформы). +- **Три состояния отправки**, и первое из них — измеренная неожиданность. В браузере мок-сеть это + service worker, а Chromium **не сообщает прогресс отправки для запроса, который отвечает + воркер**: проба напечатала `loadstart` с верным total (`0 / 300379`) и НИ ОДНОГО `progress` + после. Поэтому экран не рисует долю, которой нет: «Файл отправляется: 300 кБ» без процента. + Доля и вторая фаза («файл ушёл целиком, ждём ответа») живут для настоящей платформы и доказаны + тестом `src/api/upload.test.ts` — там мок перехватывает сам `XMLHttpRequest` и события шлёт. +- **Подтверждение видимое** (§3.2): после 201 окно показывает карточку из полей КОНТРАКТА — + название, языки, знаки, состояние («разбор»), — и `chapter_count` в ней намеренно нет: до конца + разбора он ноль, и печатать его значило бы ответить на «сколько разделов» неправдой. +- **Отказы — состояния формы, не алерты**: фраза берётся из `problem+json` платформы (контракт + кладёт слова на сервер), а СОВЕТ пишет клиент, потому что провод его не несёт и он разный: + «проверьте файл» ≠ «повторите позже» ≠ «этот сервис книги не принимает». + +#### 3. Запуск прогона (остаток строки 126) + +Форма читает `GET /books/{id}/run-options` при ОТКРЫТИИ (потолок принадлежит аккаунту и уезжает, +пока книга стоит), шкала строится из `CeilingBounds` в ГЛАВАХ, предустановка — `default_chapters` +платформы. Клиент не клампит второй раз. `max_chapters: 0` — не пустая шкала, а честное +исчерпанное состояние. `409` на старте перечитывает bounds, и выбранное значение возвращается к +новой предустановке ВО ВРЕМЯ РЕНДЕРА, а не размонтированием формы: размонтировав, я бы убрала с +экрана ту самую фразу отказа, ради которой всё и затевалось. Галочка «остановиться на подписи +банка» — параметр ЭТОГО запуска. ⚠ **По умолчанию ВЫКЛЮЧЕНА, и это решение про сегодня, а не про +продукт** (нашло адверсариальное ревью): с включённой галочкой прогон доходит до `awaiting_bank` — и +дальше в этой сборке идти НЕКУДА: экран подписи это S5, у чтения банка вообще нет канала (Ф-43), а +`resume` отвечает 409, пока набор решений неполон. Предустановить её значило бы предустановить +тупик. Сама галочка остаётся — выбор принадлежит человеку и этому прогону (владелец, 02.08), — а +дефолт вернётся в тот день, когда S5 даст остановке куда идти. + +**`paused` прожит, и оповещение — НЕ тост.** Решение и аргумент: тост — временный носитель +постоянного факта, он исчезает, а состояние остаётся; на десктопном однооконном экране статус-полоса +и карточка книги видны всегда; §3.8 запрещает экрану выглядеть тревожным, а всплывающее окно — самая +тревожная из форм. Поэтому остановка называется там, где стоит действие: фраза владельца в +статус-полосе И выноской на карточке книги, и обе не исчезают. Кадр `EventCeiling` + `status: +paused` в мире моков прожит целиком (сцена `intake`). `resume` как лекарство не предлагается нигде; +путь вперёд — НОВЫЙ прогон со своим потолком, и он на карточке есть. + +#### 4. Библиотека на масштабе + +- **Группировка по сотням** (хвост Ф-12): дерево свыше 200 разделов складывается в группы по 100 + («разделы 1–100»), 2284 раздела дают 23 строки. Аргумент — не кадр в секунду (плоское дерево + виртуализовано и стоит 31 строку в DOM), а полоса прокрутки: 59 618px хода и треть пикселя на + строку. Группа с выбранным разделом раскрывается сама (переход из палитры не должен терять + строку) и остаётся закрываемой рукой — раскрытие стало УПРАВЛЯЕМЫМ (`ui/Tree.tsx`). +- **Выбор книги в дереве построен** (§2 промта зоны — «клик по книге открывает её состояние»). До + S4 карточка всегда показывала `books[0]`, и половина статусов была недостижима на экране в + принципе. Побочный эффект найден axe в первой же сцене: строка книги теперь БЫВАЕТ выбранной, и + правило `[data-selected] .badge`, снятое в S3.7 как мёртвое, ожило — бейдж на синем полотне давал + 3.52:1 при пороге 4.5, а тон-глиф книги 1.7:1 при пороге 3. Оба перекрашены, сторож контраста + выбранной строки теперь гоняется и на строке КНИГИ. +- **Все одиннадцать `BookStatus` прожиты**: в дереве — словом (фикстура держит книгу каждого + состояния), на карточке — те три, у которых есть что сказать сверх слова (`uploading`, `parsing`, + `rejected` с причиной и советом, `paused` с фразой владельца); проверено сценой `context`. + +#### 5. Дыра, которую нашла постройка: конца интейка никто не объявляет + +Живой канал принадлежит ПРОГОНУ, а книга в `uploading`/`parsing` прогона не имеет — значит «разбор +кончился» не приходит ничем. Без лечения книга висела бы в «разборе» до случайной +инвалидации. Сделано: опрос ровно на время интейка (`intakePollMs = 3000`, останавливается вместе +со статусом) плюс перечитывание ДЕРЕВА на выходе из интейка (`useIntakeEnd.ts`) — иначе список +разделов, прочитанный когда разделов не было, так и оставался пустым (поймано сценой, не +рассуждением). Это лечение, а не решение: правильным был бы кадр от контракта — **Ф-56**. + +⚠ Названная цена опроса на фикстуре: в мире `showcase` книга `bk_11` стоит в `uploading` вечно (она +там ради того, чтобы состояние было видно на экране), поэтому библиотека на этом маршруте +опрашивается каждые три секунды бесконечно. На живой платформе так не бывает — брошенную загрузку +подметает свип через час (`books.UploadGrace`), — но фикстура этого не моделирует, и знать об этом +надо тому, кто будет мерить на `/showcase` что-нибудь чувствительное к сети. + +#### 6. Диспозиции находок приёмки S3.7 — все девять несущих + +| № | Диспозиция | +|---|---| +| 1 (high) дубль подстановки строк | **починено.** `scripts/scenes.mjs` больше не держит своего филлера: `say()` зовёт тот же `src/i18n/fill.ts`, что и приложение (харнесс уже умеет грузить `.ts`). Семантика пропуска стала одна | +| 2 отметка «перечитывание в пути» ставится без старта чтения | **починено, и дефект оказался хуже описанного.** Отметка снимается ровно тогда, когда кончается чтение, за которое она стоит (`resetQueries().finally`). Настоящая цена прежней версии: чтение, которое ПРОВАЛИЛОСЬ, оставляло запись пустой — и ключ замолкал до конца прогона. Тест `a key whose re-read failed is not silenced for the rest of the run` падает на до-фиксном коде | +| 3 `onResync` не чистит `asked` | **починено:** отметки уходят вместе со снимками. Тест написан так, чтобы ловить дефект В ОКНЕ (чтение задержано на 120 мс, кадр приходит, пока перечитывание в полёте) — с быстрым моком он зеленел бы и на дефекте | +| 4 клейм «resync не рвёт живой поток» | **клейм ОПРОВЕРГНУТ замером — S3.7 была неправа.** С задержанным ответом (120 мс) поток открывался ДВАЖДЫ: id прогона лежит внутри карточки книги, а reset её опустошает, и подписка снималась на время одного чтения. Починено удержанием id на время книги (`useRunStream`), тест инструментирован задержкой и падает на до-фиксном коде | +| 5 сцена `hidden`: прекондиция не ассертится, сторож дифференциальный | **починено тремя проверками вместо одной:** прекондиция «банк ещё не пришёл» ассертится, «таблица вообще нарисована» (4 столбца, 608px) проверяется, «столбцы по содержимому, а не равными долями» — тоже; дифференциальное сравнение осталось четвёртым | +| 6 дырка ESLint-гейта строк | **починено, и шире находки.** Гейт знает НОСИТЕЛИ: `??`, тернарник и шаблон — и в читаемом атрибуте, и как ребёнок элемента (`

{'слово'}

` не ловился вовсе). Проверено живым нарушением на всех четырёх формах; живой экземпляр `Showcase.tsx` (`?? 'TextMachine'`) переехал в каталог. ⚠ Тем же ходом СУЖЕНО правило про `style` в объектном литерале — оно ловило `{ style: 'unit' }` у `Intl.NumberFormat`; сужение точечное (значение-строка React как стиль отвергает: «expects a mapping … not a string»), носители-объекты/переменные/шаблоны ловятся по-прежнему, проверено живым нарушением | +| 7 perf-оседание может проходить вакуумно | **починено, и магические паузы убраны совсем:** раунд начинается от таблицы, которая УСТАКАНИЛАСЬ (`rowsSettled`), и число строк для сравнения меряется в этом же раунде — прежнее бралось до прокрутки, а виртуализатор держит в DOM разное число строк в разных местах списка | +| 8 zoom-ассерт «в ряд» не запрещает стопку | **починено наполовину, и половина названа.** Сравнение стало СТРОГИМ (`left > предыдущего left`) — стопка теперь падает. Сильную форму («панель начинается там, где кончается предыдущая») гейтом не делаю: с 125% центр уже наезжает на правую панель — это Ф-54, вопрос плотности на владельце (В-7), и красный по построению гейт учит пропускать команду. Число печатается, как и печаталось | +| 9 кадр `status` не перечитывает список глав | **починено:** кадр статуса перечитывает и список глав, и библиотеку. Второе — моя находка сверх списка: бейдж в дереве (главный носитель состояния) продолжал говорить «в очереди» под прогоном, который уже остановился; увидела на кадре потолочного стопа. Оба теста падают на до-фиксном коде | + +**Минорные** (из свода приёмки): свип языка не мёл `package.json` и безрасширенный +`scripts/githooks/pre-commit` — **починено**, оба в списке, проверено живым нарушением (русский +комментарий в хуке роняет тест) · гейт вызовов каталога держался на имени переменной `text` — +**починено**, регексп ловит любой вызов вида `f('ключ', {…})`, а ложное срабатывание невозможно по +построению (ключ не из каталога пропускается); проверено живым нарушением с переменной по имени +`say` · шапка `ru.ts` ссылалась на несуществующий `src/i18n.test.ts` — **починено** · perf: +удлинение запроса занижает мс/клавишу — **знаю, не трогала**: запрос берётся из фикстуры целым +термином, и менять его длину сейчас значит менять величину, которую сравниваю с прошлым паком · +защёлка флага съёмки (окно 150 мс) — **не трогала без замера**, как и просил свод. + +#### 7. Гигиена доков и арбитраж двух чисел ЗАМЕРОМ + +- `FRONTEND_PLAN.md`: «платформа = ноль строк кода» → P5 принята (D39.130); таблица чтения больше + не подаёт строки 95/99/100/103 как «чего нет» и не шлёт в `API_CONTRACT_INPUT.md` как в живой + вход (он под баннером «исполнено») — вместо него контракт; ссылка на удалённый `src/api/types.ts` + заменена на то, где состояние книги живёт сегодня (`src/api/vocabulary.ts`, одна запись). +- `STACK_DECISIONS.md`: тулчейн платформы **1.26.6** (сверено по `platform/Makefile:19`), состав + `npm run check` приведён к ШЕСТИ шагам по `package.json` — промт §7 называл семь, и первая + редакция моей правки повторила его число; посчитано командой, а не переписано. +- `FRONTEND_SESSION_PROMPT.md:62`: «скелет P0 ждёт приёмки» → P5 принята и залендена. +- `frontend/README.md`: зонная копия спеки больше не «черновик на ратификацию». +- **Арбитраж (замер на дереве ДО пака).** Ключей каталога — `node -e "import('./src/i18n/ru.ts')…"` + → **157**: правы Ф-35 и живой `ru.ts`, «147» записи S3.7 неверно. Цена бандла — `npx vite build` + → главный чанк **724130 байт**: права ЗАПИСЬ S3.7 (+7999 от 716131), а «723102 (+6971)» строки + Ф-35 неверно. Живые носители (Ф-35, Ф-18) приведены к замеру; записи хроники не правлены — это + попутка, как и требует промт. + +#### 8. Заведённые строки и вопросы + +Ф-56 (нет кадра о конце интейка — вылечено опросом) · Ф-57 (какие пары языков продукт принимает — +контракт не говорит, форма предлагает свой список) · Ф-58 (RTL не проверен, триггер наступил) · +Ф-59 (спойлер достаётся поиском по странице — ЗАМЕРЕНО) · Ф-60 (канвас-замер не знает `lang`) · +Ф-61 (платформа отвечает по-английски в русском интерфейсе — у контракта нет ни `Accept-Language`, +ни машинного кода отказа) · **Ф-62** (переименовать книгу нечем: у книги на всей поверхности +контракта нет ни одной записи — нашло ревью на обещании «поправить позже», обещание снято) · +**Ф-63** (рамка поля на модальной поверхности 1.20:1 при пороге 1.4.11 — тот же токен несёт рамки +всей оболочки, поэтому перекраска это вид, а вид принимает владелец). Владельцу: **В-10** (спойлер — +защита или вежливость) и **В-11** (фразы PD-173 на утверждение). Ф-52 закрыта замером и гейтом, +Ф-42 закрыта решением по замеру, Ф-12 и Ф-26 — наполовину. + +#### 8а. Фразы PD-173 — отдельным списком на утверждение владельцем + +Промт (§2) просит вынести их отдельно. Написаны по РЕАЛЬНОМУ флоу отказов (прочитан +`platform/internal/books/parse.go` read-only), в границах ПТ-33 — ни стадий, ни моделей, ни слова +«парсер»/«манифест» — и без тревожного тона (§3.8). Разное следующее действие — разные фразы. + +| Машинная причина | Фраза | Совет (следующее действие) | +|---|---|---| +| `source_unreadable` | Файл не удалось разобрать на разделы | Проверьте файл — возможно, это не текст книги — и добавьте её заново. | +| `not_configured` | Сервис пока не готов принять эту книгу | Файл здесь ни при чём: повторная загрузка поможет не раньше, чем мы это наладим. | +| `parser_unavailable` | Не удалось обработать файл на нашей стороне | Файл здесь ни при чём: попробуйте добавить книгу позже. | +| причина неизвестна (ветка неизвестного) | Книгу не удалось принять | Причина не названа. Попробуйте добавить книгу заново или позже. | + +Почему так: `source_unreadable` — движок прочитал файл и отказался (или не нашёл в нём ни одного +раздела), исходник при этом УДАЛЯЕТСЯ и пере-разбора в контракте нет, поэтому единственное честное +действие — добавить книгу заново. `parser_unavailable` — движок не удалось ЗАПУСТИТЬ, файл цел, но +дотянуться до него нечем: повтор позже осмыслен. `not_configured` — деплою нечем разбирать книгу, +и повтор сам по себе не поможет; ⚠ на сегодняшней платформе эта причина на отклонённую книгу не +попадает вовсе (в записи выше), то есть фраза написана впрок. Ветка неизвестного не обвиняет ни +файл, ни сервис — она не знает, кто виноват. + +#### 9. Адверсариальное ревью диффа (author≠reviewer) — механизмом + +Пять независимых линз по диффу (контракт · корректность клиента · продуктовые правила и доступность +· гейты и тесты · фикстура и шов), на КАЖДУЮ находку — отдельный скептик с установкой ОПРОВЕРГАТЬ и +требованием проверять исполнением, а не рассуждением. **44 находки, 49 агентов, 14 выжило.** +Скептики гоняли vitest и рендерили компоненты сами — в отчётах ниже это видно по формулировкам +«reproduced by rendering», и это ровно то, чего требует D39.129. + +⚠ **Честная оговорка о методе:** ревью шло по ЖИВОМУ дереву, пока я в нём работала, поэтому часть +«опровергнутых» — это находки, верные в момент подачи и починенные мной до того, как до них дошёл +скептик (порядок, счёт знаков, преждевременные тотали, 404 на старте, версия рукопожатия). В +следующий раз ревью надо гонять по замороженному дереву, иначе «опровергнуто» смешивает два +разных исхода. Ниже — по существу, а не по ярлыку воркфлоу. + +| Находка | Диспозиция | +|---|---| +| **Плейсхолдер названия обещает разбор, которого платформа не делает** (`parsedTitle` менял `_-` на пробелы, а `titleFrom` платформы срезает только расширение) | **принято, починено.** Скептик проверил ДВУМЯ прогонами — нашим `vitest` и `go test` платформы, где табличный случай прямо фиксирует, что дефис переживает `titleFrom`. Клиент теперь предсказывает ровно то, что делает сервер | +| **Любой 409 на старте трактовался как «потолок уехал»** — платформа отвечает им ещё на четыре причины (прогон уже идёт, книга не готова, нет кредита) | **принято, починено:** совет стал причинно-нейтральным, а перечитываются ТЕПЕРЬ ОБА чтения — и шкала, и карточка книги (устаревшая карточка и была второй половиной дефекта). Воспроизведено скептиком рендером с реальным телом `problem+json` платформы | +| **`useIntakeEnd` срабатывал на СМЕНУ книги** и выбрасывал список глав только что открытой | **принято, починено:** «переход» считается только внутри ОДНОЙ книги (в ref лежит пара «книга + состояние»). Воспроизведено рендером | +| **`runOptionsQuery` наследовал общий `staleTime` 15 с** — переоткрытие формы в этом окне отдавало кэшированные bounds | **принято, починено:** `staleTime: 0`. Это ровно то, ради чего bounds — отдельный ресурс: максимум принадлежит АККАУНТУ и уезжает, пока книга стоит | +| **Авто-раскрытие открывало ГРУППУ, но не саму книгу** — прыжок из палитры в свёрнутую книгу оставлял строку невидимой | **принято, починено:** книга открывается вместе с группой | +| **`useRunStream` отдавал состояние связи ПРЕДЫДУЩЕЙ книги** на первый кадр после смены | **принято, починено:** при смене книги состояние сбрасывается в `connecting` | +| **Строки групп кликались и не делали ничего** | **принято, починено:** клик по строке группы её сворачивает и разворачивает — то, что делает клик по папке в любом дереве | +| **Шкала потолка объявляет голое число, подсказка не привязана** | **принято наполовину, замерено.** Подсказка привязана (`aria-describedby` теперь доезжает до инпута — проба в браузере до и после). Единицу в `aria-valuetext` подставить НЕЛЬЗЯ: библиотека составляет его сама из значения, переданный проп до инпута не доходит (замер). Цену назвала в коде: единицу несёт `SliderOutput`, а он живая область (`role="status"`) и читается на каждое изменение | +| **Проверка «фраза платформы» в тесте 413 проходила при ОТСУТСТВИИ тела** (`undefined !== ''`) | **принято, починено:** проверяется непустая СТРОКА | +| **Сцена `refusals` проверяла отсутствие фразы, недостижимой на этом пути** — не могла упасть | **принято, переписано:** теперь проверяется отсутствие СОБСТВЕННОЙ фразы клиента для этого класса (она достижима — ею клиент отвечает, когда тела нет), то есть проверка ловит подмену. ⚠ И тем же ходом нашлось второе: фраза фикстуры для 408 была ПОБУКВЕННО равна фразе клиента, из-за чего проверка была слепа — фикстура заговорила своими словами | +| **Клавиатурная проверка Select утверждала то, что верно до нажатия клавиш** | **принято, починено:** сравнивается ИЗМЕНЕНИЕ значения | +| **Список читаемых атрибутов гейта строк не знал `valueLabel`** — текстового пропа обоих новых примитивов | **принято, список расширен** (`valueLabel`, `aria-valuetext`) | +| **`rowsSettled` мог принять промежуточное плато за устаканенную таблицу** | **принято:** нужны ТРИ одинаковых замера подряд, не два | +| **Порог 3:1 сравнивался после округления** — 2.995 прошло бы как 3 | **принято** (формально опровергнуто «сегодня такого токена нет», но дыра реальна): сравнение идёт по замеренному числу | + +**Найдено ревью и починено ДО вердикта скептика** (то есть в «опровергнутых» по причине «уже не +воспроизводится»): фикстура отдавала библиотеку от старых к новым, а платформа — от новых к старым +(`order by added_at desc`), и только что загруженная книга уезжала вниз · `character_count` считался +БАЙТАМИ, а платформа считает код-пойнты (`counter.Write` считает ведущие байты UTF-8) · книга без +прогона получала ненулевые тотали юнитов, и карточка печатала «Разделов 0 · Блоков 12» · старт +прогона отвечал 404 на четырёх из пяти запускаемых книг витрины (фикстура отдавала только заранее +записанный прогон — теперь старт СОЗДАЁТ прогон, как на платформе) · счётчики глав не двигались в +мире интейка, пока счётчик книги бежал к концу (теперь мир отдаёт кадр `chapter`, и чтение с ним +согласовано) · рукопожатие фикстуры объявляло 0.2.2 после бампа спеки до 0.2.3 — **и это теперь +заперто тестом**, потому что клиент сверяет только МАЖОР и молчал бы дальше · правило длины поля +считало UTF-16-единицы вместо байтов · живые области для чтения с экрана, цель чекбокса 18px и его +рамка 1.20:1 о модальную поверхность (последняя — Ф-63, потому что тот же токен несёт рамки всей +оболочки) · обещание «название можно поправить позже» при отсутствии в контракте хоть одной записи +о книге (Ф-62). + +**Опровергнуто и НЕ менялось** (примеры): «спека утверждает про `BookIntake.title` то, чего у +платформы нет» — так и задумано, это ратифицированная правка, а платформа догоняет; «`not_configured` +недостижим» — назван мной самой в записи и в спеке, значение словаря версии; «сужение правила +`style` освобождает любую строку» — React строковый `style` отвергает, носители-объекты ловятся; +«английские фразы платформы в русском интерфейсе» — контракт кладёт слово на сервер, а расхождение +заведено строкой Ф-61, а не залатано вторым носителем на клиенте. + +#### 9а. Второй проход ревью — по осям, которых не было в первом (норма кода · вода · велосипеды · соответствие заданию · ошибки самого задания) + +Первый проход (§9) смотрел контракт, корректность клиента, продуктовые правила, гейты и фикстуру. +Осей, которые владелец назвал прямо — «промышленные стандарты, без хаков и велосипедов, без воды в +коде», — и оси «сделано ли ровно то, о чём просили, с поправкой на то, что промт писала нейросеть» — +в нём НЕ БЫЛО. Второй проход закрывает эту дыру: пять линз (нормы §5.1 · вода · велосипеды · +соответствие заданию · верность самого задания), скептик на каждую находку, агентам ЗАПРЕЩЕНО писать +в репозиторий (в первом проходе один агент оставил правку в моём файле — поймано снимком дерева; +здесь снимок сверен после прогона: дерево тронуто только мной). **46 находок, 6 выжило.** + +| Находка | Диспозиция | +|---|---| +| **(ошибка ЗАДАНИЯ, high) «поле после файла = 400» — неправда.** Промт §2 (PD-172) и §2 (PD-180) так говорят, я перенесла это в спеку 0.2.3 и в фикстуру | **принято, исправлено во всех трёх местах.** По коду платформы (`httpapi/v0.go:352-361`) читатель ОСТАНАВЛИВАЕТСЯ на файле: часть после файла не читается вовсе — обязательная (языки) даёт 400 «как будто её не слали», необязательная (жанр) молча теряется, и загрузка проходит. Моя фикстура отказывала форме с хвостовым `genre` — то есть отказывала ЗАКОННОМУ запросу. Спека переписана точно, фикстура моделирует поток, добавлены два теста: обязательное поле за файлом = 400, необязательное = 201 без него | +| **(ошибка ЗАДАНИЯ, low) §7 велит починить число, которое само неверно:** «`npm run check` — 4 шага против нынешних 7» | **принято.** Шагов **шесть** (посчитано `node -e` по `scripts.check`), и первая редакция моей правки повторила «семь» из промта — то есть я занесла свежую неправду в живой док. Исправлено в `STACK_DECISIONS.md` и в записи | +| **(medium) Дерево узнавало строку-складку, РАЗБИРАЯ id** (`/:\d+$/`) | **принято, переписано.** Контракт: «идентификатор непрозрачен, клиент НЕ ДОЛЖЕН его разбирать», а движок уже строит ключи вида `<глава>:<срез>:<индекс>`, под этот шаблон подходящие. Теперь складка узнаётся по ПРИНАДЛЕЖНОСТИ к множеству, которое этот же файл и построил. ⚠ И первая редакция фикса была неверна — множеством стали ВСЕ дети книги, то есть на плоском дереве складкой оказалась каждая глава; поймала батарея сцен (`tabs`: двойной клик перестал закреплять вкладку), а не рассуждение | +| **(medium) Ветка 409 не показана ни сценой, ни кадром, ни тестом** — при том что §11 требует показать каждую | **принято, построено.** Фикстура двигает потолок ОДИН раз, ровно как описывает контракт («холд по другой книге снижает остаток между чтением и вызовом»), поэтому первое нажатие «Перевести» получает 409 — единственный отказ, до которого доходит корректный клиент на корректной форме. Сцена `intake` теперь проверяет: фраза платформы на экране, наша не подставлена, шкала перечитана и стоит на новых bounds; второе нажатие запускает прогон | +| **(medium) Правка `STACK_DECISIONS` повторила ошибочное число** | см. вторую строку — то же исправление | +| **(low) Мёртвый элемент в списке авто-раскрытия** (`openBookId` дважды, второй под тернарником) | **принято, убрано** | + +**Найдено ревью и починено, хотя скептик снял** (не «опровергнуто по существу», а «уже не +воспроизводится» либо «не этой сессии код», а дефект настоящий): выбор ТОГО ЖЕ файла второй раз не +поднимал событие `change` — вход теперь очищается после чтения, иначе отказ оставался на экране под +только что перевыбранным файлом · арифметика контраста в харнессе сцен округляла ДО сравнения, то +есть имела ровно ту дыру, которую я закрыла в токен-гейте. + +**Опровергнуто по существу, и это тоже результат** (примеры с грунтом скептика): «AddBook 298 строк +и шесть компонентов» — норма мягкая («~150», приёмка глазами), компонент один плюс три вида его же +состояний и два чистых помощника, и файл в ряду зоны не выбивается · «состояние книги решается +литералами в экране, контрольный вопрос провален» — ратифицированное чтение вопроса (компаньон +контракта §«новое продуктовое состояние») требует ОДНОГО файла для карты «статус → вид», и он один, +компилятор его и держит · «карта отказов написана трижды» — таблицы двух операций не пересекаются ни +одной строкой и повторяют перечни ответов из спеки, слияние было бы контрактной ошибкой · +«файловый вход — велосипед, у библиотеки есть `FileTrigger`» — скрытый вход приехал из S3.7 и принят, +а норма зоны запрещает импорт примитивов вне `src/ui/` · «сужение гейта `style` — подгонка» — обе +формы регистра Ф-9 по-прежнему падают, порог не двигался, а сужение объявлено в зонном журнале, +который и есть канал пингов фронта. + +⚠ **Флейк `perf`, названный числом.** В полной батарее сцена показала 63 мс/клавишу при пороге 60 и +уронила команду; та же сцена в одиночку — 38 мс (34·36·38·38·51). Причина видна в `ps`: параллельные +сессии зоны полигона и платформы держали `load average` около 8–13 (их питон и `go test`). Порог не +трогала — S3.7 уже заменила шумный способ снятия величины на медиану пяти заходов, а двигать порог +под чужую нагрузку было бы ровно той подгонкой, которую норма запрещает. На тихой машине (`load` 2.4) +`npm run check:full` — **EXIT=0**, 335 тестов, 136 проверок, 11 сценариев, 8 маршрутов. + +#### 10. Чего я НЕ сделала + +- **Живого стенда платформы не поднимала** и smoke `POST /books` через dev-прокси не гоняла (§10 — + опция, не гейт). Значит всё, что сказано про поведение НАСТОЯЩЕЙ платформы, — из её кода и из + контракта, а не из прогона: сюда попадают определённая доля отправки, вторая фаза «файл ушёл» и + фразы `problem+json` (Ф-61). +- **Отмена отправки на середине не проверена исполнением.** Тест покрывает только уже отменённый + сигнал: мок-сеть перехватывает запрос в тот же тик, и прервать его тиком позже она не даёт. + Сам путь — три строки (`signal → xhr.abort()`), но честнее назвать, чем зачесть. +- **Добавление ГЛАВЫ (§3.10) не строила** — ручки в контракте нет, кнопка без действия запрещена + нормой зоны. Черновика правки спеки не писала: это не хвост загрузки, а отдельный сценарий, и + его форму (глава в существующую книгу? пере-разбор?) решать владельцу контракта. +- **Референса `antigravity_add_folder.png` на диске нет** (папка под .gitignore, как было с + fleet_2.png): форму добавления книги я НЕ перерисовывала — достроила поля в раскладку S3.5. + + + +#### 11. Гейты на сдаче + +`npm run check` — зелёный, **335 тестов** (было 279). `npm run check:full` — EXIT=0: сборка, **8 +маршрутов** со снимком и axe (добавился `/intake`), **11 сценариев, 136 проверок** (добавились +`intake` и `refusals`), ноль блокирующих нарушений axe и ноль узлов контраста на всех маршрутах и +во всех сценах. Числа получены командами `npm run check`, `npm run check:full`, `npm run scenes`. + +Живым нарушением проверено (каждый гейт — своим): расширенный гейт строк роняет все четыре новые +формы (`?? 'Слово'`, тернарник, шаблон, литерал ребёнком элемента) и по-прежнему ловит носители +инлайн-стиля (объект, переменная) · сужение правила `style` не пропускает ничего, кроме строки · +свип языка ловит русский комментарий в безрасширенном `scripts/githooks/pre-commit` · гейт вызовов +каталога ловит лишнюю переменную при вызове через переменную по имени `say` · порог контраста тонов +краснеет на прежнем `--color-danger`. Тесты фиксов приёмки (кадр библиотеки, кадр списка глав, +отметка после провалившегося чтения, отметки после ресинка, поток при медленном перечитывании) +падают на до-фиксном коде — проверено откатом каждого фикса по очереди. + ### 10.08 — приёмка S3.7 оркестратором: ПРИНЯТО и залендено; арбитраж 8.1 — права сессия Гейты пере-прогнаны исполнением: `npm run check:full` EXIT=0 · vitest **279** · 9 сценариев diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index bcf9fd0c..899aa2d2 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -118,8 +118,14 @@ const tokenGate = [ // The leftover hole from §5.4: `
`, where style arrives inside an object. The // `style` property itself is forbidden in object literals — the carrier is then caught where it // is assembled. - { selector: "Property[key.name='style']", message: inlineStyle }, - { selector: "Property[key.value='style']", message: inlineStyle }, + // + // ⚠ Except when its value is a plain string, and that is a narrowing with a reason rather than an + // exemption: React refuses a string `style` outright ("expects a mapping … not a string"), so + // such a property cannot carry styling anywhere — while `{ style: 'unit' }` is how `Intl` + // formats a size in megabytes, and the words of a unit belong to `Intl` and not to us. Every + // carrier that CAN style — an object, a variable, a template — is still caught. + { selector: "Property[key.name='style'][value.type!='Literal']", message: inlineStyle }, + { selector: "Property[key.value='style'][value.type!='Literal']", message: inlineStyle }, // Named colours in the markup: forbidden in CSS, they used to pass in TSX — the asymmetry of Ф-9. { selector: `JSXAttribute[name.name=${colorAttributes}] > Literal[value!=${allowedColorValue}]`, @@ -157,7 +163,26 @@ const letter = String.raw`/[A-Za-z\u0400-\u04FF]/`; // Includes the props our own primitives take text in (`label`, `empty`, `waiting`, `description`, // `hint`, `caption`) — a word handed to a component is as printed as a word between its tags. const readableAttributes = - '/^(title|placeholder|alt|label|empty|waiting|description|hint|caption|aria-label|aria-description)$/'; + '/^(title|placeholder|alt|label|empty|waiting|description|hint|caption|valueLabel|aria-label|aria-description|aria-valuetext)$/'; + +// The shapes a written-out word takes inside `{…}`. A bare literal was the only one the gate knew, +// and the hole it left was found by the acceptance of S3.7 with a live specimen (`Showcase.tsx`: +// `title={book?.title ?? 'TextMachine'}`): a word behind `??` or behind a ternary is as printed as +// one standing on its own. +// +// ⚠ Carriers are listed rather than caught by descent (`JSXExpressionContainer Literal`), and that +// is not timidity: `{text('bank.tab')}` is a literal inside an expression container too, and a +// descending selector would flag every call of the catalogue — the very mechanism this rule exists +// to enforce. Nesting deeper than one carrier goes through a named variable, where a computed +// value is a deliberate step rather than an inline one (the same rule the colour gate applies). +const writtenWord = [ + `Literal[value=${letter}]`, + `TemplateLiteral > TemplateElement[value.raw=${letter}]`, + ...['ConditionalExpression', 'LogicalExpression'].flatMap((carrier) => [ + `${carrier} > Literal[value=${letter}]`, + `${carrier} > TemplateLiteral > TemplateElement[value.raw=${letter}]`, + ]), +]; const noBareText = [ { selector: `JSXText[value=${letter}]`, message: catalogueOnly }, @@ -165,14 +190,16 @@ const noBareText = [ selector: `JSXAttribute[name.name=${readableAttributes}] > Literal[value=${letter}]`, message: catalogueOnly, }, - { - selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > Literal[value=${letter}]`, - message: catalogueOnly, - }, - { - selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > TemplateLiteral > TemplateElement[value.raw=${letter}]`, - message: catalogueOnly, - }, + // Inside a readable attribute, and — the second half of the same hole — as the CHILD of an + // element: `

{'a word'}

` prints one exactly as `

a word

` does, and only the second was + // caught (by the `JSXText` rule). + ...['JSXAttribute[name.name=' + readableAttributes + ']', 'JSXElement', 'JSXFragment'].flatMap( + (host) => + writtenWord.map((shape) => ({ + selector: `${host} > JSXExpressionContainer > ${shape}`, + message: catalogueOnly, + })), + ), ]; export default tseslint.config( diff --git a/frontend/scripts/scenes.mjs b/frontend/scripts/scenes.mjs index 6cbb15e7..37eb964a 100644 --- a/frontend/scripts/scenes.mjs +++ b/frontend/scripts/scenes.mjs @@ -41,13 +41,36 @@ const load = (/** @type {string} */ path) => import(new URL(path, import.meta.ur /** @type {Record} */ const ru = (await load('../src/i18n/ru.ts')).ru; -/** @type {{ book: Schemas['Book'], chapters: Schemas['Chapter'][], notes: Schemas['Note'][] }} */ -const { book, chapters, notes } = await load('../src/mock/book.ts'); +/** @type {(line: string, variables: Record | undefined) => string} */ +const fill = (await load('../src/i18n/fill.ts')).fill; +/** + * @type {{ + * book: Schemas['Book'], + * books: Schemas['Book'][], + * chapters: Schemas['Chapter'][], + * notes: Schemas['Note'][], + * }} + */ +const { book, books, chapters, notes } = await load('../src/mock/book.ts'); + +/** + * The book of a given status, by the fixture's own hand: a title written out here would go stale in + * silence the moment the fixture is edited. + * @param {Schemas['BookStatus']} status + */ +function bookOf(status) { + const found = books.find((row) => row.status === status); + if (!found) throw new Error(`the fixture has no book in status ${status}`); + return found; +} + +const uploadingBook = bookOf('uploading'); +const rejectedBook = bookOf('rejected'); +const pausedBook = bookOf('paused'); /** @type {{ terms: Schemas['BankTerm'][], bankTerms: Schemas['BankTerm'][] }} */ const { terms, bankTerms } = await load('../src/mock/bank.ts'); /** @type {{ scaleTerms: Schemas['BankTerm'][] }} */ const { scaleTerms } = await load('../src/mock/scale.ts'); - /** @typedef {import('playwright').Page} Page */ /** @typedef {import('playwright').Locator} Locator */ /** @typedef {(name: string) => Promise} Shot */ @@ -56,12 +79,17 @@ const { scaleTerms } = await load('../src/mock/scale.ts'); * Interface wording by catalogue key — a scene looks for exactly what the screen prints. An unknown * key throws: a locator built out of `undefined` matches ANY node of its role, and the scene would * go green having checked nothing. + * + * ⚠ The FILLING is the application's own (`src/i18n/fill.ts`), not a copy of it. S3.7 wrote a + * second one right here, with a different rule for a place left without a variable, and its own + * report claimed the module was shared — the acceptance found the claim false. A scene has to + * build the phrase the screen prints, which means by the same code. * @param {MessageKey} key @param {Record} [variables] */ function say(key, variables = {}) { const line = ru[key]; if (line === undefined) throw new Error(`no such key in the message catalogue: ${key}`); - return line.replace(/\{(\w+)\}/g, (whole, name) => String(variables[name] ?? whole)); + return fill(line, variables); } /** @@ -110,6 +138,10 @@ async function audit(page, what) { const blocking = axe.violations.filter((v) => v.id !== 'color-contrast'); for (const v of axe.violations.filter((v) => v.id === 'color-contrast')) { console.log(` contrast (accepted as is): ${v.nodes.length} node(s)`); + // The NODE, not only the count: a bare number is a thing nobody can act on, and the same + // omission cost the acceptance of S3.6 a hunt by hand. + for (const node of v.nodes) + console.log(` ${node.target.join(' ')} — ${node.html.slice(0, 120)}`); } if (blocking.length > 0) { for (const v of blocking) { @@ -266,6 +298,30 @@ const scenes = { /** @param {Page} page @param {Shot} shot */ async scroll(page, shot) { await open(page, '/scale'); + + // The long tail folds by hundreds (Ф-12): 2284 sections stand as 23 folds, and a click on a fold + // is a fold — not a row that answers with nothing. Checked here because this scene already opens + // the book that has the tail. The FIRST fold is the one in the DOM: the tree is virtualized, so + // a fold a thousand rows down is not drawn at all. + const fold = page + .locator('nav [role="row"]') + .filter({ hasText: say('library.chapterRange', { from: 1, to: 100 }) }); + check((await fold.count()) === 1, 'the tree of 2284 sections is folded by hundreds'); + const opened = await page.locator('nav [role="row"]').count(); + await fold.first().click(); + await page.waitForTimeout(300); + const closed = await page.locator('nav [role="row"]').count(); + check( + closed < opened, + `a click on a fold closes it (${String(opened)} → ${String(closed)} rows in the DOM)`, + ); + await fold.first().click(); + await page.waitForTimeout(300); + check( + (await page.locator('nav [role="row"]').count()) === opened, + 'and a second click opens it again', + ); + const bank = page.locator(`[role="grid"][aria-label="${say('bank.tableLabel')}"]`); await bank.evaluate((node) => node.scrollTo(0, 9000)); const before = await bank.evaluate((node) => node.scrollTop); @@ -343,6 +399,41 @@ const scenes = { (await page.locator('[role="tab"][data-preview="true"]').count()) === 1, 'a row of the summary opens its chapter as a preview tab', ); + + // S4: a click on a BOOK opens its state (the zone's §2), and the states that have something to + // say beyond a word say it on the card. The fixture holds every one of the eleven; these three + // are the ones with a sentence attached, and each is reached by choosing its book. + await panel.getByRole('tab', { name: say('about.tab') }).click(); + for (const { title, phrase, what } of [ + { title: uploadingBook.title, phrase: say('about.uploading'), what: 'a book still arriving' }, + { + title: rejectedBook.title, + phrase: say('rejected.sourceUnreadable'), + what: 'a book that could not be parsed', + }, + { + title: pausedBook.title, + phrase: say('paused.creditExhausted'), + what: 'a run halted on its ceiling', + }, + ]) { + await page.locator('nav').getByText(title, { exact: true }).click(); + // The wait is the assertion's own timeout, and its verdict is what `check` prints: a bare + // `check(true, …)` after a wait reads as a tautology to whoever comes next. + const said = await page + .waitForFunction( + (want) => document.querySelector('aside')?.textContent?.includes(want) === true, + phrase, + { timeout: 10_000 }, + ) + .then( + () => true, + () => false, + ); + check(said, `${what}: the card says so in words, not by colour alone («${phrase}»)`); + } + await shot('context-book-state'); + await audit(page, 'the card of a book in an intake state'); }, // Second-round remark 7: the bank is a TABLE in the right panel (not a tab in the centre), with @@ -679,6 +770,13 @@ const scenes = { const panel = page.locator('aside'); // We leave the bank BEFORE it has answered: the tabs are drawn at once, the data are not. await panel.getByRole('tab', { name: say('about.tab') }).click(); + // ⚠ The PRECONDITION is asserted and not hoped for (acceptance of S3.7, finding 5): if the bank + // had already arrived by the time the tab was left, this scene would be measuring an ordinary + // table and going green about nothing. + check( + (await panel.getByRole('grid', { name: say('bank.tableLabel') }).count()) === 0, + 'the bank had not arrived by the time its tab was left — which is what this scene is about', + ); await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, { timeout: 15_000, }); @@ -699,6 +797,19 @@ const scenes = { const neverHidden = await columnSpread( page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') }), ); + // ⚠ Three checks and not one, because the differential alone is a guard a SYMMETRIC regression + // walks straight through (acceptance of S3.7, finding 5): two tables that both fell back to + // equal shares — or both drew nothing at all — measure the same as each other. + check( + hiddenThenShown.widths.length === 4 && hiddenThenShown.sum > 0, + `the table is drawn at all: ${String(hiddenThenShown.widths.length)} columns, ` + + `${String(hiddenThenShown.sum)}px`, + ); + check( + new Set(hiddenThenShown.widths.slice(0, 3)).size > 1, + `and its columns are sized by their CONTENT rather than by equal shares ` + + `(${hiddenThenShown.widths.join('·')})`, + ); check( hiddenThenShown.widths.join('·') === neverHidden.widths.join('·'), `a table mounted in a hidden tab measures the same as one that was never left ` + @@ -769,11 +880,22 @@ const scenes = { // Not "each is wider than zero" — the library cannot give a panel a negative width, so that // form would hold on any layout at all. What is asked is that the three still stand side by // side inside the window, in order and without lying on top of one another. + // ⚠ STRICTLY to the right, not "no further left": equal lefts are exactly what a stack has, + // and the check passed on one (acceptance of S3.7, finding 8). + // + // The stronger form — each panel begins where the previous one ENDS — is deliberately NOT a + // gate: from 125% the centre already runs under the right panel by the number printed below, + // and that is Ф-54, an open question of interface density that belongs to the owner (В-7). + // A gate red by construction teaches people to skip the command, so the overlap is printed + // and the order is asserted. check( layout.edges.length === 3 && layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) && - layout.edges.every(({ left }, index) => left >= (layout.edges[index - 1]?.left ?? 0)), - `${percent}%: the three panels stand side by side inside the window (${layout.panels.join(' · ')})`, + layout.edges.every( + ({ left }, index) => index === 0 || left > (layout.edges[index - 1]?.left ?? 0), + ), + `${percent}%: the three panels stand side by side inside the window ` + + `(${layout.edges.map(({ left, right }) => `${left}‥${right}`).join(' · ')})`, ); // The centre keeps a floor of its own and its CONTENT is cut by it long before the window is: // printed rather than asserted, because the floor is the owner's question of interface density @@ -869,7 +991,6 @@ const scenes = { // (useDeferredValue), so that typing does not wait for the collection. const search = page.getByRole('searchbox', { name: say('bank.search') }); await search.click(); - const before = await table.getByRole('row').count(); const rowsSelector = '[role="grid"] [role="row"]'; // The query MUST narrow the result SHARPLY: a single syllable is in almost every term of this // fixture, the number of rows in the DOM would not change — the measurement would be timing the @@ -887,7 +1008,13 @@ const scenes = { let settled = 0; for (let round = 0; round < 5; round += 1) { await search.fill(''); - await page.waitForTimeout(300); + // ⚠ The round starts from a table that has SETTLED, and the number it settled at is measured + // here rather than inherited: the count of rows in the DOM belongs to the virtualizer and + // depends on where the list stands, so the one taken before the scrolling above is not the + // one this round narrows from. With a fixed pause instead, the rows could still be missing + // when the stopwatch started — "fewer rows than before" would be true on arrival and the + // settling would be measured as nothing (acceptance of S3.7, finding 7). + const full = await rowsSettled(page, rowsSelector); await search.click(); const typed = Date.now(); await page.keyboard.type(query); @@ -901,7 +1028,7 @@ const scenes = { echoes.push(Math.round((Date.now() - typed) / query.length)); await page.waitForFunction( ({ was, selector }) => document.querySelectorAll(selector).length < was, - { was: before, selector: rowsSelector }, + { was: full, selector: rowsSelector }, { timeout: 5000 }, ); settled = Date.now() - typed; @@ -933,6 +1060,341 @@ const scenes = { check(drag.janky <= 3, `long frames over the drag ${drag.janky} (threshold 3)`); }, + // S4: the path the product begins with — a file becomes a book, the book is parsed, the book is + // translated. Everything here runs against the mock network by the form of the contract; the + // fixture's own rules (the file last, the caps) are the platform's, so a client that broke one + // would fail here rather than in production. + /** @param {Page} page @param {Shot} shot */ + async intake(page, shot) { + await open(page, '/intake'); + // The precondition is ASSERTED and not assumed: this world starts empty, and every count below + // is a count of what the form put there. + check( + (await page.locator('nav [role="row"]').count()) === 0, + 'the intake world starts with an empty library', + ); + + await page.getByRole('button', { name: say('library.addBook') }).click(); + const modal = page.getByRole('dialog'); + await shot('intake-form'); + await audit(page, 'the add-a-book form'); + + // The pair of languages is a real choice with a real keyboard: the whole path has to be + // passable without a mouse. + const source = modal.getByLabel(say('addBook.sourceLangLabel')); + const before = (await source.innerText()).trim(); + await source.focus(); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + const after = (await source.innerText()).trim(); + // ⚠ A CHANGE, not "there is a value": the control shows one before a key is ever pressed, so + // the first version of this check was true of a keyboard that did nothing (found by the + // adversarial review). + check( + after !== before && after !== '', + `the language of the original is chosen from the keyboard (${before} → ${after})`, + ); + + await modal.locator('input[type="file"]').setInputFiles({ + name: 'gu-zhen-ren.txt', + mimeType: 'text/plain', + buffer: Buffer.alloc(300_000, 'a'), + }); + await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).fill(book.title); + check( + await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(), + 'a title entered by hand differs from the automatic parse', + ); + + await modal.getByRole('button', { name: say('addBook.submit') }).click(); + // The sending is a STATE and not a flash. ⚠ WHAT IS CHECKED HERE is what the browser world can + // show: under the mock network the request is answered by a service worker, and Chromium reports + // no upload progress for such a request at all (probe: `loadstart` with the right total, then + // nothing). So the screen honestly says the size going out and draws NO share — the determinate + // bar and the "all sent" line belong to the real platform and are proved by src/api/upload.test.ts. + // The line is found by the part of the catalogue string that stands BEFORE the place: the size + // itself is formatted by `Intl` for the interface's locale, and a scene that spelled it out + // would be a second copy of that formatting. + const sending = modal.getByText(say('addBook.sendingUnknown').split('{')[0]?.trim() ?? '', { + exact: false, + }); + await sending.waitFor({ state: 'visible', timeout: 5000 }); + await shot('intake-sending'); + const said = await sending.innerText(); + check(/\d/.test(said), `the form says how much is going out («${said.trim()}»)`); + check( + (await modal.getByRole('progressbar').count()) === 0, + 'and draws no share while the browser reports none — a bar at zero would be a number that means nothing', + ); + + // 201 carries `parsing` — the confirmation shows what came back, from the contract's own + // fields and not from what the form typed in. + await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 }); + await shot('intake-accepted'); + const confirmation = await modal.innerText(); + check( + confirmation.includes(book.title) && confirmation.includes(say('status.parsing')), + 'the confirmation names the book and says it is being parsed', + ); + await audit(page, 'the confirmation of the upload'); + + await modal.getByRole('button', { name: say('action.done') }).click(); + // Parsing is a visible step of its own, with no number on it: the contract has no counter for + // it, so the screen says the state and invents no percentage. + const badge = page.locator('nav [role="row"]').first(); + check( + (await badge.innerText()).includes(say('status.parsing')), + 'the book stands in the library while it is being parsed', + ); + check( + !/\d+\s*%/.test(await badge.innerText()), + 'and no percentage of the parsing is invented anywhere on that row', + ); + await shot('intake-parsing'); + + // Nothing PUSHES the end of the parse — a book being parsed has no run and therefore no + // stream — so this waits for the poll to bring it. + await page.waitForFunction( + (want) => document.querySelector('nav [role="row"]')?.textContent?.includes(want) === true, + say('status.notStarted'), + { timeout: 20_000 }, + ); + // Waited for and not counted on the spot: the tree is read AGAIN when the poll finds the parse + // over, and the answer to that read is a round trip behind the status. + const grown = await page + .waitForFunction(() => document.querySelectorAll('nav [role="row"]').length > 1, null, { + timeout: 15_000, + }) + .then( + () => true, + () => false, + ); + const chapters = await page.locator('nav [role="row"]').count(); + check(grown, `the sections of the parsed book are in the tree (${String(chapters)} rows)`); + await shot('intake-parsed'); + + // The run: the scale is in CHAPTERS, and there is no money on the screen in any form (§4.8). + const panel = page.locator('aside'); + await panel.getByRole('tab', { name: say('about.tab') }).click(); + await panel.getByRole('button', { name: say('run.action') }).click(); + const scale = modal.getByRole('slider'); + await scale.waitFor({ timeout: 5000 }); + await shot('intake-run-form'); + await audit(page, 'the run form'); + // The scale is a native range under the hood (that is what gives it the keyboard and the + // screen reader for free), so its bounds live on the input and not in aria-* attributes. + const boundsOf = () => + scale.evaluate((node) => + node instanceof HTMLInputElement + ? { now: node.value, min: node.min, max: node.max } + : { + now: node.getAttribute('aria-valuenow'), + min: node.getAttribute('aria-valuemin'), + max: node.getAttribute('aria-valuemax'), + }, + ); + const bounds = await boundsOf(); + check( + bounds.now === '4' && bounds.min === '1' && bounds.max === '8', + `the scale is built from the platform's bounds, preset included (${JSON.stringify(bounds)})`, + ); + check( + !/[$₽€]|\d+[.,]\d{2}\b/.test(await modal.innerText()), + 'not a sum, not a currency and not a price anywhere on the run form', + ); + // The scale is a real control for the keyboard, not a decoration for the mouse. + await scale.focus(); + await page.keyboard.press('ArrowRight'); + check((await boundsOf()).now === '5', 'the ceiling moves from the keyboard'); + + // ⚠ THE FIRST PRESS MEETS A 409, and the fixture produces it the way the platform does rather + // than by a marker: the bounds moved between the read and the call (the contract's own case — a + // hold taken for another book lowers what is left). This is the one refusal a correct client + // can meet on a correct form, and §11 of the assignment asks for every branch to be SHOWN. + await modal.getByRole('button', { name: say('run.action') }).click(); + const refused = modal.getByText(say('run.conflictAdvice'), { exact: false }); + await refused.waitFor({ timeout: 10_000 }); + await shot('intake-run-conflict'); + check( + await refused.isVisible(), + 'a ceiling that no longer fits is refused, and the form says so', + ); + // The same shape as the intake refusals: the client's OWN phrase for this class must be absent, + // which is what says the sentence on the screen came from the platform. + check( + !(await modal.innerText()).includes(say('run.conflict')), + "and the phrase is the platform's own, not the client's fallback", + ); + // The scale was re-read, so the value now standing on it is the platform's NEW preset — not the + // one the person picked against bounds that no longer exist. + await page.waitForFunction( + () => + (document.querySelector('[role="dialog"] input[type="range"]')?.getAttribute('max') ?? + '') === '2', + null, + { timeout: 10_000 }, + ); + check((await boundsOf()).now === '2', 'and the scale comes back with the bounds that are left'); + + await modal.getByRole('button', { name: say('run.action') }).click(); + // The run goes, and it ends where the contract says a ceiling stop ends: `paused`, with a + // machine reason, never `failed`. The phrase is the owner's (В-6) and the client only picks it. + await page.waitForFunction( + (want) => document.querySelector('footer')?.textContent?.includes(want) === true, + say('paused.creditExhausted'), + { timeout: 20_000 }, + ); + await shot('intake-paused'); + // The halt is a LIVED state and not a toast: the strip carries it, the card carries it, and both + // are still there a minute later — which a message that fades would not be. + check( + (await page.locator('footer').innerText()).includes(say('paused.creditExhausted')), + 'the halt on the ceiling is named in the status strip, in the owner’s own words', + ); + check( + (await panel.innerText()).includes(say('paused.creditExhausted')), + 'and on the card of the book, where the action for it would be', + ); + // ⚠ What the card offers is a NEW run — which carries a new ceiling — and never "continue": + // after a ceiling stop `resume` does not move the run at all (contract §resumeRun). + check( + (await panel.innerText()).includes(say('run.action')), + 'the way on from a halt is a new run with a ceiling of its own, and it is offered', + ); + }, + + // The other half of the same path: everything that can go wrong with an intake, each as a state + // of the form rather than an alert over it (§3.8). The class is asked for by a marker in the name + // of the file — the fixture's own switch (src/mock/intake.ts), because a correct client cannot + // produce these answers and the screens for them would otherwise be checked by nothing. + /** @param {Page} page @param {Shot} shot */ + async refusals(page, shot) { + await open(page, '/intake'); + const modal = page.getByRole('dialog'); + + /** @param {string} name @param {number} size */ + const send = async (name, size) => { + await page.getByRole('button', { name: say('library.addBook') }).click(); + await modal.locator('input[type="file"]').setInputFiles({ + name, + mimeType: 'text/plain', + buffer: Buffer.alloc(size, 'a'), + }); + await modal.getByRole('button', { name: say('addBook.submit') }).click(); + // Any of the advices the form can end on: which one it is, is what the checks below ask. + await modal + .getByText(say('upload.retryAdvice'), { exact: false }) + .or(modal.getByText(say('upload.badRequestAdvice'), { exact: false })) + .or(modal.getByText(say('upload.tooLargeAdvice'), { exact: false })) + .or(modal.getByText(say('upload.notAcceptedAdvice'), { exact: false })) + .waitFor({ timeout: 10_000 }); + return modal.innerText(); + }; + + // The body over the cap — the one refusal the fixture takes from the FILE itself and not from + // a marker, because that rule is real on both sides of the wire. + const tooLarge = await send('gu-zhen-ren.txt', 2_400_000); + await shot('refusal-413'); + check( + tooLarge.includes(say('upload.tooLargeAdvice')), + 'a file over the cap is refused with the advice to take a smaller one', + ); + await audit(page, 'the refusal of an oversized file'); + await closeModal(page); + + for (const { marker, advice, ours, what } of [ + { + marker: 'refuse-400', + advice: say('upload.badRequestAdvice'), + ours: say('upload.badRequest'), + what: 'a form that did not arrive whole', + }, + { + marker: 'refuse-404', + advice: say('upload.notAcceptedAdvice'), + ours: say('upload.notAccepted'), + what: 'a deployment that accepts no books', + }, + { + marker: 'refuse-408', + advice: say('upload.retryAdvice'), + ours: say('upload.timeout'), + what: 'a body that missed the deadline', + }, + ]) { + const shown = await send(`${marker}.txt`, 1000); + await shot(`refusal-${marker}`); + check(shown.includes(advice), `${what}: the form says what to do next`); + // ⚠ The CLIENT'S OWN phrase for this very class must be absent: it is the fallback used when + // the platform sends no problem body, so it is reachable — and its absence therefore says the + // phrase on the screen came from the server. The first version of this check looked for a + // phrase no path can print at all and so could never fail (found by the adversarial review). + check( + !shown.includes(ours), + `${what}: the phrase shown is the platform's, not the client's own («${ours}»)`, + ); + await closeModal(page); + } + + // And the other end of the intake: a book that was accepted and could not be turned into + // chapters. Three reasons, three different next actions. + for (const { marker, reason, advice } of [ + { + marker: 'reject-source', + reason: say('rejected.sourceUnreadable'), + advice: say('rejected.sourceUnreadableAdvice'), + }, + { + marker: 'reject-config', + reason: say('rejected.notConfigured'), + advice: say('rejected.notConfiguredAdvice'), + }, + { + marker: 'reject-parser', + reason: say('rejected.parserUnavailable'), + advice: say('rejected.parserUnavailableAdvice'), + }, + ]) { + await page.getByRole('button', { name: say('library.addBook') }).click(); + await modal.locator('input[type="file"]').setInputFiles({ + name: `${marker}.txt`, + mimeType: 'text/plain', + buffer: Buffer.alloc(1000, 'a'), + }); + await modal.getByRole('button', { name: say('addBook.submit') }).click(); + await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 }); + await modal.getByRole('button', { name: say('action.done') }).click(); + // The book just uploaded is CHOSEN in the tree — the library holds the ones before it, and a + // card that went on showing the first book would make every reason below read the same. + // (The title is the name of the file: the form left it to the parse.) + await page.locator('nav').getByText(marker, { exact: true }).click(); + const panel = page.locator('aside'); + await panel.getByRole('tab', { name: say('about.tab') }).click(); + await page.waitForFunction( + (want) => document.querySelector('aside')?.textContent?.includes(want) === true, + reason, + { timeout: 20_000 }, + ); + await shot(`refusal-${marker}`); + // The row of a book can be SELECTED since S4, so the pairs on the blue ground are checked + // here as well: axe does not see the contrast of a badge it considers decorative, and the + // rule that recolours it had been removed in S3.7 as dead code. + const faint = await contrastInSelectedRow(page); + check( + faint.length === 0, + `${marker}: everything on the selected row of the book is readable ${faint + .map((part) => `${part.what} ${part.ratio}:1 < ${part.floor}`) + .join(' · ')}`, + ); + check( + (await panel.innerText()).includes(advice), + `${marker}: the card names the reason and what to do about it`, + ); + } + await audit(page, 'a rejected book on the card'); + }, + // Remarks 11, 12, 4: three modal windows instead of stub tabs and a second search button. /** @param {Page} page @param {Shot} shot */ async overlays(page, shot) { @@ -959,7 +1421,9 @@ const scenes = { await modal.getByText(say('addBook.titleHintAuto'), { exact: false }).isVisible(), 'an empty title field honestly says that the title will be given by parsing the file', ); - await modal.getByRole('textbox').last().fill(book.title); + // By NAME and not by position: the form has gained the pair of languages and the genre since + // this scene was written, and `.last()` had quietly become the genre field. + await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).fill(book.title); check( await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(), 'a title entered by hand differs from the automatic parse', @@ -1087,9 +1551,12 @@ function contrastInSelectedRow(page) { c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); }; + // ⚠ NOT rounded before the comparison: two places would accept 2.995 as three, which is the + // hole the token gate closed on its own copy of this arithmetic (adversarial review). The + // rounding that remains is for the MESSAGE and happens after the verdict. const ratio = (/** @type {string} */ a, /** @type {string} */ b) => { const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x); - return Math.round((100 * (high + 0.05)) / (low + 0.05)) / 100; + return (high + 0.05) / (low + 0.05); }; const row = document.querySelector('nav [role="row"][data-selected]'); @@ -1121,10 +1588,34 @@ function contrastInSelectedRow(page) { }); } } - return parts.filter((part) => part.ratio < part.floor); + return parts + .filter((part) => part.ratio < part.floor) + .map((part) => ({ ...part, ratio: Math.round(part.ratio * 100) / 100 })); }); } +/** + * The number of rows the table has come to rest at. The repaint of the collection is deferred on + * purpose (`useDeferredValue`), so "how many rows are there" has no answer until it stops changing — + * and a fixed pause in its place is a number that is either too short to be true or too long to be + * a measurement. + * @param {Page} page @param {string} selector + */ +async function rowsSettled(page, selector) { + // THREE readings in a row, not two: the deferred repaint has plateaus of its own, and two equal + // samples 50 ms apart can both land on one (found by the adversarial review). + let previous = -1; + let steady = 0; + for (let attempt = 0; attempt < 60; attempt += 1) { + const now = await page.locator(selector).count(); + steady = now === previous ? steady + 1 : 0; + if (steady >= 2 && now > 0) return now; + previous = now; + await page.waitForTimeout(50); + } + throw new Error('the table never settled on a number of rows'); +} + /** * Drags the right separator through a list of offsets from where it started, then lets go. * @param {Page} page @param {number[]} offsets @@ -1215,6 +1706,21 @@ const handleStyle = (separator) => return { opacity: style.opacity, background: style.backgroundColor, height: style.height }; }); +/** + * Closes the modal that is open and WAITS for it to be gone. + * + * Escape alone is a race: the window plays a transition, and the next click lands on the overlay + * that is still there — which is how this scene began failing once in every few runs, with the + * pointer intercepted by `_overlay_`. Waiting for the dialog to detach is the only honest signal. + * @param {Page} page + */ +async function closeModal(page) { + const modal = page.getByRole('dialog'); + if ((await modal.count()) === 0) return; + await page.keyboard.press('Escape'); + await modal.waitFor({ state: 'detached', timeout: 5000 }); +} + /** @param {Page} page @param {string} route */ async function open(page, route) { await page.goto(`${origin}${route}`, { waitUntil: 'load' }); diff --git a/frontend/scripts/shot.mjs b/frontend/scripts/shot.mjs index 129c3eee..e9287555 100644 --- a/frontend/scripts/shot.mjs +++ b/frontend/scripts/shot.mjs @@ -21,6 +21,7 @@ const KNOWN_ROUTES = [ '/showcase', '/scale', '/empty', + '/intake', '/loading', '/error', '/offline', diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 775e22ed..490a67c9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,7 +5,8 @@ import type { Page, Problem } from './contract'; -const basePath = '/v0'; +/** The version prefix, in ONE place: the upload speaks the same surface and must not carry a copy. */ +export const basePath = '/v0'; // CSRF on the cookie path. What protects is the PRESENCE of the header; the value is arbitrary and // carries no token semantics. Required on same-origin requests too — it is not a CORS mechanism. diff --git a/frontend/src/api/contract.test.ts b/frontend/src/api/contract.test.ts index 4d7c97b2..9276ec41 100644 --- a/frontend/src/api/contract.test.ts +++ b/frontend/src/api/contract.test.ts @@ -7,6 +7,7 @@ import openapiTS, { astToString } from 'openapi-typescript'; import { expect, test } from 'vitest'; import { readFile } from 'node:fs/promises'; +import { contractVersion } from '../mock/events'; import { narrow } from './contract'; import { majorOf, supportedMajor } from './stream'; import { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary'; @@ -46,6 +47,14 @@ test('the major version this build speaks is the one the spec declares', async ( expect(majorOf(declared as string)).toBe(supportedMajor); }); +// The fixture announces a version in its handshake, and nothing at run time can read the spec to +// check it: the copy is locked here. It drifted a whole session unnoticed, because the client +// compares the MAJOR — so a wrong minor breaks no test and tells no one. +test('the version the fixture announces is the version the spec declares', async () => { + const spec = await readFile(specPath, 'utf8'); + expect(contractVersion).toBe(/^\s{2}version:\s*(\S+)\s*$/m.exec(spec)?.[1]); +}); + test('an unknown vocabulary value is narrowed, not trusted and not thrown', () => { expect(bookStatus.read('translating')).toBe('translating'); expect(bookStatus.read('status-from-a-future-contract')).toBeNull(); diff --git a/frontend/src/api/contract.ts b/frontend/src/api/contract.ts index 92865ddd..f97bd35a 100644 --- a/frontend/src/api/contract.ts +++ b/frontend/src/api/contract.ts @@ -12,6 +12,7 @@ import { bookStatus, noteSeverity, pausedReason, + rejectReason, termKind, termOrigin, termStatus, @@ -24,6 +25,7 @@ type Schemas = components['schemas']; export type BookStatus = Schemas['BookStatus']; export type PausedReason = Schemas['PausedReason']; +export type RejectReason = Schemas['RejectReason']; export type NoteSeverity = Schemas['NoteSeverity']; export type TermKind = Schemas['TermKind']; export type TermStatus = Schemas['TermStatus']; @@ -40,7 +42,12 @@ export type CeilingBounds = Schemas['CeilingBounds']; export type BankDecision = Schemas['BankDecision']; export type Problem = Schemas['Problem']; -export type Book = Omit & { status: BookStatus | null }; +export type Book = Omit & { + status: BookStatus | null; + // Optional on the wire and narrowed to `null` when unknown: the contract says an absent reason + // and one this build does not know are the same fact — "why is not known" — and are shown alike. + reject_reason?: RejectReason | null; +}; export type Run = Omit & { status: BookStatus | null; paused_reason: PausedReason | null; @@ -75,7 +82,14 @@ export interface Page { } export const narrow = { - book: (wire: Schemas['Book']): Book => ({ ...wire, status: bookStatus.read(wire.status) }), + book: (wire: Schemas['Book']): Book => ({ + ...wire, + status: bookStatus.read(wire.status), + reject_reason: + wire.reject_reason === undefined || wire.reject_reason === null + ? wire.reject_reason + : rejectReason.read(wire.reject_reason), + }), run: (wire: Schemas['Run']): Run => ({ ...wire, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a723ca1c..79c8e74c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -12,7 +12,16 @@ export * from './scenarios'; export { ApiError } from './client'; export { subscribeToRun, supportedMajor, majorOf, readFrame } from './stream'; export type { ConnectionState, Frame } from './stream'; -export { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary'; +export { uploadBook, UploadAborted } from './upload'; +export type { BookIntake, UploadProgress } from './upload'; +export { + bookStatus, + noteSeverity, + pausedReason, + rejectReason, + termKind, + termStatus, +} from './vocabulary'; export type { Tone } from './vocabulary'; /** diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index be4675bc..931cf250 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -9,10 +9,12 @@ import { replaceEqualDeep } from '@tanstack/react-query'; import { request, requestAll } from './client'; import { narrow } from './contract'; +import { bookStatus } from './vocabulary'; import type { Bank, BankDecision, BookDetail, + BookStatus, ChapterList, Id, Library, @@ -59,6 +61,23 @@ const dropStaleReads = { }, }; +/** + * How often a book still in intake is asked about again, in milliseconds. + * + * ⚠ POLLING, and it is the contract's shape that forces it rather than a shortcut: the live channel + * belongs to a RUN (`/runs/{runId}/events`), and a book being uploaded or parsed has no run — so + * nothing pushes "the parsing is over". Without asking again, a book stays `parsing` on the screen + * until something else happens to invalidate the read. The interval is deliberately unhurried: this + * is a step that takes minutes on a real book, and the answer is a small JSON. It stops the moment + * the book leaves intake — a poll that never stops is the thing to be afraid of here (BACKLOG Ф-56: + * an event for the end of the intake is a question for the contract's owner). + */ +export const intakePollMs = 3000; + +/** Whether a book is still arriving. The answer is a property of the STATUS and lives with it. */ +export const inIntake = (status: BookStatus | null | undefined) => + status !== undefined && bookStatus.describe(status).intake === true; + export const keys = { library: () => ['library'] as const, book: (bookId: Id) => ['book', bookId] as const, @@ -148,11 +167,15 @@ export const libraryQuery = () => ({ queryKey: keys.library(), queryFn: readLibrary, ...dropStaleReads, + refetchInterval: ({ state }: { state: { data?: Library } }) => + state.data?.books.some((book) => inIntake(book.status)) === true ? intakePollMs : false, }); export const bookQuery = (bookId: Id) => ({ queryKey: keys.book(bookId), queryFn: () => readBook(bookId), ...dropStaleReads, + refetchInterval: ({ state }: { state: { data?: BookDetail } }) => + inIntake(state.data?.book.status) ? intakePollMs : false, }); export const chaptersQuery = (bookId: Id) => ({ queryKey: keys.chapters(bookId), @@ -178,6 +201,11 @@ export const usageQuery = () => ({ queryKey: keys.usage(), queryFn: readUsage }) export const runOptionsQuery = (bookId: Id) => ({ queryKey: keys.runOptions(bookId), queryFn: () => readRunOptions(bookId), + // ⚠ NEVER fresh from the cache, against the client's own 15-second default: the whole reason + // these bounds are a resource of their own is that the maximum belongs to the ACCOUNT and moves + // while the book does not (contract, `run-options`). Re-opening the form within the window + // otherwise showed a scale read before somebody else's hold was taken. + staleTime: 0, }); // Actions. Unsafe methods, so every one of them carries `X-TM-Client` — the client half of the diff --git a/frontend/src/api/scenarios.ts b/frontend/src/api/scenarios.ts index fdfc4863..bf73639e 100644 --- a/frontend/src/api/scenarios.ts +++ b/frontend/src/api/scenarios.ts @@ -10,6 +10,7 @@ export const scenarios = [ 'showcase', 'scale', 'empty', + 'intake', 'loading', 'error', 'offline', diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 662479bd..8d3a03f6 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -24,8 +24,40 @@ export interface paths { put?: never; /** * Add a book. - * @description Accepts the file and the properties declared by the user. Responds immediately; the book - * enters `uploading` and parsing is a separate visible step. + * @description Accepts the file and the properties declared by the user. + * + * **The `file` part MUST come LAST in the form** (0.2.3). The platform reads the form as a + * STREAM, part by part, and stops at the file: the book's row — the record that makes an + * upload visible while it arrives and findable when it dies halfway — cannot be written + * before the languages that row requires. + * + * A part sent after the file is therefore NOT READ AT ALL. What that costs depends on the + * part: a required one (`source_lang`, `target_lang`) is answered `400`, because to a reader + * that streams "the languages came late" and "the languages never came" are the same thing; + * an optional one is silently lost, and the book is created without it. A client that puts + * anything after the file is a client whose form is partly ignored. + * + * **The `201` carries `parsing`, not `uploading`** (0.2.3). The answer is written after the + * last byte has landed, so by the time a client can read it the file is in and the book has + * moved on. `uploading` is a real state and is observable — by a SECOND read of the library + * while the upload is still on the wire — but never as the answer to this call. + * + * Parsing is a separate visible step after that, and it has no numeric progress: no counter of + * it exists on this contract, and a percentage would have to be invented. + * + * Refusals of the intake, all of them product states of the form rather than failures of the + * service: + * + * - `400` — the form could not be read: more than 16 parts, a text field longer than a + * kilobyte, a REQUIRED field that arrived after the file (or never), or no file at all; + * - `404` — this deployment does not accept books at all. An instance with no place to put a + * file and no engine to cut it with serves the library and refuses this path, which is a + * property of the DEPLOYMENT and not of the request; + * - `408` — the body did not finish inside the route's deadline: a slow client on a large + * book. Retrying is the remedy, which is what separates it from `413`; + * - `413` — the body is over the intake cap. The threshold belongs to the deployment and is + * not stated here: a number in the contract would be a second copy of it, and the two would + * disagree the day it changes. */ post: operations["createBook"]; delete?: never; @@ -342,6 +374,11 @@ export interface paths { * * **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a * complete set. + * + * **503 answers a deployment that cannot run at all** (0.2.3). Continuing a run is starting a + * process, so it needs exactly what a start needs — the seam that tells the engine its ceiling + * and the one that records how a run ended. 0.2.1 named that state for `startRun` only, and + * the omission was in the SPEC and not in the wire: the two calls share one refusal. */ post: operations["resumeRun"]; delete?: never; @@ -523,6 +560,30 @@ export interface components { * @enum {string} */ BookStatus: "uploading" | "parsing" | "not_started" | "translating" | "awaiting_bank" | "finalizing" | "ready" | "paused" | "stopped" | "rejected" | "failed"; + /** + * @description Machine reason a book was rejected (0.2.3). The platform's own closed vocabulary; as with + * `PausedReason` the API carries STATE and the phrase the user reads is drawn by the client, + * so no wording appears here. + * + * - `source_unreadable` — the file was read and is not a book this service can cut: it yields + * no sections at all, or the reader refused it. TERMINAL, and the source does not survive + * it — there is no path in this contract that re-reads a rejected book, so the remedy is to + * add the book again; + * - `not_configured` — this deployment has nothing to read the book AGAINST. It is a state of + * the service, never of the file, and retrying by itself does not clear it; + * - `parser_unavailable` — the service could not process the file, repeatedly, until it gave + * up. A state of the service as well, and a temporary one. + * + * The three are two different NEXT ACTIONS for the user, and a client that told them apart by + * colour alone would be telling them apart by nothing: the first means "the file is not one we + * can read", the other two mean "not us, not now". + * + * A client MUST tolerate an unknown value arriving under a minor bump, and MUST render a + * rejected book that carries no reason at all: a deployment older than this minor answers + * exactly that. + * @enum {string} + */ + RejectReason: "source_unreadable" | "not_configured" | "parser_unavailable"; /** @description A book in the library. */ Book: { id: components["schemas"]["Id"]; @@ -537,6 +598,16 @@ export interface components { /** Format: date-time */ added_at: string; status: components["schemas"]["BookStatus"]; + /** + * @description Why the book was rejected; meaningful only while `status` is `rejected` (0.2.3). + * + * OPTIONAL, unlike `Run.paused_reason`, and the asymmetry is deliberate: a rejection + * reason is meaningful in exactly one of eleven states, and a deployment that predates + * this minor sends nothing here at all. "Absent" and "null" therefore mean one and the + * same thing — the reason is not known — and a client renders both the same way. That is + * the opposite of the `sense` case (0.2.2), where the two shapes carried DIFFERENT facts. + */ + reject_reason?: components["schemas"]["RejectReason"] | null; progress: components["schemas"]["Progress"]; note_count: number; }; @@ -583,11 +654,27 @@ export interface components { /** @description Current or last run; `null` if the book was never run. */ run?: components["schemas"]["Run"] | null; }; - /** @description Add-a-book form. */ + /** + * @description Add-a-book form. + * + * ⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part, and + * every other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the + * rule is stated in prose because it cannot be stated in the schema. + */ BookIntake: { + /** + * @description Title given by hand (0.2.3). OPTIONAL, and the two cases are told apart by the user + * rather than guessed at: absent or empty means "the parse will name it" — today the + * platform takes the name of the uploaded file — while a value present means the person + * named the book themselves, and no later parse overwrites it. + * + * Bounded like the title the platform derives: the library lists it, and an unbounded + * string on that screen is the client's problem to draw, not the server's to store. + */ + title?: string; /** * Format: binary - * @description Book file. + * @description Book file. The LAST part of the form. */ file: string; source_lang: components["schemas"]["LangCode"]; @@ -1022,9 +1109,23 @@ export interface components { }; }; /** - * @description The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a - * run requires the engine seam to be fully configured, and answering with any other code - * would misname the state. Temporary by nature — retry later; no Retry-After is promised. + * @description The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on + * a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the + * remedy — which is what a `413` and a `500` in its place would both hide. + */ + RequestTimeout: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** + * @description The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting or + * continuing a run requires the engine seam to be fully configured, and answering with any + * other code would misname the state. Temporary by nature — retry later; no Retry-After is + * promised. */ ServiceUnavailable: { headers: { @@ -1115,7 +1216,7 @@ export interface operations { }; }; responses: { - /** @description Book accepted. */ + /** @description Book accepted; it is being parsed. */ 201: { headers: { [name: string]: unknown; @@ -1126,6 +1227,8 @@ export interface operations { }; 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 408: components["responses"]["RequestTimeout"]; 413: components["responses"]["TooLarge"]; }; }; @@ -1482,6 +1585,7 @@ export interface operations { 401: components["responses"]["Unauthorized"]; 404: components["responses"]["NotFound"]; 409: components["responses"]["Conflict"]; + 503: components["responses"]["ServiceUnavailable"]; }; }; getUsage: { diff --git a/frontend/src/api/upload.test.ts b/frontend/src/api/upload.test.ts new file mode 100644 index 00000000..b6a5285d --- /dev/null +++ b/frontend/src/api/upload.test.ts @@ -0,0 +1,254 @@ +// The upload, against the mock network. Its own file because what is tested here is a TRANSPORT and +// not a read: the order of the parts on the wire, the numbers the browser reports while a body goes +// out, and the way every class of refusal comes back. +// +// ⚠ The progress half is tested HERE and not by a scene, and that is a measurement rather than a +// preference: in a browser the mock network is a service worker, and Chromium reports no upload +// progress for a request a worker answers (probe: `loadstart` with the right total, then nothing). +// Under this runner the mock intercepts `XMLHttpRequest` itself and does emit the events, so the +// wiring is provable — while the scene can only check what the browser world is able to show. + +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest'; + +import { ApiError } from './client'; +import { book } from '../mock/book'; +import { rewind } from '../mock/live'; +import { clearIntake, intakeLimits, settleIntake } from '../mock/intake'; +import { handlersFor } from '../mock/handlers'; +import { readBook, readChapters, readLibrary } from './queries'; +import { uploadBook } from './upload'; +import type { UploadProgress } from './upload'; + +const server = setupServer(); +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }); +}); +afterAll(() => { + server.close(); +}); +beforeEach(() => { + clearIntake(); + // The moving state of the fixture is shared by the worlds (one module, one live counter), and a + // run started here would otherwise hand its totals to the next file's showcase book. + rewind(); + server.use(...handlersFor('intake')); +}); +afterEach(() => { + server.resetHandlers(); +}); + +const bookFile = (name = 'gu-zhen-ren.txt', size = 4096) => + new File([new Uint8Array(size)], name, { type: 'text/plain' }); + +test('the book is accepted and comes back as `parsing`, never as `uploading`', async () => { + const accepted = await uploadBook({ + file: bookFile(), + sourceLang: 'zh', + targetLang: 'ru', + title: book.title, + }); + + // PD-180: by the time an answer can be read the last byte is in, so the state it carries is the + // one AFTER the upload. A client that drew `uploading` from this answer would draw a state the + // book has already left. + expect(accepted.status).toBe('parsing'); + expect(accepted.title).toBe(book.title); + expect(accepted.character_count).toBe(4096); + // The chapters are not there yet, and the count says so rather than promising a tree. + expect(accepted.chapter_count).toBe(0); +}); + +test('an empty title leaves the naming to the parse', async () => { + const accepted = await uploadBook({ + file: bookFile(), + sourceLang: 'zh', + targetLang: 'ru', + title: '', + }); + // Today that means the name of the uploaded file (0.2.3): the form sends no `title` part at all, + // and what the platform does with its absence is the platform's. + expect(accepted.title).toBe('gu-zhen-ren'); +}); + +test('the file is the LAST part of the form, and every field precedes it', async () => { + // The rule the whole intake stands on (PD-172): the platform reads the form as a stream and + // writes the book's row before the body of the file, so a field behind the file is never read. + // What the two tests below add is the other side of it — what the platform does with a part that + // arrived too late, required or not. + let parts: string[] = []; + server.use( + http.post('*/v0/books', async ({ request }) => { + parts = [...(await request.formData()).keys()]; + return HttpResponse.json({}, { status: 500 }); + }), + ); + + await uploadBook({ + file: bookFile(), + sourceLang: 'zh', + targetLang: 'ru', + title: book.title, + genre: book.genre, + }).catch(() => undefined); + + expect(parts).toEqual(['title', 'source_lang', 'target_lang', 'genre', 'file']); +}); + +test('a REQUIRED field behind the file is the same as a field never sent', async () => { + // The other side of the rule the client obeys, and it is stated exactly as the platform behaves: + // the reader stops at the file, so the languages behind it were never seen — and "late" and + // "never" are one answer, 400. + const form = new FormData(); + form.append('file', bookFile()); + form.append('source_lang', 'zh'); + form.append('target_lang', 'ru'); + + const answer = await fetch('/v0/books', { + method: 'POST', + headers: { 'X-TM-Client': 'web' }, + body: form, + }); + + expect(answer.status).toBe(400); +}); + +test('an OPTIONAL field behind the file is lost, and the book is created without it', async () => { + // ⚠ Not a refusal. The assignment of this pack said "a field after the file = 400" and the + // platform does not do that: it stops reading at the file, so `genre` behind it is dropped on the + // floor and the upload succeeds. A fixture that refused this would refuse a legal request. + const form = new FormData(); + form.append('source_lang', 'zh'); + form.append('target_lang', 'ru'); + form.append('file', bookFile()); + form.append('genre', book.genre ?? ''); + + const answer = await fetch('/v0/books', { + method: 'POST', + headers: { 'X-TM-Client': 'web' }, + body: form, + }); + + expect(answer.status).toBe(201); + expect(((await answer.json()) as { genre?: string }).genre).toBe(''); +}); + +test('more parts than the form allows, or a field longer than a kilobyte, are refused', async () => { + const many = new FormData(); + for (let part = 0; part < intakeLimits.maxParts; part += 1) many.append(`x${String(part)}`, '1'); + many.append('file', bookFile()); + expect( + (await fetch('/v0/books', { method: 'POST', headers: { 'X-TM-Client': 'web' }, body: many })) + .status, + ).toBe(400); + + const long = new FormData(); + long.append('genre', 'x'.repeat(intakeLimits.maxFieldBytes + 1)); + long.append('source_lang', 'zh'); + long.append('target_lang', 'ru'); + long.append('file', bookFile()); + expect( + (await fetch('/v0/books', { method: 'POST', headers: { 'X-TM-Client': 'web' }, body: long })) + .status, + ).toBe(400); +}); + +test('the numbers of the body going out reach the caller', async () => { + const seen: UploadProgress[] = []; + await uploadBook( + { file: bookFile('gu-zhen-ren.txt', 4096), sourceLang: 'zh', targetLang: 'ru' }, + { onProgress: (progress) => seen.push(progress) }, + ); + + expect(seen.length).toBeGreaterThan(0); + const last = seen.at(-1); + // The total is the whole multipart body — bigger than the file by its envelope — and the share a + // screen draws is `sent / total`, which is why both halves have to arrive. + expect(last?.total).toBeGreaterThanOrEqual(4096); + expect(last?.sent).toBe(last?.total); +}); + +test('a body over the cap is refused with 413 and a product phrase', async () => { + const failure = await uploadBook({ + file: bookFile('big.txt', intakeLimits.maxBodyBytes + 1), + sourceLang: 'zh', + targetLang: 'ru', + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ApiError); + expect((failure as ApiError).status).toBe(413); + // The phrase is the platform's and reaches the screen untouched; what the client adds is the + // ADVICE, which is different for every class and is not on the wire at all. + // ⚠ Asserted as a NON-EMPTY string and not as `not.toBe('')`: with no problem body at all the + // title is `undefined`, which is also "not an empty string" — the check passed on the very case + // it exists to catch (found by the adversarial review). + expect(typeof (failure as ApiError).problem?.title).toBe('string'); + expect((failure as ApiError).problem?.title).not.toBe(''); +}); + +test.each([ + ['refuse-400.txt', 400], + ['refuse-404.txt', 404], + ['refuse-408.txt', 408], +])('%s is answered %i', async (name, status) => { + const failure = await uploadBook({ + file: bookFile(name), + sourceLang: 'zh', + targetLang: 'ru', + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ApiError); + expect((failure as ApiError).status).toBe(status); +}); + +test('an upload the user cancelled is not a failure of anything', async () => { + const abort = new AbortController(); + abort.abort(); + + // ⚠ The signal is checked BEFORE the request is opened, and this test covers only that half. A + // cancellation mid-body is the browser's own (`xhr.abort()`), and the mock network cannot be made + // to model it: under this runner it intercepts the request the moment it is sent, so an abort a + // tick later changes nothing. Named rather than faked green. + await expect( + uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru' }, { signal: abort.signal }), + ).rejects.toMatchObject({ name: 'UploadAborted' }); +}); + +test('a parsed book gains its chapters; a rejected one gains a machine reason', async () => { + const good = await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru' }); + const bad = await uploadBook({ + file: bookFile('reject-source.txt'), + sourceLang: 'zh', + targetLang: 'ru', + }); + settleIntake(); + + const parsed = await readBook(good.id); + expect(parsed.book.status).toBe('not_started'); + expect((await readChapters(good.id)).chapters.length).toBeGreaterThan(0); + + const rejected = await readBook(bad.id); + expect(rejected.book.status).toBe('rejected'); + expect(rejected.book.reject_reason).toBe('source_unreadable'); + // Both books are in the library, and both are visible: a rejected book keeps its row so that the + // person can see the upload did not make it. + expect((await readLibrary()).books).toHaveLength(2); +}); + +test('a reason this build does not know is narrowed to "not known", never trusted', async () => { + const accepted = await uploadBook({ file: bookFile(), sourceLang: 'zh', targetLang: 'ru' }); + server.use( + http.get('*/v0/books/:bookId', () => + HttpResponse.json({ + revision: 1, + book: { ...accepted, status: 'rejected', reject_reason: 'a_reason_from_a_future_contract' }, + run: null, + }), + ), + ); + + // Narrowed to `null` — which the screen shows exactly as it shows an ABSENT reason, because the + // contract says the two are one fact: the reason is not known (0.2.3). + expect((await readBook(accepted.id)).book.reject_reason).toBeNull(); +}); diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts new file mode 100644 index 00000000..d11881d0 --- /dev/null +++ b/frontend/src/api/upload.ts @@ -0,0 +1,143 @@ +// Sending a book. The one call of this surface that carries a body worth watching, and therefore +// the one that is not written on `fetch`. +// +// ⚠ WHY XHR AND NOT FETCH. A book is tens of megabytes on a domestic connection — minutes of +// silence — and `fetch` reports nothing about a body going out: the Fetch standard exposes progress +// on the RESPONSE (`response.body`) and has no equivalent for the request. The only standard way to +// stream a request and watch it is a `ReadableStream` body, which needs `duplex: 'half'`, is HTTP/2 +// only, and is shipped by no browser but Chromium — so on Firefox and Safari it would not upload at +// all. `XMLHttpRequest.upload` is the interoperable mechanism, it is what every upload widget in the +// industry still uses for this, and it is legal here and only here: the network gate +// (eslint.config.js) forbids it outside `src/api/`. +// +// The seam does not leak: the screen gets a promise and a stream of numbers, exactly as it would +// from a `fetch` written here. + +import { ApiError, basePath } from './client'; +import { narrow } from './contract'; +import type { Book, Problem } from './contract'; +import type { components } from './schema'; + +type Schemas = components['schemas']; + +/** The add-a-book form, as a screen fills it in. */ +export interface BookIntake { + file: File; + sourceLang: string; + targetLang: string; + /** Empty means "the parse will name it" — the contract's own reading of an absent title. */ + title?: string; + genre?: string; +} + +export interface UploadProgress { + /** Bytes already handed to the network. */ + sent: number; + /** Bytes in the whole body, or `null` while the browser has not said. */ + total: number | null; +} + +interface Options { + /** + * Called as the body goes out. MAY NEVER BE CALLED, and that is not a defect to be worked + * around: the browser reports the progress of a request it sends itself, and a request answered + * by a service worker it does not (measured — under the mock network Chromium fires `loadstart` + * with the right total and then nothing at all). A screen therefore has to have a state for "it + * is going, how far is not known", and must not draw a share out of a zero. + */ + onProgress?: (progress: UploadProgress) => void; + signal?: AbortSignal; +} + +/** + * `POST /books`. + * + * ⚠ The ORDER of the parts is the contract (0.2.3, PD-172): `file` goes LAST, and every text field + * before it. The platform reads the form as a stream and writes the book's row — the thing that + * makes an upload visible while it arrives — before the body of the file; a field that arrives + * after the file is not read at all and the call is answered `400`. `FormData` keeps the order it + * was appended in, so this function is the one place that has to get it right. + */ +export function uploadBook(intake: BookIntake, options: Options = {}): Promise { + const form = new FormData(); + const title = intake.title?.trim() ?? ''; + if (title !== '') form.append('title', title); + form.append('source_lang', intake.sourceLang); + form.append('target_lang', intake.targetLang); + if (intake.genre !== undefined && intake.genre.trim() !== '') + form.append('genre', intake.genre.trim()); + form.append('file', intake.file, intake.file.name); + + return new Promise((resolve, reject) => { + if (options.signal?.aborted === true) { + reject(new UploadAborted()); + return; + } + const request = new XMLHttpRequest(); + request.open('POST', `${basePath}/books`); + request.setRequestHeader('Accept', 'application/json'); + // The client half of the CSRF rule, as on every unsafe call. `Content-Type` is deliberately NOT + // set: only the browser knows the multipart boundary it is about to generate. + request.setRequestHeader('X-TM-Client', 'web'); + + request.upload.addEventListener('progress', (event) => { + options.onProgress?.({ + sent: event.loaded, + // `lengthComputable` is false while the browser does not know the size — a chunked body, + // or an interceptor standing in for the network. A percentage invented out of a zero total + // would be a number that means nothing. + total: event.lengthComputable ? event.total : null, + }); + }); + + request.addEventListener('load', () => { + if (request.status < 200 || request.status >= 300) { + reject(new ApiError(request.status, problemOf(request), 'Request refused')); + return; + } + try { + resolve(narrow.book(JSON.parse(request.responseText) as Schemas['Book'])); + } catch { + // A 2xx whose body is not the book: the call succeeded and the answer is unusable, which is + // a refusal from where the screen stands. + reject(new ApiError(request.status, null, 'Request refused')); + } + }); + // A body that never left, a connection that dropped: the platform was not reached, which is a + // different thing from a refusal and the screen says so differently (status 0, as in client.ts). + request.addEventListener('error', () => { + reject(new ApiError(0, null, 'Server unreachable')); + }); + request.addEventListener('abort', () => { + reject(new UploadAborted()); + }); + + options.signal?.addEventListener( + 'abort', + () => { + request.abort(); + }, + { once: true }, + ); + request.send(form); + }); +} + +/** The user cancelled the sending. Not a failure of anything, so not an `ApiError`. */ +export class UploadAborted extends Error { + constructor() { + super('Upload aborted'); + this.name = 'UploadAborted'; + } +} + +// The same rule as on the `fetch` path: a refusal carries an RFC 9457 body whose phrases are +// already product language, and anything else is read as "no problem body". +function problemOf(request: XMLHttpRequest): Problem | null { + if (!request.getResponseHeader('Content-Type')?.includes('problem+json')) return null; + try { + return JSON.parse(request.responseText) as Problem; + } catch { + return null; + } +} diff --git a/frontend/src/api/vocabulary.ts b/frontend/src/api/vocabulary.ts index 4d7f8000..bf13c450 100644 --- a/frontend/src/api/vocabulary.ts +++ b/frontend/src/api/vocabulary.ts @@ -50,26 +50,40 @@ function vocabulary( * by shape and tone alone — a probe put three pairs on the same pixels — so the word carries the * meaning and colour only doubles it, for attention and refusal. */ -export const bookStatus = vocabulary( +export const bookStatus = vocabulary< + Schemas['BookStatus'], + { label: MessageKey; tone?: Tone; startable?: boolean; intake?: boolean } +>( { - uploading: { label: 'status.uploading', tone: 'note' }, - parsing: { label: 'status.parsing', tone: 'note' }, - not_started: { label: 'status.notStarted' }, + // `intake` — the book is still ARRIVING: it has no run, therefore no event stream, therefore + // no way of announcing that it is over. The reads poll while it lasts (src/api/queries.ts). + uploading: { label: 'status.uploading', tone: 'note', intake: true }, + parsing: { label: 'status.parsing', tone: 'note', intake: true }, + not_started: { label: 'status.notStarted', startable: true }, translating: { label: 'status.translating', tone: 'note' }, awaiting_bank: { label: 'status.awaitingBank', tone: 'warn' }, finalizing: { label: 'status.finalizing', tone: 'note' }, - ready: { label: 'status.ready', tone: 'ok' }, + ready: { label: 'status.ready', tone: 'ok', startable: true }, // Resumable stop, so no danger tone: the screen must not read as a failure (D39.100 K-8). // ⚠ The wording is the OWNER'S and is not ours to shorten (В-6). S3.5 did shorten it to fit // the badge slot and had to put it back: the badge truncates with a tooltip instead, and // which word belongs in the tree is a question for the owner, not a layout decision. - paused: { label: 'status.paused', tone: 'warn' }, - stopped: { label: 'status.stopped' }, + // + // Startable, and that is not the same as resumable: a run stopped by a ceiling is not moved by + // `resume` at all (contract §resumeRun), while a NEW run carries a new ceiling of its own — + // which is the whole reason the ceiling travels with the start. + paused: { label: 'status.paused', tone: 'warn', startable: true }, + stopped: { label: 'status.stopped', startable: true }, rejected: { label: 'status.rejected', tone: 'danger' }, - failed: { label: 'status.failed', tone: 'danger' }, + failed: { label: 'status.failed', tone: 'danger', startable: true }, }, // Neutral on purpose: a state from a future contract is not an error, and calling it one would // put an alarm on a screen that the product forbids to look alarming. + // + // ⚠ NOT startable, and the direction is picked rather than defaulted: a minor bump adds states, + // and the states a pipeline gains are stages of a run in flight — exactly where starting a second + // one is refused. An action offered for a state this build cannot read would be a promise made on + // a guess; the state itself is still named on the card. { label: 'status.unknown' }, ); @@ -84,6 +98,40 @@ export const pausedReason = vocabulary( + { + source_unreadable: { + label: 'rejected.sourceUnreadable', + advice: 'rejected.sourceUnreadableAdvice', + }, + not_configured: { label: 'rejected.notConfigured', advice: 'rejected.notConfiguredAdvice' }, + parser_unavailable: { + label: 'rejected.parserUnavailable', + advice: 'rejected.parserUnavailableAdvice', + }, + }, + // A reason this build does not know — and an absent one, which the contract says to render the + // same way — claims nothing about whose fault it is. It must not send the reader to check a file + // that may be perfectly good, nor promise that waiting will help. + { label: 'rejected.unknown', advice: 'rejected.unknownAdvice' }, +); + /** * Severity step of a note → look of the callout. * diff --git a/frontend/src/i18n/catalogue.test.ts b/frontend/src/i18n/catalogue.test.ts index c8094920..81edf450 100644 --- a/frontend/src/i18n/catalogue.test.ts +++ b/frontend/src/i18n/catalogue.test.ts @@ -51,10 +51,14 @@ const roots = [ 'vite.config.ts', 'index.html', 'tsconfig.json', + 'package.json', '.npmrc', '.gitignore', '.spectral.yaml', '.prettierignore', + // Extensionless, and therefore invisible to the sweep by extension above — while it is a script + // of this zone like any other (acceptance of S3.7, minor). + 'scripts/githooks/pre-commit', ]; const files = [...sources('src'), ...sources('scripts'), ...roots] @@ -95,7 +99,11 @@ test('a place left without a variable stays visible rather than blank', () => { * rendering every screen, and a gate that needs a browser is a gate nobody runs. */ test('every call passes exactly the variables its line has places for', () => { - const calls = /\btext\(\s*'([\w.]+)'\s*,\s*\{([^{}]*)\}/g; + // ANY call of the shape `f('a.key', { … })`, not only one made through a local variable named + // `text`: the rule is about the KEY and its places, and a gate that hung on the caller's name + // stopped seeing a screen that named it anything else (acceptance of S3.7, minor). A false match + // is impossible by construction — a key that is not in the catalogue is skipped below. + const calls = /\b\w+\(\s*'([\w.]+)'\s*,\s*\{([^{}]*)\}/g; const seen: string[] = []; for (const path of files) { @@ -132,7 +140,7 @@ test('every line with a place is called with variables somewhere', () => { for (const path of files) { if (!['.ts', '.tsx'].includes(extname(path))) continue; const source = readFileSync(join(root, path), 'utf8'); - for (const [, key] of source.matchAll(/\btext\(\s*'([\w.]+)'\s*,\s*\{/g)) + for (const [, key] of source.matchAll(/\b\w+\(\s*'([\w.]+)'\s*,\s*\{/g)) filled.add(key as string); } diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index 29180e57..ddccb4f0 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -1,5 +1,6 @@ // THE catalogue of Russian interface strings. The only file in `src/` where interface wording -// lives, and the only one the Cyrillic gate lets through (eslint.config.js, `src/i18n.test.ts`). +// lives, and one of the two the Cyrillic gate lets through (eslint.config.js, and the sweep in +// `src/i18n/catalogue.test.ts` — the other one is the fixtures). // // What belongs here: every word the user reads — labels, hints, empty states, accessible names, // tooltips. What does NOT: the prose of the fixtures (`src/mock/`), which is DATA standing in for a @@ -13,6 +14,12 @@ // translation is invented here. export const ru = { + 'action.cancel': 'Отмена', + // The window is closed by the cross in its corner; this is the word for "I have read it, we are + // done" — a different act, and a different button. + 'action.done': 'Готово', + + 'shell.appName': 'TextMachine', 'shell.collapseLeft': 'Свернуть левую панель', 'shell.collapseRight': 'Свернуть правую панель', 'shell.documentsPanel': 'Открытые документы', @@ -24,6 +31,7 @@ export const ru = { 'library.addBook': 'Добавить книгу', 'library.booksTab': 'Книги', 'library.chapterProgress': 'переведено {done} из {total}', + 'library.chapterRange': 'разделы {from}–{to}', 'library.chaptersFailed': 'разделы не загрузились', 'library.chaptersLoading': 'загрузка разделов…', 'library.emptyCentreDescription': 'Добавьте книгу — её разделы откроются здесь вкладками.', @@ -104,6 +112,9 @@ export const ru = { 'about.noBooksTitle': 'Книг пока нет', 'about.notes': 'Замечаний', 'about.panel': 'О читаемой книге', + 'about.parsing': + 'Книга разбирается на разделы. Они появятся в дереве слева, когда разбор дойдёт до них.', + 'about.uploading': 'Файл ещё загружается. Разбор начнётся, когда он дойдёт целиком.', 'about.runFinished': 'Прогон завершён', 'about.runStarted': 'Прогон начат', 'about.status': 'Состояние', @@ -123,19 +134,70 @@ export const ru = { 'goto.title': 'Перейти', 'goto.truncated': 'Показаны первые {count} совпадений.', - 'addBook.cancel': 'Отмена', + 'addBook.acceptedDescription': 'Файл принят целиком. Разбор на разделы идёт сам.', + 'addBook.acceptedTitle': 'Книга загружена', 'addBook.chooseFile': 'Выбрать файл', 'addBook.fileLabel': 'Файл книги', - 'addBook.settingsLabel': 'Настройки книги', - 'addBook.settingsSlot': 'Пара языков, жанр и параметры запуска появятся здесь.', + 'addBook.genreLabel': 'Жанр', + 'addBook.genrePlaceholder': 'не обязательно', + 'addBook.sending': 'Файл отправляется', + 'addBook.sendingSize': 'отправлено {sent} из {total}', + 'addBook.sendingUnknown': 'Файл отправляется: {total}', + 'addBook.sent': 'Файл отправлен целиком — ждём ответа сервиса.', + 'addBook.sourceLangLabel': 'Язык оригинала', 'addBook.submit': 'Добавить', + 'addBook.targetLangLabel': 'Язык перевода', 'addBook.title': 'Добавить книгу', - 'addBook.titleHintAuto': - 'Поле пустое: название определит разбор файла — его можно будет поправить позже.', + // ⚠ Без «поправить позже»: переименовать книгу нечем — на всей поверхности контракта у книги + // нет ни одной записи (Ф-62), и обещать правку, которой нет, интерфейс не вправе. + 'addBook.titleHintAuto': 'Поле пустое: название определит разбор файла.', 'addBook.titleHintManual': 'Название задано вручную: разбор его не перепишет.', 'addBook.titleLabel': 'Название', 'addBook.titlePlaceholder': 'по умолчанию — из файла', + 'upload.badRequest': 'Форма не дошла целиком', + // Отдельный совет от общего «повторите»: если форма не дошла второй раз, повтор третий раз + // ничего не изменит — и честно сказать, что это уже наша сторона. + 'upload.badRequestAdvice': 'Попробуйте ещё раз. Если повторится — дело не в файле, а в нас.', + 'upload.failedTitle': 'Книга не загрузилась', + 'upload.notAccepted': 'Этот сервис не принимает книги', + 'upload.notAcceptedAdvice': 'Файл здесь ни при чём — загрузка недоступна на этом сервисе.', + 'upload.refused': 'Сервис не принял файл', + 'upload.refusedAdvice': 'Попробуйте ещё раз позже.', + 'upload.retryAdvice': 'Попробуйте отправить файл ещё раз.', + 'upload.timeout': 'Файл не успел дойти', + 'upload.tooLarge': 'Файл больше, чем сервис принимает', + 'upload.tooLargeAdvice': 'Выберите файл поменьше.', + + 'rejected.notConfigured': 'Сервис пока не готов принять эту книгу', + 'rejected.notConfiguredAdvice': + 'Файл здесь ни при чём: повторная загрузка поможет не раньше, чем мы это наладим.', + 'rejected.parserUnavailable': 'Не удалось обработать файл на нашей стороне', + 'rejected.parserUnavailableAdvice': 'Файл здесь ни при чём: попробуйте добавить книгу позже.', + 'rejected.sourceUnreadable': 'Файл не удалось разобрать на разделы', + 'rejected.sourceUnreadableAdvice': + 'Проверьте файл — возможно, это не текст книги — и добавьте её заново.', + 'rejected.unknown': 'Книгу не удалось принять', + 'rejected.unknownAdvice': 'Причина не названа. Попробуйте добавить книгу заново или позже.', + + 'run.action': 'Перевести', + 'run.ceilingHint': 'Прогон остановится, дойдя до этого числа разделов.', + // Не называет причину: 409 приходит и на уехавший потолок, и на прогон, который уже идёт, и на + // книгу, которая ещё не готова — платформа называет причину сама, а это совет на все случаи. + 'run.conflict': 'Запустить не удалось: состояние книги изменилось', + 'run.conflictAdvice': 'Форма и карточка книги обновлены — посмотрите их и попробуйте снова.', + // Не называет причину: ноль на шкале приходит и от исчерпанного остатка, и от книги, которая + // переведена целиком (контракт: максимум уже подрезан обоими). + 'run.exhaustedDescription': 'Сейчас доступно ноль разделов — начинать нечего.', + 'run.exhaustedTitle': 'Запустить прогон сейчас нельзя', + 'run.failedTitle': 'Прогон не начался', + 'run.title': 'Перевести книгу', + 'run.unavailable': 'Перевод сейчас запустить нельзя', + 'run.unavailableAdvice': 'Попробуйте позже.', + 'run.verifyBank': 'Остановиться на подписи банка', + 'run.verifyBankHint': 'Перед финальным проходом прогон остановится, чтобы вы подписали термины.', + 'run.waiting': 'Параметры запуска', + 'settings.defaultsAbout': 'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.', 'settings.defaultsName': 'Перевод по умолчанию', diff --git a/frontend/src/mock/book.ts b/frontend/src/mock/book.ts index 20d3db68..0642415d 100644 --- a/frontend/src/mock/book.ts +++ b/frontend/src/mock/book.ts @@ -64,7 +64,9 @@ export const books: Schemas['Book'][] = [ other('bk_6', 'Записки о лисах и духах', 'not_started'), other('bk_7', 'Небесный чиновник', 'paused'), other('bk_8', 'Сломанный меч', 'stopped'), - other('bk_9', 'Повреждённая выгрузка', 'rejected'), + // With a machine reason (0.2.3): a rejected book without one is legal too — that is what a + // deployment older than this minor answers — and the intake world carries that case. + other('bk_9', 'Повреждённая выгрузка', 'rejected', { reject_reason: 'source_unreadable' }), other('bk_10', 'Оборванный свиток', 'failed'), other('bk_11', 'Свежая загрузка', 'uploading'), ]; diff --git a/frontend/src/mock/events.ts b/frontend/src/mock/events.ts index 61697791..63fd9422 100644 --- a/frontend/src/mock/events.ts +++ b/frontend/src/mock/events.ts @@ -17,6 +17,16 @@ type Schemas = components['schemas']; const encoder = new TextEncoder(); +/** + * The version the fake platform announces in its handshake. + * + * ⚠ It has to be the SPEC's own, and nothing at run time can read the spec — so the copy is locked + * by a test instead (`src/api/contract.test.ts`). Bought by this pack: the number stood at 0.2.2 for + * a whole session after the spec moved on, and the client compares only the MAJOR, so nothing failed + * and nothing said a word. + */ +export const contractVersion = '0.2.3'; + function frame(id: number, event: string, data: unknown): Uint8Array { return encoder.encode(`id: ${String(id)}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } @@ -39,7 +49,7 @@ export function runEvents(scenario: Scenario, world: World) { async start(controller) { controller.enqueue( frame(live.revision, 'hello', { - contract: '0.2.2', + contract: contractVersion, run_id: String(params.runId), revision: live.revision, } satisfies Schemas['EventHello']), @@ -57,6 +67,29 @@ export function runEvents(scenario: Scenario, world: World) { }, } satisfies Schemas['EventProgress']), ); + // A chapter of its own on every other burst, where the world can move one: the tree draws + // progress per chapter, and a book whose own counter runs while every chapter stands at + // zero is two answers to one question. + const chapter = step % 2 === 1 ? world.advanceChapter?.() : null; + if (chapter) controller.enqueue(frame(live.revision, 'chapter', chapter)); + } + + // The end of the path: the run reaches its ceiling and halts. Two frames, in the order the + // contract puts them in — the fact of the halt, then the state it left behind — and the + // world is told FIRST, because a read the frames provoke must not answer with a run that + // is still going (the fixture's own invariant). + if (world.halt) { + await delay(400); + world.halt(); + controller.enqueue( + frame(live.revision, 'ceiling', { halted: true } satisfies Schemas['EventCeiling']), + ); + controller.enqueue( + frame(live.revision, 'status', { + status: 'paused', + paused_reason: 'credit_exhausted', + } satisfies Schemas['EventStatus']), + ); } // No close: a run outlives its bursts, and closing here would make the browser reconnect // and replay the script, which is not what a live run looks like. diff --git a/frontend/src/mock/handlers.ts b/frontend/src/mock/handlers.ts index 667e3ed4..c9660100 100644 --- a/frontend/src/mock/handlers.ts +++ b/frontend/src/mock/handlers.ts @@ -10,6 +10,7 @@ import { HttpResponse, delay, http } from 'msw'; import type { Scenario } from '../api'; import type { components } from '../api/schema'; import { runEvents } from './events'; +import { intakeHandler } from './intake'; import { bookId, worldOf, type World } from './worlds'; type Schemas = components['schemas']; @@ -79,7 +80,16 @@ export function handlersFor(scenario: Scenario) { ]; } - return [...reads(world), ...actions(world), runEvents(scenario, world)]; + return [ + // The intake stands FIRST: MSW takes the first handler that matches, and `POST /books` has to + // win over nothing else — the reads below answer `GET` only. A world that does not accept books + // does not mount it at all, which is exactly how the platform answers 404 on a deployment + // without an intake (contract 0.2.3, PD-174). + ...(world.intake === true ? [intakeHandler] : []), + ...reads(world), + ...actions(world), + runEvents(scenario, world), + ]; } function reads(world: World) { @@ -163,18 +173,16 @@ function actions(world: World) { if (missingHeader(request)) return problem(403, problemTitles.noClientHeader); const body = (await request.json()) as Schemas['RunRequest']; const ceiling = world.ceiling; - if ( - body.ceiling_chapters < ceiling.min_chapters || - body.ceiling_chapters > ceiling.max_chapters - ) { - return problem(409, problemTitles.ceilingTooHigh); - } - const started = runOf(String(params.bookId)); + // The world may have its own answer — bounds that moved between the read and this call, which + // is what the contract's 409 is for. Without one, the bounds it answered the read with decide. + const fits = + world.fits?.(body.ceiling_chapters) ?? + (body.ceiling_chapters >= ceiling.min_chapters && + body.ceiling_chapters <= ceiling.max_chapters); + if (!fits) return problem(409, problemTitles.ceilingTooHigh); + const started = world.startRun(String(params.bookId), body); if (!started) return problem(404, problemTitles.bookNotFound); - return json( - { ...started, ceiling_chapters: body.ceiling_chapters, verify_bank: body.verify_bank }, - { status: 202 }, - ); + return json(started, { status: 202 }); }), http.post('*/v0/runs/:runId/stop', ({ request }) => { diff --git a/frontend/src/mock/intake.ts b/frontend/src/mock/intake.ts new file mode 100644 index 00000000..d70089b3 --- /dev/null +++ b/frontend/src/mock/intake.ts @@ -0,0 +1,377 @@ +// The intake world: a library that starts EMPTY and fills up from the form. +// +// It is the one fixture that models a WRITE, and therefore the one that has to model the rules of +// the wire rather than only the shape of the answer: the order of the parts, their number, the +// length of a text field, the cap on the body. A client that broke one of them would otherwise be +// found out by the platform and not here (contract 0.2.3, PD-172/PD-180). +// +// ⚠ HOW A REFUSAL IS ASKED FOR. Everything the platform refuses for a reason of its own — no intake +// on this deployment, a body that missed the deadline, a source it could not read — is chosen by a +// MARKER IN THE FILE NAME (the table below). It is the fixture's own switch, the same job the route +// does for the choice of the world, and it exists because those branches have no other trigger: a +// correct client cannot produce them, so without it the screens for them would be checked by +// nothing. Product code knows nothing about any of this. + +import { HttpResponse, delay, http } from 'msw'; + +import type { components } from '../api/schema'; +import { begin, live } from './live'; + +type Schemas = components['schemas']; + +/** The wire rules of the intake, in the platform's own numbers (httpapi/v0.go). */ +export const intakeLimits = { + maxParts: 16, + maxFieldBytes: 1024, + /** Small on purpose: a scene has to be able to exceed it without carrying tens of megabytes. */ + maxBodyBytes: 2_000_000, +}; + +/** How long a book stays `parsing` before the fixture answers what came of it. */ +export const parseMs = 1200; + +/** + * How long the answer to the intake is held back. + * + * A book is minutes of upload on a domestic connection, and the state that waits for the answer is + * a state the screen has to have — held for a moment here so that it can be photographed and + * checked at all. Everything before it is the browser's own doing: the mock network answers the + * request only after the body has been read. + */ +const answerMs = 600; + +/** + * What a marker in the file name asks the fixture to answer. `null` means "accept and parse". + * + * The refusals are the classes the contract names for this call; the reasons are the platform's + * closed vocabulary for a book that was accepted and then could not be turned into chapters. + */ +const outcomes = [ + // ⚠ The phrases are deliberately NOT the client's own fallbacks word for word: the client shows + // the server's phrase and keeps its own only for a refusal that carried no body, and two equal + // strings would make that impossible to tell apart — the scene checking it went blind on exactly + // the one that matched (found by the adversarial review). + { marker: 'refuse-400', status: 400, title: 'Загрузка не дошла целиком' }, + { marker: 'refuse-404', status: 404, title: 'Этот сервис книги не принимает' }, + { marker: 'refuse-408', status: 408, title: 'Файл не дошёл за отведённое время' }, + { marker: 'reject-source', reject: 'source_unreadable' }, + { marker: 'reject-config', reject: 'not_configured' }, + { marker: 'reject-parser', reject: 'parser_unavailable' }, +] as const; + +interface Accepted { + book: Schemas['Book']; + chapters: Schemas['Chapter'][]; + /** When the parse ends, in milliseconds of the clock. */ + parsedAt: number; + reject: Schemas['RejectReason'] | null; + run: Schemas['Run'] | null; +} + +const accepted: Accepted[] = []; + +/** Tests and scenes share one module instance; a world left half-filled would leak into the next. */ +export function clearIntake(): void { + accepted.length = 0; + ceilingMoved = false; +} + +/** Moves every unfinished parse into the past — for tests, which must not wait out a delay. */ +export function settleIntake(): void { + for (const row of accepted) row.parsedAt = 0; +} + +export function intakeBooks(): Schemas['Book'][] { + // NEWEST FIRST, as the platform answers (`order by b.added_at desc, b.id desc` — pgstore/books.go): + // a fixture that listed them the other way round would put the book just uploaded at the bottom of + // a library the screen reads from the top. + return accepted.map((row) => shown(row)).reverse(); +} + +export function intakeChapters(bookId: string): Schemas['Chapter'][] { + const row = accepted.find((item) => item.book.id === bookId); + // A book still being parsed has no chapters yet — and that is the honest answer, not an error: + // the tree shows the book with none until the parse ends. + return row && parsed(row) && row.reject === null ? row.chapters : []; +} + +export function intakeUnits(bookId: string, chapterId: string): Schemas['Unit'][] { + const chapter = intakeChapters(bookId).find((row) => row.id === chapterId); + if (!chapter) return []; + return Array.from({ length: chapter.units_total }, (_, index) => ({ + id: `${chapterId}_u${String(index)}`, + source: sources[index % sources.length] ?? '', + target: '', + state: 'pending' as const, + note: null, + })); +} + +export function intakeRun(bookId: string): Schemas['Run'] | null { + const run = accepted.find((row) => row.book.id === bookId)?.run; + // The revision is taken at READ time: frozen at the start it would fall behind the frames the + // stream is already emitting, which is the one thing the fixture must never do. + return run ? { ...run, revision: live.revision } : null; +} + +/** + * One chapter of the running book finishes. + * + * The tree draws progress PER CHAPTER, and without this the indicators stood at zero while the + * book's own counter ran to the end — the tree and the card contradicting each other on the same + * screen. Mutates the stored chapter so that a re-read agrees with the frame: a fixture whose read + * takes back what its stream just said is a server the contract forbids. + */ +export function advanceIntakeChapter(): Schemas['EventChapter'] | null { + const row = accepted.find((item) => item.run !== null); + const chapter = row?.chapters.find((item) => item.units_done < item.units_total); + if (!chapter) return null; + chapter.units_done = chapter.units_total; + live.revision += 1; + return { + chapter_id: chapter.id, + units_done: chapter.units_done, + note_count: chapter.note_count, + }; +} + +/** + * The bounds of the ceiling scale, and they MOVE — once, on the first start. + * + * The contract says exactly this happens (§startRun): the maximum belongs to the account, a hold + * taken for another book lowers it between the read and the call, and the answer is 409. It is the + * one refusal a correct client can meet on a correct form, so the fixture produces it the way the + * platform does rather than by a marker — and it produces it ONCE, so the second press goes through + * and the path stays walkable. + */ +export const intakeCeiling = { min_chapters: 1, max_chapters: 8, default_chapters: 4 }; +let ceilingMoved = false; + +/** The bounds a read answers with. Narrower after the first start attempt was refused. */ +export function intakeBounds(): Schemas['CeilingBounds'] { + return ceilingMoved ? { ...intakeCeiling, max_chapters: 2, default_chapters: 2 } : intakeCeiling; +} + +/** The refusal a start meets when the ceiling asked for no longer fits. */ +export function intakeCeilingFits(chapters: number): boolean { + if (!ceilingMoved) { + ceilingMoved = true; + return false; + } + return chapters <= intakeBounds().max_chapters; +} + +/** + * Starting a run over an uploaded book. The world's own, because here the run does not exist until + * it is started — in the hand-written worlds it is a fixture that was always there. + */ +export function startIntakeRun( + bookId: string, + request: Schemas['RunRequest'], +): Schemas['Run'] | null { + const row = accepted.find((item) => item.book.id === bookId); + if (!row || !parsed(row) || row.reject !== null) return null; + begin(row.book.progress.draft.total); + row.run = { + id: `run_${row.book.id}`, + revision: live.revision, + status: 'translating', + verify_bank: request.verify_bank, + ceiling_chapters: request.ceiling_chapters, + paused_reason: null, + started_at: new Date().toISOString(), + finished_at: null, + }; + return row.run; +} + +/** + * The run walks into its ceiling and halts. + * + * ⚠ Built to the CONTRACT and not to the live platform: a ceiling stop is `paused` with a machine + * reason, never `failed` (§BookStatus). On the stand today it arrives as `failed` — the consumer + * half of that seam is not built (platform PD-113) — and a fixture bent to match it would teach the + * screens the wrong shape. Which ceiling was hit, the run's own or the account's, this frame does + * not say: whether the two need separate reasons is the owner's question (K-13), so the fixture + * uses the single value the contract has. + */ +export function haltIntakeRun(): void { + const row = accepted.find((item) => item.run !== null); + if (!row?.run) return; + live.revision += 3; + row.run = { ...row.run, status: 'paused', paused_reason: 'credit_exhausted' }; +} + +/** `POST /books` — the whole of it, rules included. */ +export const intakeHandler = http.post('*/v0/books', async ({ request }) => { + if (request.headers.get('X-TM-Client') === null) { + return problem(403, 'Запрос без заголовка клиента'); + } + const form = await request.formData(); + const parts = [...form.entries()]; + if (parts.length > intakeLimits.maxParts) return problem(400, 'В форме слишком много частей'); + // ⚠ The platform reads the form as a STREAM and STOPS at the file (httpapi/v0.go): everything + // after it is never read. So the rule is modelled as the platform's own — parts before the file + // are taken, parts after it are dropped on the floor — and not as "the file must be last, else + // 400". The difference is not academic: a form with a trailing `genre` is ACCEPTED by the + // platform, minus the genre, and a fixture that refused it would refuse a legal request (found by + // the adversarial review of the assignment itself). + const fileAt = parts.findIndex(([name, value]) => name === 'file' && value instanceof File); + if (fileAt < 0) return problem(400, 'Форма пришла без файла'); + const read = parts.slice(0, fileAt); + const last = parts[fileAt]; + if (!last || !(last[1] instanceof File)) return problem(400, 'Форма пришла без файла'); + // In BYTES, which is what the platform measures (`io.LimitReader` over the part): a Cyrillic + // character is two bytes and a hanzi three, so counting string units would let through a field + // three times over the limit. + const bytesOf = new TextEncoder(); + for (const [name, value] of read) { + if (typeof value === 'string' && bytesOf.encode(value).length > intakeLimits.maxFieldBytes) { + return problem(400, `Поле «${name}» длиннее килобайта`); + } + } + const file = last[1]; + if (file.size > intakeLimits.maxBodyBytes) { + return problem(413, 'Файл больше, чем принимает сервис'); + } + + const outcome = outcomes.find((item) => file.name.includes(item.marker)); + if (outcome && 'status' in outcome) return problem(outcome.status, outcome.title); + + await delay(answerMs); + // Only what was READ counts — a language sent after the file is a language the platform never + // saw, and it answers that with the same 400 as one never sent. + const fields = new Map( + read.filter(([, value]) => typeof value === 'string') as [string, string][], + ); + const source = fields.get('source_lang') ?? ''; + const target = fields.get('target_lang') ?? ''; + if (source === '' || target === '') return problem(400, 'Языки не заданы'); + + // CHARACTERS and not bytes: the platform counts UTF-8 lead bytes as it streams + // (books.go `counter.Write`), which is a count of code points — and on CJK that is three times + // fewer than bytes. The cap above is the other quantity and stays in bytes, as it is on the wire. + const characters = [...(await file.text())].length; + const book = create({ + // The title of the intake, or the name of the file when the form left it empty — which is what + // the platform does today (contract 0.2.3, `BookIntake.title`). + title: (fields.get('title') ?? '') || file.name.replace(/\.[^.]+$/, ''), + source, + target, + genre: fields.get('genre') ?? '', + characters, + reject: outcome?.reject ?? null, + }); + // The `201` carries `parsing` and never `uploading` (PD-180): by the time an answer can be read, + // the last byte is in. `uploading` is real and is seen from a SECOND read of the library while + // the body is still on the wire — which is the mock network's own doing, not this handler's. + return HttpResponse.json(shown(book), { status: 201 }); +}); + +// Deliberately Russian: the platform words its own `problem+json`, and the client shows those +// phrases as they came (contract §Problem). A fixture that answered in English would be modelling +// a platform we do not have. +function problem(status: number, title: string): Response { + return HttpResponse.json({ type: 'about:blank', title, status } satisfies Schemas['Problem'], { + status, + headers: { 'Content-Type': 'application/problem+json' }, + }); +} + +function create(intake: { + title: string; + source: string; + target: string; + genre: string; + characters: number; + reject: Schemas['RejectReason'] | null; +}): Accepted { + const chapters = chaptersOf(accepted.length); + const row: Accepted = { + book: { + id: `bk_up${String(accepted.length + 1)}`, + title: intake.title, + source_lang: intake.source, + target_lang: intake.target, + genre: intake.genre, + chapter_count: chapters.length, + character_count: intake.characters, + added_at: new Date().toISOString(), + status: 'parsing', + // ZERO until a run starts, and it is the contract's shape: `Progress` counts the units of a + // RUN, so a book that has never been run has nothing to count. A fixture that filled the + // totals at intake made the card print "0 sections, 12 blocks" while the parse was still on. + progress: { draft: { done: 0, total: 0 }, edit: { done: 0, total: 0 } }, + note_count: 0, + }, + chapters, + parsedAt: Date.now() + parseMs, + reject: intake.reject, + run: null, + }; + accepted.push(row); + live.revision += 1; + return row; +} + +const parsed = (row: Accepted) => Date.now() >= row.parsedAt; + +/** + * The book as a read must answer it right now: parsing until the deadline, then either the tree it + * was cut into or the reason it could not be. + * + * ⚠ Nothing PUSHES this change: the event stream belongs to a run, and a book being parsed has no + * run at all. The client learns of it by asking again — which is why the reads of the library and + * of the card poll while a book is in intake (src/api/queries.ts, BACKLOG Ф-56). + */ +function shown(row: Accepted): Schemas['Book'] { + // ⚠ NO chapter count until the parse ends, and it is the platform's own shape: the number is + // written when the engine has cut the book, so a fixture that answered it earlier would teach the + // screen that a book being parsed already knows how many sections it has. + if (!parsed(row)) return { ...row.book, chapter_count: 0 }; + if (row.reject !== null) { + return { ...row.book, status: 'rejected', reject_reason: row.reject, chapter_count: 0 }; + } + if (row.run) { + return { + ...row.book, + status: row.run.status, + progress: { + draft: { done: live.draftDone, total: live.draftTotal }, + edit: { done: 0, total: live.draftTotal }, + eta_seconds: live.etaSeconds, + }, + }; + } + return { ...row.book, status: 'not_started' }; +} + +// The book a parse produces: labels of the same shape as the long-tail fixture, and a book without +// headings among them — a book legally has none (K-3), and the intake is where one arrives. +const headings = [ + 'Нет раскаяния', + 'Прозрение пятисот лет', + 'Церемония открытия', + 'Класс А', + 'Деревня Гуюэ', + 'Первый гу', + 'Аптека', + 'Кровь на снегу', +]; + +const sources = [ + '青茅山下,古月家的少年们排成一列,等待开窍。', + '风从北面吹来,带着雪的气味。', + '祠堂前的石阶上落满了霜。', +]; + +function chaptersOf(book: number): Schemas['Chapter'][] { + return Array.from({ length: headings.length }, (_, index) => ({ + id: `up${String(book)}_ch${String(index + 1)}`, + number: index + 1, + heading: index === headings.length - 1 ? null : (headings[index] ?? null), + units_total: 1 + (index % 2), + units_done: 0, + note_count: 0, + })); +} diff --git a/frontend/src/mock/live.ts b/frontend/src/mock/live.ts index bdb4e50d..da01ccde 100644 --- a/frontend/src/mock/live.ts +++ b/frontend/src/mock/live.ts @@ -5,19 +5,48 @@ import { book } from './book'; -const draftTotal = book.progress.draft.total; +const initialEta = book.progress.eta_seconds ?? null; + +/** + * Seconds per unit, taken from the hand-written book's own numbers: its estimate covers the units it + * has left. A book of another size gets an estimate of its own from this — a fixture that handed a + * twelve-unit book the two-and-a-half hours of a 4276-unit one was printing a number nothing in it + * supported (found by the adversarial review). + */ +const secondsPerUnit = + (initialEta ?? 0) / Math.max(1, book.progress.draft.total - book.progress.draft.done); export const live = { revision: 1841, draftDone: book.progress.draft.done, - etaSeconds: book.progress.eta_seconds ?? null, + draftTotal: book.progress.draft.total, + etaSeconds: initialEta, + /** What the estimate was when the run began; the one below is derived from it and the progress. */ + etaAtStart: initialEta, }; +/** + * A run that starts from nothing — a book just uploaded and just parsed. The counters belong to the + * run and not to the fixture that was written by hand, so a world where the book arrives from the + * form has to be able to set them. + */ +export function begin(draftTotal: number): void { + live.draftDone = 0; + live.draftTotal = draftTotal; + live.etaAtStart = Math.round(draftTotal * secondsPerUnit); + live.etaSeconds = live.etaAtStart; +} + /** One burst of the run. Counters JUMP: the contract lets the server coalesce frames. */ export function advance(step: number): void { live.revision += 3; - live.draftDone = Math.min(draftTotal, live.draftDone + 7 + step * 4); - live.etaSeconds = Math.max(0, 9240 - step * 900); + live.draftDone = Math.min(live.draftTotal, live.draftDone + 7 + step * 4); + // Derived from the progress rather than counted down by a constant: an estimate that falls as the + // work is done is what the field means, and it then fits a book of any size. + live.etaSeconds = + live.etaAtStart === null + ? null + : Math.max(0, Math.round(live.etaAtStart * (1 - live.draftDone / live.draftTotal))); } /** The book as it stands right now — what a read must answer while the run is going. */ @@ -25,7 +54,7 @@ export function liveBook() { return { ...book, progress: { - draft: { done: live.draftDone, total: draftTotal }, + draft: { done: live.draftDone, total: live.draftTotal }, edit: book.progress.edit, eta_seconds: live.etaSeconds, }, @@ -36,5 +65,7 @@ export function liveBook() { export function rewind(): void { live.revision = 1841; live.draftDone = book.progress.draft.done; - live.etaSeconds = book.progress.eta_seconds ?? null; + live.draftTotal = book.progress.draft.total; + live.etaSeconds = initialEta; + live.etaAtStart = initialEta; } diff --git a/frontend/src/mock/worlds.ts b/frontend/src/mock/worlds.ts index 55e950c9..76b66194 100644 --- a/frontend/src/mock/worlds.ts +++ b/frontend/src/mock/worlds.ts @@ -5,6 +5,7 @@ // showcase — the hard case: mid draft wave, a half-decided bank, chapters without labels // scale — the long tail: 2284 chapters and 1200 terms, served in pages // empty — a user with no books at all +// intake — the first path of the product: upload → parsing → a book that can be translated // loading — reads that never answer, so the waiting branch is the final picture // error — the platform refuses, so the error branch is the final picture // offline — reads work, the live stream does not @@ -13,6 +14,17 @@ import type { Scenario } from '../api'; import type { components } from '../api/schema'; import { bank } from './bank'; import { bookId, books, chapters, notes, run, unitsOf } from './book'; +import { + advanceIntakeChapter, + haltIntakeRun, + intakeBooks, + intakeBounds, + intakeCeilingFits, + intakeChapters, + intakeRun, + intakeUnits, + startIntakeRun, +} from './intake'; import { live, liveBook } from './live'; import { scaleBook, scaleChapters, scaleNotes, scaleTerms, scaleUnitsOf } from './scale'; @@ -22,6 +34,12 @@ export interface World { readonly revision: number; readonly books: Schemas['Book'][]; runOf: (id: string) => Schemas['Run'] | null; + /** + * Starting a run. A world where the book comes from the FORM has no run written in advance, so + * what "start" means belongs to the world; the hand-written ones hand back the run they always + * had, with the ceiling and the stop the request asked for. + */ + startRun: (id: string, request: Schemas['RunRequest']) => Schemas['Run'] | null; chaptersOf: (id: string) => Schemas['Chapter'][]; unitsOf: (bookId: string, chapterId: string) => Schemas['Unit'][]; notesOf: (id: string) => Schemas['Note'][]; @@ -30,8 +48,52 @@ export interface World { ceiling: Schemas['CeilingBounds']; /** Rows per page. The scale world pages for real, so following the cursor is exercised live. */ pageSize: number; + /** + * Whether a ceiling asked for still fits. Its own answer per world, because the bounds a world + * answers a READ with may move before the START — which is the contract's 409 and the one refusal + * a correct client meets on a correct form. + */ + fits?: (chapters: number) => boolean; + /** The world accepts uploads. Only the intake one does; elsewhere `POST /books` is not mounted. */ + intake?: boolean; + /** + * The run halts on its ceiling at the end of the script. Present only where the whole path is + * played out — the reads have to agree with the stream, so the world is told before the frames + * announcing the halt are sent. + */ + halt?: () => void; + /** + * One chapter of the running book finishes, and the world moves with it. Present in the same + * world and for the same reason: the tree's per-chapter indicators have to be fed by something, + * and what feeds them must also be what a re-read answers. + */ + advanceChapter?: () => Schemas['EventChapter'] | null; } +/** + * Starting a run in a hand-written world. + * + * ⚠ A run is MINTED for the book that was asked about, and not taken out of the fixture: on the + * platform a start creates a run, so a fixture that answered only for the one book it had written + * down refused every other with "book not found" — which is what the new action on the card ran + * into on four of the five startable books (found by the adversarial review of this pack). The + * book's own run, where the fixture has one, gives the id and the revision; everything else comes + * from the request, because that is what a start is. + */ +const startExisting = + (world: Pick) => (id: string, request: Schemas['RunRequest']) => { + const existing = world.runOf(id); + return { + ...(existing ?? run), + id: existing?.id ?? `run_${id}`, + status: 'translating' as const, + paused_reason: null, + finished_at: null, + ceiling_chapters: request.ceiling_chapters, + verify_bank: request.verify_bank, + }; + }; + const pausedRun: Schemas['Run'] = { ...run, id: 'run_38', @@ -52,6 +114,7 @@ const defaultWorld: World = { }, runOf: (id) => id === bookId ? { ...run, revision: live.revision } : id === 'bk_7' ? pausedRun : null, + startRun: (id, request) => startExisting(defaultWorld)(id, request), chaptersOf: (id) => (id === bookId ? chapters : []), unitsOf: (id, chapterId) => (id === bookId ? unitsOf(chapterId) : []), notesOf: (id) => (id === bookId ? notes : []), @@ -73,6 +136,7 @@ const scaleWorld: World = { revision: 4102, books: [scaleBook], runOf: () => ({ ...run, id: 'run_scale' }), + startRun: (id, request) => startExisting(scaleWorld)(id, request), chaptersOf: () => scaleChapters, unitsOf: (_, chapterId) => scaleUnitsOf(chapterId), notesOf: () => scaleNotes, @@ -88,6 +152,7 @@ const emptyWorld: World = { revision: 1, books: [], runOf: () => null, + startRun: () => null, chaptersOf: () => [], unitsOf: () => [], notesOf: () => [], @@ -97,10 +162,39 @@ const emptyWorld: World = { pageSize: 5000, }; +// The path the product begins with: an empty library, a book that arrives through the form, gets +// cut into chapters and is translated. The only world that accepts an upload. +const intakeWorld: World = { + get revision() { + return live.revision; + }, + get books() { + return intakeBooks(); + }, + runOf: (id) => intakeRun(id), + startRun: (id, request) => startIntakeRun(id, request), + chaptersOf: (id) => intakeChapters(id), + unitsOf: (id, chapterId) => intakeUnits(id, chapterId), + notesOf: () => [], + bankOf: () => ({ total: 0, signed: 0, terms: [] }), + usage: { state: 'ok', remaining_percent: 100, paused_reason: null }, + // A getter: these bounds MOVE, once, when the first start meets them — the contract's own 409 + // (a hold taken for another book lowers what is left between the read and the call). + get ceiling() { + return intakeBounds(); + }, + fits: intakeCeilingFits, + pageSize: 5000, + intake: true, + halt: haltIntakeRun, + advanceChapter: advanceIntakeChapter, +}; + const worlds: Record = { showcase: defaultWorld, scale: scaleWorld, empty: emptyWorld, + intake: intakeWorld, loading: defaultWorld, error: defaultWorld, offline: defaultWorld, diff --git a/frontend/src/showcase/About.module.css b/frontend/src/showcase/About.module.css index 8ece7bdb..e8cce82c 100644 --- a/frontend/src/showcase/About.module.css +++ b/frontend/src/showcase/About.module.css @@ -1,3 +1,14 @@ +/* The card is a column: what the book is DOING right now stands above the pairs, and the one + action of the card between them — a person looks for it where the state was just named. */ +.card { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: var(--space-4); + padding-top: var(--space-2); +} + /* A calm list of "field — value" pairs, not cards with statistics (prompt §3.9). */ .about { flex: 1; diff --git a/frontend/src/showcase/About.tsx b/frontend/src/showcase/About.tsx index d039c0df..28cdebec 100644 --- a/frontend/src/showcase/About.tsx +++ b/frontend/src/showcase/About.tsx @@ -1,6 +1,10 @@ -import type { BookDetail } from '../api'; -import { useText, type MessageKey } from '../i18n/text'; +import { pausedReason as pausedReasonWords, rejectReason as rejectReasonWords } from '../api'; +import type { Book, BookDetail, Run } from '../api'; +import { useText, type Text } from '../i18n/text'; +import { Button } from '../ui/Button'; +import { Callout } from '../ui/Callout'; import { counted, date, languageName, number, statusOf, translatedPercent } from './format'; +import { chapters } from './units'; import styles from './About.module.css'; /** @@ -8,20 +12,78 @@ import styles from './About.module.css'; * boundary is a defence of the approaches, not a gap: there is no money on the screen (§4.8), and * no models or pipeline stages either. */ -/** Plural forms of the chapter counter; Intl picks the category. */ -const chapters: Record = { - zero: 'about.chaptersZero', - one: 'about.chaptersOne', - two: 'about.chaptersTwo', - few: 'about.chaptersFew', - many: 'about.chaptersMany', - other: 'about.chaptersOther', -}; - -export function About({ detail }: { detail: BookDetail }) { +export function About({ detail, onStartRun }: { detail: BookDetail; onStartRun?: () => void }) { const text = useText(); const { book, run } = detail; - const fields: [string, string][] = [ + const startable = statusOf(book.status).startable === true; + + return ( +
+ + {/* The one action of the book card, and it stands where the card is: a book is translated + from the place that says what the book is. It is offered only where a run can begin — + which state that is, is decided by the vocabulary on the api seam, in one file. */} + {startable && onStartRun !== undefined && ( + + )} +
+ {fields(detail, text).map(([name, value]) => ( +
+
{name}
+
+ {value} +
+
+ ))} +
+
+ ); +} + +/** + * The states a book is IN rather than the fields it has: still arriving, being cut into sections, + * refused, halted. Each of them is a state the list of pairs below cannot say anything about, and + * each of them is what the person is waiting on. + * + * ⚠ Parsing carries NO number, and that is the contract's shape and not an omission: there is no + * counter of it anywhere on this surface, so a percentage would have to be invented (0.2.3). + */ +function State({ book, run }: { book: Book; run: Run | null }) { + const text = useText(); + + if (book.status === 'uploading') + return ; + if (book.status === 'parsing') return ; + if (book.status === 'rejected') { + // The machine reason is the platform's, the phrase and the advice are the product's — and the + // advice is the point: "the file is not one we can read" and "not us, not now" end in + // different next actions (0.2.3, PD-173). + const reason = rejectReasonWords.describe(book.reject_reason ?? null); + return ( + + {text(reason.advice)} + + ); + } + // A halt is stated where the action is, not only in the status strip: the strip says WHAT, the + // card is where the person looks for what to do about it. The phrase is the owner's (В-6), and + // resume is deliberately not offered — after a ceiling stop it does not move the run at all. + if (book.status === 'paused') { + return ( + + ); + } + return null; +} + +function fields(detail: BookDetail, text: Text): [string, string][] { + const { book, run } = detail; + const rows: [string, string][] = [ [text('about.title'), book.title], [ text('about.languages'), @@ -32,12 +94,17 @@ export function About({ detail }: { detail: BookDetail }) { [text('about.translated'), `${String(translatedPercent(book.progress))}%`], [text('about.chapters'), number(book.chapter_count)], [text('about.blocks'), number(book.progress.draft.total)], - [text('about.characters'), number(book.character_count ?? 0)], + // Optional on the wire, so an absent size is a dash rather than a zero — the same shape the + // dates use for "not known". + [ + text('about.characters'), + book.character_count === undefined ? '—' : number(book.character_count), + ], [text('about.notes'), number(book.note_count)], [text('about.added'), date(book.added_at)], ]; if (run) { - fields.push( + rows.push( [text('about.runStarted'), date(run.started_at)], // `finished_at` comes both as `null` and as altogether absent: an unfinished run may have no // field at all — both forms mean one and the same thing and are drawn as a dash. @@ -48,17 +115,5 @@ export function About({ detail }: { detail: BookDetail }) { [text('about.verifyBank'), run.verify_bank ? text('about.yes') : text('about.no')], ); } - - return ( -
- {fields.map(([name, value]) => ( -
-
{name}
-
- {value} -
-
- ))} -
- ); + return rows; } diff --git a/frontend/src/showcase/AddBook.module.css b/frontend/src/showcase/AddBook.module.css index 5b1ede4f..7d7d41b8 100644 --- a/frontend/src/showcase/AddBook.module.css +++ b/frontend/src/showcase/AddBook.module.css @@ -19,12 +19,73 @@ clip-path: inset(50%); } -.slots { +/* The pair of languages stands in one row: it is one decision — out of what, into what — and split + into two rows it reads as two unrelated fields. */ +.pair { + display: grid; + gap: var(--space-4); + grid-template-columns: 1fr 1fr; +} + +/* The live region is a container and nothing more: it takes no space of its own until there is + something in it, so an empty one leaves no gap in the form. */ +.live:empty { + display: none; +} + +.sending, +.failed, +.accepted { + display: grid; + gap: var(--space-4); border-top: 1px solid var(--color-border); padding-top: var(--space-5); } +.accepted { + border-top: 0; + padding-top: 0; +} + +.acceptedTitle { + margin: 0; + color: var(--color-text); + font-size: var(--font-size-ui); + font-weight: inherit; +} + +/* A refusal is a state of the form and not an alarm (§3.8): the phrase stands in the ordinary text + colour, and the tone of the state is carried by the book's own glyph in the tree — there is no + red panel here and no exclamation mark. */ +.failedTitle { + color: var(--color-text); +} + .slot { color: var(--color-text-secondary); font-size: var(--font-size-small); } + +.fields { + display: grid; + margin: 0; + gap: var(--space-2); +} + +.field { + display: grid; + gap: var(--space-4); + grid-template-columns: 1fr 2fr; +} + +.fieldName { + color: var(--color-text-secondary); + font-size: var(--font-size-small); +} + +.fieldValue { + overflow: hidden; + margin: 0; + color: var(--color-text); + text-overflow: ellipsis; +} diff --git a/frontend/src/showcase/AddBook.tsx b/frontend/src/showcase/AddBook.tsx index 08844153..5e7d4ded 100644 --- a/frontend/src/showcase/AddBook.tsx +++ b/frontend/src/showcase/AddBook.tsx @@ -1,30 +1,76 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { FilePlus2 } from 'lucide-react'; import { useRef, useState } from 'react'; -import { useText } from '../i18n/text'; +import { ApiError, UploadAborted, keys, uploadBook } from '../api'; +import type { Book, BookIntake, UploadProgress } from '../api'; +import { useText, type MessageKey, type Text } from '../i18n/text'; import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; +import { ProgressBar } from '../ui/ProgressBar'; +import { Select } from '../ui/Select'; import { TextField } from '../ui/TextField'; import { icon } from '../ui/icon'; +import { fileSize, languageName, number, statusOf } from './format'; +import { sourceLanguages, targetLanguages } from './languages'; import styles from './AddBook.module.css'; /** - * Adding a book — with a modal window after the model of antigravity_add_folder.png (remark 12). - * The two sources of the title are told apart: by hand or by parsing the file, and what will happen - * is said before the press. The sending itself is the work of the upload screen (Ф-26). + * Adding a book: the form, the sending, and what came of it. + * + * The four states are one screen and not four, because they are one act of the user's: the window + * that took the file is the window that says what happened to it. A refusal is a state of this + * form — not an alert over it (§3.8: a screen that looks alarming is a wrong screen). */ export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { const text = useText(); - const [file, setFile] = useState(null); - const [title, setTitle] = useState(''); + const client = useQueryClient(); const input = useRef(null); + const [file, setFile] = useState(null); + const [title, setTitle] = useState(''); + const [sourceLang, setSourceLang] = useState(sourceLanguages[0]); + const [targetLang, setTargetLang] = useState(targetLanguages[0]); + const [genre, setGenre] = useState(''); + // `null` means the browser has said nothing about the body going out — which is a state and not + // an absence of one (see `Sending`). + const [sent, setSent] = useState(null); + // Cancelling is the user's, so the handle to it belongs to the sending and not to the request: + // the screen has to be able to stop a body that has minutes left to go. + const abort = useRef(null); + + const upload = useMutation({ + mutationFn: (intake) => { + abort.current = new AbortController(); + setSent(null); + return uploadBook(intake, { + signal: abort.current.signal, + onProgress: setSent, + }); + }, + onSuccess: () => { + // The library is the only place the new book can be seen from, and it is read by a query that + // considers itself fresh for fifteen seconds. Nothing else would ask it again. + void client.invalidateQueries({ queryKey: keys.library() }); + }, + }); + const close = () => { + abort.current?.abort(); + upload.reset(); setFile(null); setTitle(''); + setGenre(''); + setSent(null); onClose(); }; + const send = () => { + if (file === null) return; + upload.mutate({ file, sourceLang, targetLang, title, genre }); + }; + + const accepted = upload.data; return ( v footer={ <> - + {accepted === undefined && ( + + )} } > -
-
-

{text('addBook.fileLabel')}

- {/* A real file picker, not a drawn one: the file name is needed for real — the automatic - parse takes the title out of it, and without a real name that branch cannot be - checked by a frame. */} - setFile(event.target.files?.[0]?.name ?? null)} + {accepted ? ( + // The same reason as the live region below: the form is gone and the answer is what + // replaced it, so it is announced rather than merely drawn. +
+ +
+ ) : ( +
+
+

{text('addBook.fileLabel')}

+ {/* A real file picker, not a drawn one: the file name is needed for real — the automatic + parse takes the title out of it, and without a real name that branch cannot be + checked by a frame. */} + { + setFile(event.target.files?.[0] ?? null); + upload.reset(); + // The input keeps its value, and an input whose value did not change fires nothing: + // picking the SAME file again after a refusal would then be silent, and the refusal + // would stay on the screen under a file the person had just re-chosen. + event.target.value = ''; + }} + /> + +
+ + - -
- +
+ ({ id: code, label: languageName(code) }))} + value={targetLang} + onChange={setTargetLang} + /> +
- {/* The place of the future per-book settings. Named, but not drawn with switches that do - not exist: a button without an action is forbidden (Ф-7). */} -
-

{text('addBook.settingsLabel')}

-

{text('addBook.settingsSlot')}

+ + + {/* ⚠ A live region, and it is PERMANENT rather than mounted with its content: what + happens after the button is pressed happens away from the focus — the sending, and + then either the answer or the refusal — and a reader that is not told is a reader + who thinks nothing happened (WCAG 4.1.3). A region inserted together with its text + is announced by some readers and not by others; one that was already there is + announced by all. */} +
+ {upload.isPending && } + {upload.isError && } +
-
+ )} ); } /** - * The automatic parse of the title out of the file name — that very second source signed in the - * form. + * How far the file has got — in three states, and the first of them is the one that is easy to get + * wrong. + * + * ⚠ **Nothing said yet.** The browser reports the progress of a request it sends itself; a request + * answered by a service worker it does not — measured under the mock network: `loadstart` arrives + * with the right total and not a single `progress` after it. There is then no share to draw, and a + * bar sitting at zero for the whole upload would be a number that means nothing, so what is shown + * is the size going out and no percentage at all. + * + * **Going.** A share, and it is the browser's own numbers; the size of the FILE stands in when the + * browser states no total of its own — the multipart envelope is tens of bytes against tens of + * megabytes. + * + * **All out.** The last byte leaving is not the answer: the service is still writing the book's row + * and cutting nothing yet. Without that line a bar frozen at a hundred per cent reads as a hang. + */ +function Sending({ file, sent }: { file: File | null; sent: UploadProgress | null }) { + const text = useText(); + const size = file?.size ?? 0; + if (sent === null) { + return ( +
+

{text('addBook.sendingUnknown', { total: fileSize(size) })}

+
+ ); + } + const total = sent.total ?? size; + const share = total === 0 ? 0 : Math.min(1, sent.sent / total); + return ( +
+ + {share >= 1 &&

{text('addBook.sent')}

} +
+ ); +} + +/** + * What came back, in the words of the contract and no others: the platform answers with the book + * card itself, so the confirmation is the same four fields the panel of the book shows — and + * `chapter_count` is deliberately not among them. It is zero until the parse ends, and printing a + * zero would answer "how many sections" with a lie. + */ +function Accepted({ book }: { book: Book }) { + const text = useText(); + const fields: [string, string][] = [ + [text('about.title'), book.title], + [ + text('about.languages'), + `${languageName(book.source_lang)} → ${languageName(book.target_lang)}`, + ], + // A dash and not a zero when the platform sent no size: the field is optional on the wire, and + // "0 characters" is a number the book does not have. + [ + text('about.characters'), + book.character_count === undefined ? '—' : number(book.character_count), + ], + [text('about.status'), text(statusOf(book.status).label)], + ]; + return ( +
+

{text('addBook.acceptedTitle')}

+
+ {fields.map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+

{text('addBook.acceptedDescription')}

+
+ ); +} + +/** + * A refusal of the intake, as a state of the form. + * + * The PHRASE is the platform's when it sent one: `problem+json` carries product language on both + * fields by contract, and a second copy of it here would be a second thing to keep in step. What is + * ours is the ADVICE — the next action, which the wire does not carry and which is different for + * every class: check the file, send it again, or wait. + */ +function Failed({ error }: { error: Error }) { + const text = useText(); + const [phrase, advice] = refusal(error, text); + return ( +
+

{phrase}

+

{text(advice)}

+
+ ); +} + +function refusal(error: Error, text: Text): [string, MessageKey] { + if (error instanceof UploadAborted) return [text('upload.failedTitle'), 'upload.retryAdvice']; + if (!(error instanceof ApiError)) return [text('loaded.unknownError'), 'upload.refusedAdvice']; + if (error.status === 0) return [text('loaded.serverUnreachable'), 'upload.retryAdvice']; + + const own: Partial> = { + 400: ['upload.badRequest', 'upload.badRequestAdvice'], + 404: ['upload.notAccepted', 'upload.notAcceptedAdvice'], + 408: ['upload.timeout', 'upload.retryAdvice'], + 413: ['upload.tooLarge', 'upload.tooLargeAdvice'], + }; + const [fallback, advice] = own[error.status] ?? ['upload.refused', 'upload.refusedAdvice']; + return [error.problem?.detail ?? error.problem?.title ?? text(fallback), advice]; +} + +/** + * What the book will be called if the field is left empty — that very second source signed in the + * form. A PREDICTION of the platform's own `titleFrom`, and it has to be an accurate one: the + * platform strips the extension and nothing else (books.go), so a client that also turned + * underscores into spaces showed «gu zhen ren» and got back `gu-zhen-ren` (found by the adversarial + * review, which checked it against the platform's own test). Deciding the title is not this + * function's business — saying what to expect is. */ function parsedTitle(file: string): string { - return file - .replace(/\.[^.]+$/, '') - .replace(/[_-]+/g, ' ') - .trim(); + return file.replace(/\.[^.]+$/, '').trim(); } diff --git a/frontend/src/showcase/Context.tsx b/frontend/src/showcase/Context.tsx index f7df5cb3..8867ec5e 100644 --- a/frontend/src/showcase/Context.tsx +++ b/frontend/src/showcase/Context.tsx @@ -19,6 +19,8 @@ interface Props { /** The palette's request to show a term. The search string is the bank's own to keep. */ request: { term: string; seq: number }; onOpenChapter: (chapterId: string) => void; + /** Opens the run form. Absent while there is no book to run one over. */ + onStartRun?: () => void; } /** @@ -36,6 +38,7 @@ export function Context({ onTabChange, request, onOpenChapter, + onStartRun, }: Props) { const text = useText(); // Telling the reader to pick a book from a panel that says "not a single book" is a screen @@ -59,7 +62,7 @@ export function Context({ label: text('about.tab'), content: ( - {(detail) => } + {(detail) => } ), }, diff --git a/frontend/src/showcase/Library.module.css b/frontend/src/showcase/Library.module.css index 5154f17e..49a1b970 100644 --- a/frontend/src/showcase/Library.module.css +++ b/frontend/src/showcase/Library.module.css @@ -95,6 +95,16 @@ color: var(--color-text-secondary); } +/* The state tone of a BOOK gives way on the selected row for the same reason and by the same rule: + on the blue ground the tones fall to 1.7:1 (danger) — unreadable as a shape, let alone as a + meaning. Nothing is lost by it, because the state is carried by the WORD in the badge and colour + is the second channel by design; a channel that cannot be read on this ground has nothing to + double. */ +[data-selected] .bookIcon, +[data-selected] .bookIcon[data-tone] { + color: var(--color-text-secondary); +} + [data-selected] .progress { background-color: var(--color-text-secondary); } @@ -103,3 +113,12 @@ [data-selected] .progressFill[data-done] { background-color: var(--color-text); } + +/* The badge of a BOOK on the same blue: 3.52:1 in the quiet grey against a text threshold of 4.5. + ⚠ This rule was written once before and removed in S3.7 as dead — the row of a book could not be + selected then, because the selection followed the open document. It can now (a click on a book + opens its state, the zone's §2), and the rule is alive: axe caught the badge on the very first + scene where a book is chosen. */ +[data-selected] .badge { + color: var(--color-text); +} diff --git a/frontend/src/showcase/Library.tsx b/frontend/src/showcase/Library.tsx index 5f716294..af889b40 100644 --- a/frontend/src/showcase/Library.tsx +++ b/frontend/src/showcase/Library.tsx @@ -1,5 +1,5 @@ import { BookIcon, FileText } from 'lucide-react'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import type { Book, Chapter, ChapterList, Library as LibraryData } from '../api'; import { useText, type MessageKey, type Text } from '../i18n/text'; @@ -105,6 +105,25 @@ function Books({ // The chapters of the open book only: a tree that fetched every book's chapters would read the // whole library to draw one row. const rows = useMemo(() => chapters.data?.chapters ?? [], [chapters.data]); + // `null` means "the user has not touched the branches yet" — not "everything is closed". The same + // idiom the tab row uses for its own first look. + const [expanded, setExpanded] = useState | null>(null); + const opened = expanded ?? new Set(openBookId === undefined ? [] : [openBookId]); + // Two branches open by themselves, and both only ON A CHANGE — added on every render they could + // never be folded again. A chapter chosen from OUTSIDE the tree (the go-to palette, a row of the + // notes summary) may sit inside a folded group, and a tree that answered a jump by showing + // nothing would be a tree that lost the row; and a book just chosen shows what is in it. + const [seen, setSeen] = useState({ selectedId, openBookId }); + if (seen.selectedId !== selectedId || seen.openBookId !== openBookId) { + setSeen({ selectedId, openBookId }); + // ⚠ The BOOK goes with the group, always: a jump from outside the tree into a folded book + // opened the group inside it and left the book shut, so the row it jumped to was still not on + // the screen (found by the adversarial review, reproduced by rendering). + const opening = [groupOf(rows, openBookId, selectedId), openBookId].filter( + (id): id is string => id !== undefined && id !== null && !opened.has(id), + ); + if (opening.length > 0) setExpanded(new Set([...opened, ...opening])); + } // A book whose chapters did not arrive must not be drawn as a book WITHOUT chapters: no chevron // reads as "this book has none", while the card next to it says 2284. The state goes where the // chapters would have been. @@ -123,16 +142,48 @@ function Books({ ), [books, rows, openBookId, state, text], ); + // ⚠ The ids of the folds, kept as a SET rather than recognised by their shape. A row's kind is + // decided by what this file MINTED, never by reading a server id: ids are opaque by contract, and + // the engine already builds keys of the very shape a pattern would have matched + // (`::`) — a chapter whose id ended that way would have answered a click by + // folding a branch that does not exist (found by the adversarial review). + const groups = useMemo( + () => + new Set( + items.flatMap((node) => + (node.children ?? []) + // A fold is a child that HAS children of its own. Below the threshold the tree is flat + // and a book's children are the chapters themselves — taking every child would have + // made every chapter a fold, and a click on one would have folded instead of opening + // (caught by the scene battery, not by reasoning). + .filter((child) => child.children !== undefined) + .map((child) => child.id), + ), + ), + [items], + ); return ( { + if (!groups.has(id)) { + onOpen(id); + return; + } + const next = new Set(opened); + if (!next.delete(id)) next.add(id); + setExpanded(next); + }} onActivate={onPin} onPreload={onPreload} - defaultExpandedIds={openBookId === undefined ? [] : [openBookId]} + expandedIds={opened} + onExpandedChange={setExpanded} /> ); } @@ -166,6 +217,33 @@ function bookNode( }; } +/** + * How many chapters are put into one group, and from how many the tree starts grouping at all. + * + * Hundreds, and the number is not a taste: a chapter is addressed by its ORDINAL, and a hundred is + * the step a person counts them in. The measurement says the same thing from the other side — a + * flat tree of 2284 rows costs nothing to draw (the virtualizer keeps 31 of them in the DOM, + * BACKLOG Ф-12), so grouping is not bought for the frame rate; it is bought for the scrollbar, on + * which one row of 2284 is a third of a pixel and 59 618px of travel separates chapter 1 from + * chapter 2284. Twenty-three rows and one fold do the same journey. + * + * Below the threshold the tree stays FLAT: a book of a dozen chapters gains nothing from a fold + * that has to be opened before the book can be read at all. + */ +const groupSize = 100; +const groupFrom = 200; + +/** The id of a fold. Minted here and recognised by membership, never by its shape (see `groups`). */ +const groupId = (bookId: string, from: number) => `${bookId}:${String(from)}`; + +/** The group a chapter sits in, or `null` when the tree is flat or the id is not a chapter. */ +function groupOf(chapters: Chapter[], bookId: string | undefined, chapterId: string | undefined) { + if (chapters.length <= groupFrom || bookId === undefined || chapterId === undefined) return null; + const position = chapters.findIndex((chapter) => chapter.id === chapterId); + if (position < 0) return null; + return groupId(bookId, position - (position % groupSize)); +} + // One row saying what happened, in the place the sections would occupy. Not selectable: the screen // only opens ids it found among the chapters. function childrenOf( @@ -175,9 +253,21 @@ function childrenOf( text: Text, ): TreeNode[] | undefined { if (chaptersState !== null) return [{ id: `${book.id}:chapters-state`, title: chaptersState }]; - return chapters.length > 0 - ? chapters.map((chapter, index) => chapterNode(chapter, index, text)) - : undefined; + if (chapters.length === 0) return undefined; + if (chapters.length <= groupFrom) { + return chapters.map((chapter, index) => chapterNode(chapter, index, text)); + } + return Array.from({ length: Math.ceil(chapters.length / groupSize) }, (_, group) => { + const from = group * groupSize; + const inside = chapters.slice(from, from + groupSize); + return { + id: groupId(book.id, from), + // The range is of POSITIONS in reading order and not of the numbers printed on the chapters: + // a book legally has no numbering at all, and half of one is legal too (K-3). + title: text('library.chapterRange', { from: from + 1, to: from + inside.length }), + children: inside.map((chapter, index) => chapterNode(chapter, from + index, text)), + }; + }); } function chapterNode(chapter: Chapter, index: number, text: Text): TreeNode { diff --git a/frontend/src/showcase/RunStart.module.css b/frontend/src/showcase/RunStart.module.css new file mode 100644 index 00000000..f2563c5f --- /dev/null +++ b/frontend/src/showcase/RunStart.module.css @@ -0,0 +1,41 @@ +.form { + display: grid; + gap: var(--space-6); +} + +.option { + display: grid; + gap: var(--space-2); +} + +.hint { + color: var(--color-text-secondary); + font-size: var(--font-size-small); +} + +/* Empty until there is something to say, and then it takes no space of its own. */ +.live:empty { + display: none; +} + +/* A refusal is a state of the form, not an alarm over it (§3.8): the phrase in the ordinary text + colour, the advice in the quiet one. */ +.refused { + display: grid; + gap: var(--space-2); + border-top: 1px solid var(--color-border); + padding-top: var(--space-5); +} + +.phrase { + color: var(--color-text); +} + +/* The actions live INSIDE the form and not in the window's footer: the footer belongs to the modal, + and what stands there has to exist before the bounds have been read — while this pair only makes + sense once there is a scale to submit. */ +.actions { + display: flex; + gap: var(--space-4); + justify-content: flex-end; +} diff --git a/frontend/src/showcase/RunStart.tsx b/frontend/src/showcase/RunStart.tsx new file mode 100644 index 00000000..948dc8f0 --- /dev/null +++ b/frontend/src/showcase/RunStart.tsx @@ -0,0 +1,189 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { ApiError, keys, runOptionsQuery, startRun } from '../api'; +import type { CeilingBounds, Id, Run } from '../api'; +import { useText, type MessageKey, type Text } from '../i18n/text'; +import { Button } from '../ui/Button'; +import { Checkbox } from '../ui/Checkbox'; +import { Modal } from '../ui/Modal'; +import { Slider } from '../ui/Slider'; +import { Blank } from './Blank'; +import { Loaded } from './Loaded'; +import { counted } from './format'; +import { chapters as chapterForms } from './units'; +import styles from './RunStart.module.css'; + +interface Props { + bookId: Id; + isOpen: boolean; + onClose: () => void; +} + +/** + * Starting a translation: the ceiling of THIS run and the stop at the bank. + * + * The bounds are read right before the form is shown and not taken off the book card, because the + * maximum belongs to the ACCOUNT and moves while the book does not (contract, `run-options`). They + * arrive already trimmed and are not clamped again here: a second clamp on the client would be a + * second copy of a policy that lives on the platform. + * + * ⚠ The scale counts CHAPTERS and nothing else. There is no sum, no estimate and no money on this + * screen in any form — the chapters → money conversion is the platform's whole and only (§4.8). + */ +export function RunStart({ bookId, isOpen, onClose }: Props) { + const text = useText(); + const client = useQueryClient(); + // Read only while the window is open: bounds fetched behind a closed form are bounds that will be + // stale by the time it opens. + const options = useQuery({ ...runOptionsQuery(bookId), enabled: isOpen }); + + return ( + + + {(data) => ( +
+ Promise.all([ + client.invalidateQueries({ queryKey: keys.runOptions(bookId) }), + client.invalidateQueries({ queryKey: keys.book(bookId) }), + ]) + } + /> + )} + + + ); +} + +/** + * The form itself, mounted only once the bounds are known. + * + * ⚠ When the bounds MOVE — which is what a `409` means — the chosen value goes back to the + * platform's new preset, because a value picked against bounds that no longer exist is not a + * choice. Adjusted during the render rather than by remounting the form or by an effect: a remount + * would take the refusal off the screen at the very moment it has to be read, and an effect would + * draw the old scale for one frame first. + */ +function Form({ + bookId, + ceiling, + onClose, + onConflict, +}: { + bookId: Id; + ceiling: CeilingBounds; + onClose: () => void; + onConflict: () => Promise; +}) { + const text = useText(); + const client = useQueryClient(); + const [chapters, setChapters] = useState(ceiling.default_chapters); + // The bounds the value above was chosen against. The query layer keeps the identity of data that + // did not change (structural sharing), so this fires on a real move of the bounds and not on + // every refetch. + const [chosenAgainst, setChosenAgainst] = useState(ceiling); + if (chosenAgainst !== ceiling) { + setChosenAgainst(ceiling); + setChapters(ceiling.default_chapters); + } + // ⚠ OFF by default, and that is a choice about TODAY rather than about the product. With the stop + // on, a run ends at `awaiting_bank` — and nothing in this build can take it further: the signing + // screen is S5, the bank has no read channel at all (BACKLOG Ф-43), and `resume` answers 409 + // while the set of decisions is incomplete (contract §resumeRun). Preselecting it would be + // preselecting a dead end. The checkbox itself stays, because the choice is the user's and belongs + // to THIS run (owner, 02.08); the default flips back the day S5 gives the stop somewhere to go. + const [verifyBank, setVerifyBank] = useState(false); + + const run = useMutation({ + mutationFn: () => startRun(bookId, { ceiling_chapters: chapters, verify_bank: verifyBank }), + onSuccess: () => { + // The run lives on the book card — that is where the shell reads the stream's id from. + void client.invalidateQueries({ queryKey: keys.book(bookId) }); + onClose(); + }, + onError: (error) => { + // A 409 says the state moved between the read and this call. What moved is the platform's to + // name (its phrase reaches the screen untouched); ours is to read the state again and let the + // person look — never a quiet retry with a request the platform has just refused. + if (error instanceof ApiError && error.status === 409) void onConflict(); + }, + }); + + // `max_chapters: 0` is not a scale with nothing on it: no run can start at all, and the honest + // shape of that is the exhausted state instead of a control that cannot be used. + if (ceiling.max_chapters === 0) { + return ( + + ); + } + + return ( +
+ + +
+ +

{text('run.verifyBankHint')}

+
+ + {/* A permanent live region, as in the intake form: the refusal appears away from the focus, + and a reader that is not told is a reader who thinks the press did nothing (WCAG 4.1.3). */} +
+ {run.isError && } +
+ +
+ + +
+
+ ); +} + +/** + * Why the run did not start. As in the intake form, the phrase is the platform's where it sent one + * and the ADVICE is ours: a moved ceiling and a deployment that cannot run at all end in different + * next actions, and neither of them is "press it again". + */ +function Refused({ error }: { error: Error }) { + const text = useText(); + const [phrase, advice] = refusal(error, text); + return ( +
+

{phrase}

+

{text(advice)}

+
+ ); +} + +function refusal(error: Error, text: Text): [string, MessageKey] { + if (!(error instanceof ApiError)) return [text('loaded.unknownError'), 'upload.refusedAdvice']; + if (error.status === 0) return [text('loaded.serverUnreachable'), 'upload.retryAdvice']; + + const own: Partial> = { + 409: ['run.conflict', 'run.conflictAdvice'], + 503: ['run.unavailable', 'run.unavailableAdvice'], + }; + const [fallback, advice] = own[error.status] ?? ['run.failedTitle', 'upload.refusedAdvice']; + return [error.problem?.detail ?? error.problem?.title ?? text(fallback), advice]; +} diff --git a/frontend/src/showcase/Showcase.tsx b/frontend/src/showcase/Showcase.tsx index ad1cf09c..2dc35621 100644 --- a/frontend/src/showcase/Showcase.tsx +++ b/frontend/src/showcase/Showcase.tsx @@ -17,6 +17,7 @@ import { Document } from './Document'; import { Goto } from './Goto'; import { Library } from './Library'; import type { Query } from './Loaded'; +import { RunStart } from './RunStart'; import { Settings } from './Settings'; import { Status } from './Status'; import { @@ -28,10 +29,11 @@ import { selectDocument, } from './documents'; import { chapterLabel, isDraft } from './format'; +import { useIntakeEnd } from './useIntakeEnd'; import { useRunStream } from './useRunStream'; import { useScreenshotFlag } from './useScreenshotFlag'; -type Overlay = 'settings' | 'add-book' | 'goto' | null; +type Overlay = 'settings' | 'add-book' | 'goto' | 'run' | null; /** * The showcase, now on the data layer. One screen for every scenario: what changes between routes @@ -41,9 +43,14 @@ type Overlay = 'settings' | 'add-book' | 'goto' | null; export function Showcase() { const text = useText(); const library = useQuery(libraryQuery()); - // The library is the entry: which book is open is a consequence of what the platform returned, - // not a constant of the fixture. - const bookId = library.data?.books[0]?.id; + // Which book is open: the one chosen in the tree, and the first the platform returned until + // something is chosen (the zone's §2 — "a click on a book opens its state"). Checked against the + // library on every render rather than reset by an effect: a chosen book can DISAPPEAR from the + // library, and a screen holding an id that is no longer there reads every panel as a failure. + const [chosen, setChosen] = useState(null); + const books = library.data?.books; + const bookId = + chosen !== null && books?.some((row) => row.id === chosen) === true ? chosen : books?.[0]?.id; const enabled = bookId !== undefined; const book = useQuery({ ...bookQuery(bookId ?? ''), enabled }); @@ -65,6 +72,7 @@ export function Showcase() { }; const connection = useRunStream(bookId, book.data?.run?.id); + useIntakeEnd(bookId, book.data?.book.status); const expandPanel = useLayout((state) => state.expand); const [overlay, setOverlay] = useState(null); @@ -86,8 +94,19 @@ export function Showcase() { // The guard stands HERE and not at every caller: the tabs are built only out of chapters that // exist, so a selected id that is not among the chapters leaves the controlled tab row with no // selected tab — an empty centre without a single word (the notes summary did exactly that). + // + // A row of the tree is a chapter OR a book, and the two are told apart by where the id is found. + // Choosing a book closes the open documents: they are chapters of the book being left, and + // keeping them would leave tabs pointing into a tree that no longer holds them. const open = (id: string) => { - if (positionOf(id) >= 0) setOpened(previewDocument(documents, id)); + if (positionOf(id) >= 0) { + setOpened(previewDocument(documents, id)); + return; + } + if (books?.some((row) => row.id === id) === true && id !== bookId) { + setChosen(id); + setOpened(null); + } }; const pin = (id: string) => { if (positionOf(id) >= 0) setOpened(pinDocument(documents, id)); @@ -131,7 +150,7 @@ export function Showcase() { <>