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

This commit is contained in:
heaven 2026-08-15 19:25:25 +03:00
parent 3e6764231c
commit 267aa3569c
55 changed files with 3877 additions and 206 deletions

View file

@ -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,

File diff suppressed because one or more lines are too long

View file

@ -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) · **99103** (прогресс, манифест, таблица подписи, 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` на этой же карте.
---

View file

@ -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.10.2 (карта канона и два провода) и первоисточник `docs/research/23-engine-platform-seam.md`. Там же цена ошибки: моки, снятые не с того уровня контракта, разойдутся с API — ровно то, ради предотвращения чего заведена строка 95 единого бэклога.
---

View file

@ -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` |

View file

@ -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 }

View file

@ -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
(«разделы 1100»), 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-гейта строк | **починено, и шире находки.** Гейт знает НОСИТЕЛИ: `??`, тернарник и шаблон — и в читаемом атрибуте, и как ребёнок элемента (`<p>{'слово'}</p>` не ловился вовсе). Проверено живым нарушением на всех четырёх формах; живой экземпляр `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` около 813 (их питон и `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 сценариев

View file

@ -118,8 +118,14 @@ const tokenGate = [
// The leftover hole from §5.4: `<div {...props}/>`, 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: `<p>{'a word'}</p>` prints one exactly as `<p>a word</p>` 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(

View file

@ -41,13 +41,36 @@ const load = (/** @type {string} */ path) => import(new URL(path, import.meta.ur
/** @type {Record<MessageKey, string>} */
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<string, string | number> | 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<unknown>} 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<string, string | number>} [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 owners 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' });

View file

@ -21,6 +21,7 @@ const KNOWN_ROUTES = [
'/showcase',
'/scale',
'/empty',
'/intake',
'/loading',
'/error',
'/offline',

View file

@ -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.

View file

@ -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();

View file

@ -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<Schemas['Book'], 'status'> & { status: BookStatus | null };
export type Book = Omit<Schemas['Book'], 'status' | 'reject_reason'> & {
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<Schemas['Run'], 'status' | 'paused_reason'> & {
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,

View file

@ -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';
/**

View file

@ -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

View file

@ -10,6 +10,7 @@ export const scenarios = [
'showcase',
'scale',
'empty',
'intake',
'loading',
'error',
'offline',

View file

@ -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: {

View file

@ -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();
});

143
frontend/src/api/upload.ts Normal file
View file

@ -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<Book> {
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<Book>((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;
}
}

View file

@ -50,26 +50,40 @@ function vocabulary<Value extends string, Meaning>(
* 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<Schemas['BookStatus'], { label: MessageKey; tone?: Tone }>(
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<Schemas['PausedReason'], { label: Message
{ label: 'paused.unknown' },
);
/**
* Why a book was rejected what the reader is told and what they can do about it (0.2.3).
*
* Two things ride together because they are one decision: the phrase names what happened, the
* advice names the NEXT ACTION, and the next action is the whole reason the contract carries three
* values instead of a boolean. "The file is not one we can read" ends in checking the file; "not
* us, not now" ends in waiting. A vocabulary that carried only the phrase would leave the second
* half to be re-invented by every screen that shows a rejected book.
*
* The wording is this session's own (owner's word 14.08) and awaits his approval. The boundary it
* is written inside is the one `Problem` states for the platform's phrases: no stages, no models,
* nothing about how the parsing works.
*/
export const rejectReason = vocabulary<
Schemas['RejectReason'],
{ label: MessageKey; advice: MessageKey }
>(
{
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.
*

View file

@ -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);
}

View file

@ -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': 'Перевод по умолчанию',

View file

@ -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'),
];

View file

@ -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.

View file

@ -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<Schemas['Run']>(
{ ...started, ceiling_chapters: body.ceiling_chapters, verify_bank: body.verify_bank },
{ status: 202 },
);
return json<Schemas['Run']>(started, { status: 202 });
}),
http.post('*/v0/runs/:runId/stop', ({ request }) => {

377
frontend/src/mock/intake.ts Normal file
View file

@ -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,
}));
}

View file

@ -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;
}

View file

@ -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<World, 'runOf'>) => (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<Scenario, World> = {
showcase: defaultWorld,
scale: scaleWorld,
empty: emptyWorld,
intake: intakeWorld,
loading: defaultWorld,
error: defaultWorld,
offline: defaultWorld,

View file

@ -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;

View file

@ -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<Intl.LDMLPluralRule, MessageKey> = {
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 (
<div className={styles.card}>
<State book={book} run={run ?? null} />
{/* 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 && (
<Button look="primary" onPress={onStartRun}>
{text('run.action')}
</Button>
)}
<dl className={styles.about}>
{fields(detail, text).map(([name, value]) => (
<div className={styles.field} key={name}>
<dt className={styles.fieldName}>{name}</dt>
<dd className={styles.fieldValue} title={value}>
{value}
</dd>
</div>
))}
</dl>
</div>
);
}
/**
* 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 <Callout tone="quiet" caption={text('about.uploading')} />;
if (book.status === 'parsing') return <Callout tone="quiet" caption={text('about.parsing')} />;
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 (
<Callout tone="note" caption={text(reason.label)}>
{text(reason.advice)}
</Callout>
);
}
// 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 (
<Callout
tone="note"
caption={text(pausedReasonWords.describe(run?.paused_reason ?? null).label)}
/>
);
}
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 (
<dl className={styles.about}>
{fields.map(([name, value]) => (
<div className={styles.field} key={name}>
<dt className={styles.fieldName}>{name}</dt>
<dd className={styles.fieldValue} title={value}>
{value}
</dd>
</div>
))}
</dl>
);
return rows;
}

View file

@ -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;
}

View file

@ -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<string | null>(null);
const [title, setTitle] = useState('');
const client = useQueryClient();
const input = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [sourceLang, setSourceLang] = useState<string>(sourceLanguages[0]);
const [targetLang, setTargetLang] = useState<string>(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<UploadProgress | null>(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<AbortController | null>(null);
const upload = useMutation<Book, Error, BookIntake>({
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 (
<Modal
title={text('addBook.title')}
@ -33,60 +79,224 @@ export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => v
footer={
<>
<Button look="action" onPress={close}>
{text('addBook.cancel')}
</Button>
<Button look="primary" isDisabled={file === null} onPress={close}>
{text('addBook.submit')}
{text(accepted ? 'action.done' : 'action.cancel')}
</Button>
{accepted === undefined && (
<Button look="primary" isDisabled={file === null || upload.isPending} onPress={send}>
{text('addBook.submit')}
</Button>
)}
</>
}
>
<div className={styles.form}>
<div>
<p className={styles.label}>{text('addBook.fileLabel')}</p>
{/* 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. */}
<input
className={styles.hidden}
ref={input}
type="file"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => 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.
<div role="status">
<Accepted book={accepted} />
</div>
) : (
<div className={styles.form}>
<div>
<p className={styles.label}>{text('addBook.fileLabel')}</p>
{/* 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. */}
<input
className={styles.hidden}
ref={input}
type="file"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
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 = '';
}}
/>
<Button
look="action"
isDisabled={upload.isPending}
onPress={() => input.current?.click()}
>
<FilePlus2 {...icon} />
{file === null ? text('addBook.chooseFile') : `${file.name} · ${fileSize(file.size)}`}
</Button>
</div>
<TextField
label={text('addBook.titleLabel')}
value={title}
onChange={setTitle}
placeholder={file === null ? text('addBook.titlePlaceholder') : parsedTitle(file.name)}
hint={title === '' ? text('addBook.titleHintAuto') : text('addBook.titleHintManual')}
/>
<Button look="action" onPress={() => input.current?.click()}>
<FilePlus2 {...icon} />
{file ?? text('addBook.chooseFile')}
</Button>
</div>
<TextField
label={text('addBook.titleLabel')}
value={title}
onChange={setTitle}
placeholder={file === null ? text('addBook.titlePlaceholder') : parsedTitle(file)}
hint={title === '' ? text('addBook.titleHintAuto') : text('addBook.titleHintManual')}
/>
<div className={styles.pair}>
<Select
label={text('addBook.sourceLangLabel')}
options={sourceLanguages.map((code) => ({ id: code, label: languageName(code) }))}
value={sourceLang}
onChange={setSourceLang}
/>
<Select
label={text('addBook.targetLangLabel')}
options={targetLanguages.map((code) => ({ id: code, label: languageName(code) }))}
value={targetLang}
onChange={setTargetLang}
/>
</div>
{/* 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). */}
<div className={styles.slots}>
<p className={styles.label}>{text('addBook.settingsLabel')}</p>
<p className={styles.slot}>{text('addBook.settingsSlot')}</p>
<TextField
label={text('addBook.genreLabel')}
value={genre}
onChange={setGenre}
placeholder={text('addBook.genrePlaceholder')}
/>
{/* 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. */}
<div role="status" className={styles.live}>
{upload.isPending && <Sending file={file} sent={sent} />}
{upload.isError && <Failed error={upload.error} />}
</div>
</div>
</div>
)}
</Modal>
);
}
/**
* 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 (
<div className={styles.sending}>
<p className={styles.slot}>{text('addBook.sendingUnknown', { total: fileSize(size) })}</p>
</div>
);
}
const total = sent.total ?? size;
const share = total === 0 ? 0 : Math.min(1, sent.sent / total);
return (
<div className={styles.sending}>
<ProgressBar
label={text('addBook.sending')}
value={share}
valueLabel={text('addBook.sendingSize', {
sent: fileSize(sent.sent),
total: fileSize(total),
})}
/>
{share >= 1 && <p className={styles.slot}>{text('addBook.sent')}</p>}
</div>
);
}
/**
* 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 (
<div className={styles.accepted}>
<h3 className={styles.acceptedTitle}>{text('addBook.acceptedTitle')}</h3>
<dl className={styles.fields}>
{fields.map(([name, value]) => (
<div className={styles.field} key={name}>
<dt className={styles.fieldName}>{name}</dt>
<dd className={styles.fieldValue}>{value}</dd>
</div>
))}
</dl>
<p className={styles.slot}>{text('addBook.acceptedDescription')}</p>
</div>
);
}
/**
* 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 (
<div className={styles.failed}>
<p className={styles.failedTitle}>{phrase}</p>
<p className={styles.slot}>{text(advice)}</p>
</div>
);
}
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<Record<number, [MessageKey, MessageKey]>> = {
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();
}

View file

@ -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: (
<Loaded query={book} waiting={text('about.tab')} idle={idle}>
{(detail) => <About detail={detail} />}
{(detail) => <About detail={detail} onStartRun={onStartRun} />}
</Loaded>
),
},

View file

@ -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);
}

View file

@ -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<Set<string> | 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
// (`<chapter>:<cut>:<index>`) — 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 (
<Tree
label={text('library.treeLabel')}
items={items}
selectedId={selectedId}
onSelect={onOpen}
// A row of a GROUP is not a thing to open — it is a fold, and a click on it folds. Without
// this the row answered a click with nothing at all, which is what a row that is not a row
// looks like (found by the adversarial review).
onSelect={(id) => {
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 {

View file

@ -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;
}

View file

@ -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 (
<Modal title={text('run.title')} isOpen={isOpen} onClose={onClose}>
<Loaded query={options} waiting={text('run.waiting')}>
{(data) => (
<Form
bookId={bookId}
ceiling={data.ceiling}
onClose={onClose}
// ⚠ BOTH are re-read, because a 409 is not only a ceiling that moved: the platform
// answers it for a run already in flight and for a book not ready to be translated as
// well (`fail` in httpapi/v0.go). The bounds may be intact and the CARD stale — and the
// card is what decides whether the action is offered at all.
onConflict={() =>
Promise.all([
client.invalidateQueries({ queryKey: keys.runOptions(bookId) }),
client.invalidateQueries({ queryKey: keys.book(bookId) }),
])
}
/>
)}
</Loaded>
</Modal>
);
}
/**
* 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<unknown>;
}) {
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<Run, Error>({
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 (
<Blank title={text('run.exhaustedTitle')} description={text('run.exhaustedDescription')} />
);
}
return (
<div className={styles.form}>
<Slider
label={text('about.ceiling')}
value={chapters}
min={ceiling.min_chapters}
max={ceiling.max_chapters}
onChange={setChapters}
valueLabel={counted(chapters, chapterForms)}
hint={text('run.ceilingHint')}
/>
<div className={styles.option}>
<Checkbox label={text('run.verifyBank')} isSelected={verifyBank} onChange={setVerifyBank} />
<p className={styles.hint}>{text('run.verifyBankHint')}</p>
</div>
{/* 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). */}
<div role="status" className={styles.live}>
{run.isError && <Refused error={run.error} />}
</div>
<div className={styles.actions}>
<Button look="action" onPress={onClose}>
{text('action.cancel')}
</Button>
<Button look="primary" isDisabled={run.isPending} onPress={() => run.mutate()}>
{text('run.action')}
</Button>
</div>
</div>
);
}
/**
* 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 (
<div className={styles.refused}>
<p className={styles.phrase}>{phrase}</p>
<p className={styles.hint}>{text(advice)}</p>
</div>
);
}
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<Record<number, [MessageKey, MessageKey]>> = {
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];
}

View file

@ -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<string | null>(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<Overlay>(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() {
<>
<ScreenshotFlag />
<Shell
title={book.data?.book.title ?? 'TextMachine'}
title={book.data?.book.title ?? text('shell.appName')}
actions={
<>
<Button
@ -157,7 +176,9 @@ export function Showcase() {
library={library}
chapters={awaiting(chapters)}
openBookId={bookId}
selectedId={documents.active}
// The open CHAPTER while there is one, and the open book otherwise: a book whose
// sections nobody has opened is still the book the panels are showing.
selectedId={documents.active === '' ? bookId : documents.active}
onOpen={open}
onPin={pin}
onPreload={preload}
@ -188,6 +209,7 @@ export function Showcase() {
onTabChange={setContextTab}
request={bankRequest}
onOpenChapter={open}
onStartRun={enabled ? () => setOverlay('run') : undefined}
/>
}
status={
@ -201,6 +223,9 @@ export function Showcase() {
/>
<Settings isOpen={overlay === 'settings'} onClose={() => setOverlay(null)} />
<AddBook isOpen={overlay === 'add-book'} onClose={() => setOverlay(null)} />
{bookId !== undefined && (
<RunStart bookId={bookId} isOpen={overlay === 'run'} onClose={() => setOverlay(null)} />
)}
<Goto
isOpen={overlay === 'goto'}
onClose={() => setOverlay(null)}

View file

@ -12,6 +12,15 @@ const build = (locale: string) => ({
languages: new Intl.DisplayNames([locale], { type: 'language' }),
numbers: new Intl.NumberFormat(locale),
plurals: new Intl.PluralRules(locale),
// The unit is formatted by Intl and not by us: "MB" is a word, and a word written in the code is
// a word no translation file can reach. One formatter per magnitude, because the unit is part of
// the formatter rather than of the value.
bytes: new Map(
(['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const).map((unit) => [
unit,
new Intl.NumberFormat(locale, { style: 'unit', unit, maximumFractionDigits: 1 }),
]),
),
});
function intl() {
@ -30,6 +39,27 @@ export const date = (value: string) => new Date(value).toLocaleDateString(curren
export const languageName = (code: string) => intl().languages.of(code) ?? code;
export const number = (value: number) => intl().numbers.format(value);
/**
* A size of a file, in the unit that suits it. Decimal steps of a thousand and not 1024: that is
* what the operating system's file manager shows the same file as, and a size the interface states
* differently from the folder the file was picked from reads as a defect.
*/
export function fileSize(bytes: number): string {
const bare = { unit: 'byte', factor: 1 } as const;
const steps = [
{ unit: 'gigabyte', factor: 1e9 },
{ unit: 'megabyte', factor: 1e6 },
{ unit: 'kilobyte', factor: 1e3 },
bare,
] as const;
const step = steps.find(({ factor }) => bytes >= factor) ?? bare;
return (
intl()
.bytes.get(step.unit)
?.format(bytes / step.factor) ?? String(bytes)
);
}
/**
* A number with the word it counts: "1 term", "2 terms". The caller names the forms as catalogue
* keys, so the words stay in the catalogue and `Intl` picks the category; no table of endings is

View file

@ -0,0 +1,16 @@
// Which language pairs the add-a-book form OFFERS.
//
// ⚠ This is a client-side offer list and it is the honest weak point of the intake screen: the
// contract has no handle for "what this deployment can translate", and which pairs a deployment
// can actually run is its own property — the engine reads a pair out of a language pack, and a
// deployment shipping a different set would make this list lie. A code the user cannot enter is
// worse than a list that is short, so the form offers what the product translates today and the
// gap is written down rather than papered over (BACKLOG Ф-57: a handle in the contract).
//
// Codes only, never names: the name is computed by `Intl.DisplayNames` for the language of the
// INTERFACE, so a second interface language renames the list without an edit here (the same rule
// the whole zone follows — CLAUDE.md §2).
export const sourceLanguages = ['zh', 'ja', 'en'] as const;
export const targetLanguages = ['ru'] as const;

View file

@ -0,0 +1,17 @@
// Plural forms of the counters that more than one screen prints. `Intl` picks the category, the
// words stay in the catalogue, and this file exists only so that two screens counting the same
// thing cannot count it in two different sets of words.
//
// A counter used by ONE screen stays with that screen (the notes of the tree, the terms of the
// bank): a shared module for a single caller is a layer, not a rule.
import type { MessageKey } from '../i18n/text';
export const chapters: Record<Intl.LDMLPluralRule, MessageKey> = {
zero: 'about.chaptersZero',
one: 'about.chaptersOne',
two: 'about.chaptersTwo',
few: 'about.chaptersFew',
many: 'about.chaptersMany',
other: 'about.chaptersOther',
};

View file

@ -0,0 +1,33 @@
// The end of an intake, which is the moment a book HAS chapters.
//
// Nothing announces it. The live stream belongs to a run (`/runs/{runId}/events`) and a book being
// uploaded or parsed has no run at all, so the reads poll while it lasts (src/api/queries.ts). This
// is the other half of the same gap: the chapter tree was read while there were no chapters, and
// the poll that discovers the parse is over reads the LIBRARY — not the tree. Without this the book
// stands in the library as parsed and its sections never arrive.
//
// The right cure is an event, and that is a question for the contract's owner (BACKLOG Ф-56).
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { inIntake, keys } from '../api';
import type { BookStatus, Id } from '../api';
export function useIntakeEnd(bookId: Id | undefined, status: BookStatus | null | undefined): void {
const client = useQueryClient();
// The previous status OF THIS BOOK. What has to be caught is a crossing — firing on
// `not_started` itself would re-read the tree of every parsed book on every mount — and a
// crossing between two DIFFERENT books is not one: choosing another book while the first was
// still parsing looked exactly like a parse that had just ended, and threw away the chapter list
// of the book just opened (found by the adversarial review, reproduced in a test).
const before = useRef<{ bookId?: Id; status?: BookStatus | null }>({ bookId, status });
useEffect(() => {
const was = before.current;
before.current = { bookId, status };
if (bookId === undefined || was.bookId !== bookId) return;
if (!inIntake(was.status) || inIntake(status)) return;
void client.invalidateQueries({ queryKey: keys.chapters(bookId) });
}, [bookId, status, client]);
}

View file

@ -7,7 +7,7 @@
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { render, waitFor } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { HttpResponse, delay, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest';
@ -281,6 +281,37 @@ test('a status frame re-reads the text of every open chapter', async () => {
});
});
// S4. The same status stands in TWO answers — the book's card and the row of the library — and the
// tree's badge is the one the eye reads first. Patching only the card left the badge saying "queued"
// under a run that had already halted (seen on the frame of a ceiling stop).
// Acceptance of S3.7, finding 8: the counters of every chapter move at a stage boundary too, and
// the frame that would patch a single row may not arrive at all while `units_done` is one counter.
test('a status frame re-reads the chapter list, whose counters move at the same boundary', async () => {
const client = mount();
await settled(client);
expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(1);
hello();
emit('status', { status: 'awaiting_bank', paused_reason: null }, 1850);
await waitFor(() => {
expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(2);
});
});
test('a status frame re-reads the library, where the same status is a row', async () => {
const client = mount();
await settled(client);
expect(askedFor('/v0/books')).toBe(1);
hello();
emit('status', { status: 'paused', paused_reason: 'credit_exhausted' }, 1850);
await waitFor(() => {
expect(askedFor('/v0/books')).toBe(2);
});
});
// Ф-50. The window is every screen start and every resync: the frame arrives while the first read
// is still in flight, so there is nothing in the cache to patch — and the stream has already moved
// its high-water mark, so the answer prepared BEFORE the frame is taken as fresh and the frame is
@ -328,6 +359,78 @@ test('a burst of frames on an empty cache costs one re-read, not one per frame',
expect(askedFor(`/v0/books/${bookId}`)).toBe(2);
});
// Acceptance of S3.7, finding 2: the mark says "a re-read of this key is on its way", and it was
// never taken off. A read that FAILS leaves the entry empty, so from then on every frame for that
// key found the mark and returned — the key was silenced for the rest of the run, on a screen whose
// reads had recovered long ago.
test('a key whose re-read failed is not silenced for the rest of the run', async () => {
let refuse = true;
server.use(
http.get('*/v0/books/:bookId/bank', () => {
if (refuse) return HttpResponse.json({ title: 'no' }, { status: 503 });
return HttpResponse.json<Schemas['Bank']>({
revision,
next_cursor: null,
total: 1,
signed: 1,
terms: [],
});
}),
);
const client = mount();
await settled(client);
const before = askedFor(`/v0/books/${bookId}/bank`);
hello();
emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850);
await settled(client);
refuse = false;
// The second frame has to reach the network. On the pre-fix code the mark from the first one was
// still there, and this read never happened.
emit('bank', { total: 60, signed: 13, pending_decisions: 47 }, 1851);
await waitFor(() => {
expect(askedFor(`/v0/books/${bookId}/bank`)).toBeGreaterThan(before + 1);
});
});
// Acceptance of S3.7, finding 3: the marks belong to an epoch. A resync throws the snapshots away,
// and a mark left from before it eats the first frame of the new epoch — exactly in the window
// where the client has nothing else to go on.
test('a resync clears the marks with the snapshots', async () => {
// The read is SLOW on purpose: the window this guards is the one where the resync's own re-read
// has not landed yet, so the cache is empty and the mark is the only thing deciding. With a fast
// read the data arrives first and the mark is bypassed — the test would pass on the defect.
server.use(
http.get('*/v0/books/:bookId/bank', async () => {
await delay(120);
return HttpResponse.json<Schemas['Bank']>({
revision,
next_cursor: null,
total: 1,
signed: 1,
terms: [],
});
}),
);
const client = mount();
hello();
// NOT settled first, and that is the point: the first read is still in flight, so this frame
// finds no snapshot to patch, leaves a mark and restarts the read.
emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850);
emit('resync_required', { reason: 'the book was rebuilt' }, 12);
const before = askedFor(`/v0/books/${bookId}/bank`);
// Inside the window: the re-read is still in flight, the cache is empty, and this frame must
// still reach the network. On the pre-fix code the mark of the dead epoch ate it.
emit('bank', { total: 60, signed: 13, pending_decisions: 47 }, 13);
await waitFor(() => {
expect(askedFor(`/v0/books/${bookId}/bank`)).toBeGreaterThan(before);
});
await settled(client);
});
// Ф-51. A resync follows a FULL replacement, where the revision is not promised to keep growing —
// so the snapshot's revision has to go with the snapshot.
test('a resync drops the book from the cache, marks and all', async () => {
@ -350,7 +453,21 @@ test('a resync drops the book from the cache, marks and all', async () => {
// The cost the resync must NOT pay: the run id travels inside the book detail, so emptying that
// detail took the subscription down with it and opened a second connection. Found by the
// adversarial review of this very fix, not by the fix's own tests.
test('a resync does not restart the live stream', async () => {
//
// ⚠ The read is SLOW here, and that is the acceptance of S3.7 talking (finding 3): with an instant
// answer the observer may never render between the reset and the new data, so the test would be
// green on a stream that does restart. The window is opened on purpose and measured inside it.
test('a resync does not restart the live stream, even while the re-read is in flight', async () => {
server.use(
http.get('*/v0/books/:bookId', async () => {
await delay(120);
return HttpResponse.json<Schemas['BookDetail']>({
revision,
book,
run: { ...run, revision },
});
}),
);
const client = mount([], <WiredScreen />);
await settled(client);
expect(FakeEventSource.opened).toBe(1);
@ -358,6 +475,12 @@ test('a resync does not restart the live stream', async () => {
hello();
emit('resync_required', { reason: 'the book was rebuilt' }, 12);
// Inside the window: the card is empty and the answer has not come back yet.
await waitFor(() => {
expect(client.getQueryData<BookDetail>(keys.book(bookId))).toBeUndefined();
});
expect(FakeEventSource.opened).toBe(1);
await settled(client);
await waitFor(() => {
expect(client.getQueryData<BookDetail>(keys.book(bookId))?.revision).toBe(12);

View file

@ -15,8 +15,31 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
const client = useQueryClient();
const [state, setState] = useState<ConnectionState>('connecting');
// ⚠ The run id travels INSIDE the book card, and a resync empties that card for the length of one
// read — so for that moment the caller has no id to give, and the subscription would be torn down
// and opened again over a gap in the data that merely DESCRIBES it. Measured, not reasoned:
// with an answer that takes 120 ms the stream opened twice (the acceptance of S3.7 asked for
// exactly this measurement, and the earlier claim that it does not happen was taken against an
// instant mock).
//
// Held per BOOK: another book is another stream, and there the id must not survive. Legal by the
// contract as well — `run` is "the current or last run", so once a book has one it never goes
// back to none.
// State and not a ref, although a ref is what this looks like: a ref may not be read while
// rendering (`react-hooks/refs`), and the value is needed to decide what to render.
const [held, setHeld] = useState<{ bookId?: Id; runId?: Id }>({});
if (held.bookId !== bookId) {
setHeld({ bookId, runId });
// The state belongs to the stream it was measured on. Left alone, the first frame painted for
// another book carried the previous book's verdict — a status strip saying "live progress is
// unavailable" about a connection that had not been opened yet.
setState('connecting');
} else if (runId !== undefined && runId !== held.runId) setHeld({ bookId, runId });
const streamId = runId ?? held.runId;
useEffect(() => {
if (bookId === undefined || runId === undefined) return;
if (bookId === undefined || streamId === undefined) return;
const runId = streamId;
// Keys whose re-read is already on its way. Per subscription rather than per module: it dies
// with the stream it belongs to, and nothing leaks into the next book.
@ -30,6 +53,11 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
apply(frame, revision, bookId, client, asked);
},
onResync: () => {
// The marks go with the snapshots. They say "a re-read of this key is already on its way",
// and after a full replacement that sentence is about an epoch that no longer exists — a
// frame arriving in the window right after a resync would be eaten by a mark left from
// before it (acceptance of S3.7).
asked.clear();
// Replaying history is forbidden by the contract, so the only correct answer to "I cannot
// resume you" is to read the snapshots again.
//
@ -42,13 +70,13 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
void client.resetQueries(bookScope(bookId));
},
});
}, [bookId, runId, client]);
}, [bookId, streamId, client]);
// Without a run there is no channel, and the answer says so rather than keeping the last word of
// a stream nobody holds any more — the status strip would go on claiming a live connection
// (named by the adversarial review). Derived, not stored: state written from an effect is a
// cascade of renders, and there is nothing here to store that the arguments do not already say.
return bookId === undefined || runId === undefined ? 'connecting' : state;
return bookId === undefined || streamId === undefined ? 'connecting' : state;
}
type Client = ReturnType<typeof useQueryClient>;
@ -81,6 +109,18 @@ function apply(
// would ever ask for it again. Whole-book scope on purpose: while `units_done` stays a
// single counter (K-10) a chapter frame may not arrive once in a whole draft wave.
rereadAll(client, keys.bookUnits(bookId), asked);
// The chapter LIST goes stale at the same boundary and for the same reason (the acceptance of
// S3.7 turned Ф-49's own premise back on it): every row of it carries `units_done` and
// `note_count`, a stage boundary moves them all, and the per-chapter frame that would patch
// them may not arrive once in a whole wave while `units_done` stays a single counter (K-10).
reread(client, keys.chapters(bookId), asked);
// And the LIBRARY, which carries the same status in its own list: the tree's badge is the
// main carrier of the state (a word, not a dot), and it went on saying "queued" while the run
// it names was already halted — seen on the frame of the ceiling stop. Re-read and never
// patched: the library is a scope of its own with a revision of its own, and stamping it with
// the book's number would be comparing two counters that are not comparable (contract,
// `Revision`).
reread(client, keys.library(), asked);
return;
case 'chapter':
@ -134,6 +174,12 @@ function apply(
* five frames on an empty entry cost six reads without it and two with it, and the server MAY
* coalesce, so bursts are the normal shape of a run. What the restarted read misses of a burst the
* next frame patches, because by then the entry is no longer empty.
*
* The mark lives exactly as long as the read it stands for, and not a moment longer. Set and left
* behind which is what it was it says "on its way" about a read that ENDED: a read that failed
* leaves the entry empty, so every later frame for that key found the mark and returned, and the
* key was silenced for the rest of the run (acceptance of S3.7). A reset that starts no read at all
* a key nobody is watching resolves at once and clears it just the same.
*/
function reread(client: Client, key: readonly unknown[], asked: Set<string>): void {
const mark = key.join('/');
@ -144,7 +190,7 @@ function reread(client: Client, key: readonly unknown[], asked: Set<string>): vo
}
if (asked.has(mark)) return;
asked.add(mark);
void client.resetQueries({ queryKey: key });
void client.resetQueries({ queryKey: key }).finally(() => asked.delete(mark));
}
/**

View file

@ -39,7 +39,8 @@
the panel and the non-text floor is 3:1 (measured, S3.7) */
--color-ok: #3c7d4f;
--color-warn: #b0863a;
--color-danger: #b82e45;
--color-danger: #bd2f47; /* fleet #b82e45, lifted: verbatim it gives 2.97:1 on the panel and the
non-text floor is 3:1 the same lift --color-note took (Ф-52) */
--color-separator: #262626; /* rules, and the quiet severity step of a note */
--color-scrollbar-thumb: #333;
@ -53,6 +54,13 @@
--radius-panel: 6px; /* the corner comes out onto the straight line after 12 phys. */
--radius-control: 6px;
--radius-bar: 2px; /* the progress bar of a chapter */
--radius-round: 50%; /* the thumb of the ceiling scale, the one round thing in the shell */
/* Form controls of the intake and the run form. Both are drawn by us rather than by the browser:
a native checkbox and a native range are painted by the operating system, that is, outside the
tokens. */
--checkbox-size: 14px;
--thumb-size: 12px;
/* Re-measurement S3.5: in Fleet the bars are pressed to the edges of the window, the content is
centred within the bar itself. The former split "8 of margin + 28 of bar" kept the text at the

View file

@ -27,7 +27,6 @@ const measured: Record<string, string> = {
'--color-selection': '#164e8d',
'--color-tab-active': '#142f4c',
'--color-accent': '#746deb',
'--color-danger': '#b82e45',
'--color-ok': '#3c7d4f',
'--gap': '8px',
'--radius-panel': '6px',
@ -54,6 +53,7 @@ const toned: Record<string, string> = {
'--color-raised': '#212121', // fleet_2 #292929 is the strip; hover sits below it
'--color-selected': '#1c4478', // fleet_2 selected row #184176
'--color-note': '#2a66b0', // antigravity NOTE #2964ad, lifted to the 3:1 non-text floor
'--color-danger': '#bd2f47', // fleet #b82e45, lifted to the same floor (Ф-52)
'--color-warn': '#b0863a', // no amber in any reference; between note and danger
'--color-text': '#dedede', // fleet #dfe1e3, neutralised
'--color-text-secondary': '#9b9b9b', // fleet #8a8e91, neutralised and lifted for WCAG
@ -73,6 +73,9 @@ const chosen = [
'--color-border',
'--radius-control',
'--radius-bar',
'--radius-round',
'--checkbox-size',
'--thumb-size',
'--panel-side-min',
'--panel-side-max',
'--row-inset',
@ -149,6 +152,23 @@ describe('tokens.css', () => {
expect(stale, 'a token that is no longer in tokens.css is left in the list').toEqual([]);
});
// Ф-52 closed as a RULE and not as one edited number. Colour means state here (prompt §4.2), and
// a state tone is carried by a glyph or a strip — non-text, whose WCAG floor is 3:1. Two tones
// sat just under it (`--color-note` 2.98, `--color-danger` 2.97) and each was found by hand, one
// session apart; axe cannot find them at all, because it does not check non-text contrast.
it.each(['--color-note', '--color-ok', '--color-warn', '--color-danger'])(
'%s reaches the 3:1 non-text floor on the panel canvas',
(name) => {
const style = getComputedStyle(document.documentElement);
const tone = style.getPropertyValue(name).trim();
const panel = style.getPropertyValue('--color-panel').trim();
// Compared AS MEASURED: rounding to two places first would accept 2.995 as three (named by
// the adversarial review — no token stands there today, but a gate that rounds up is a gate
// with a hole in it).
expect(contrast(tone, panel)).toBeGreaterThanOrEqual(3);
},
);
it('the cascade order is declared explicitly and before the first layer', () => {
expect(reset.indexOf('@layer reset, vendor;')).toBeGreaterThanOrEqual(0);
expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {'));
@ -162,9 +182,11 @@ describe('tokens.css', () => {
const declared = new Set(
tokens.match(/^\s*(--[\w-]+)\s*:/gm)?.map((m) => m.trim().slice(0, -1)),
);
// Not tokens but inputs of a component: they are set on the element through style. The list is
// explicit so that "the variable is simply not declared" does not hide behind a general rule.
const local = new Set(['--progress']);
// Not tokens but inputs of a component: `--progress` is set on the element through style, and
// `--trigger-width` is written by the primitives library on its own popup (the width of the
// control it hangs under). The list is explicit so that "the variable is simply not declared"
// does not hide behind a general rule.
const local = new Set(['--progress', '--trigger-width']);
const used = new Set<string>();
for (const source of Object.values(styleSheets)) {
@ -200,6 +222,23 @@ describe('tokens.css', () => {
});
});
/**
* WCAG 2.x relative luminance and the contrast of two `#rrggbb` colours. Written out rather than
* taken from a package: it is nine lines of arithmetic from the specification, and the same nine
* already stand in the scene harness, where they run inside the browser over COMPUTED colours.
*/
function contrast(a: string, b: string): number {
const luminance = (hex: string) => {
const channel = (from: number) => {
const value = Number.parseInt(hex.slice(from, from + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
};
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (high + 0.05) / (low + 0.05);
}
// Declared deliberately and not applied yet. The list is short and by name by construction: the
// BACKLOG Ф-20 line holds the decision "either the monospace finds work in the S6 reader, or the
// import and the tokens go", and until that decision the tones stand in reserve rather than

View file

@ -0,0 +1,55 @@
/* The whole row is the target, and it is a ROW HIGH: the box itself is 14px, and a pointer target
under 24px is what WCAG 2.5.8 asks not to build. The label is part of the control (the primitive
wraps both), so making the row tall makes the target tall. */
.checkbox {
display: flex;
min-height: var(--row-height);
gap: var(--space-4);
align-items: center;
color: var(--color-text);
cursor: default;
}
/* The box is drawn by us rather than by the browser: a native one is painted by the operating
system and would be the one control in the application outside the tokens. */
.box {
display: flex;
width: var(--checkbox-size);
height: var(--checkbox-size);
flex: none;
align-items: center;
justify-content: center;
/* The muted tone and not `--color-border`: the box IS the control here nothing else says
where it is or whether it is set and 1.4.11 asks 3:1 of exactly that. Measured on the modal
surface: `--color-border` 1.20:1, `--color-text-muted` 3.29:1. The token's own caveat is about
text, and this is not text. */
border: 1px solid var(--color-text-muted);
border-radius: var(--radius-bar);
color: var(--color-text);
}
.mark {
opacity: 0;
}
/* Checked is a step of SURFACE and not a colour the same rule the primary button follows: there
are no green "save" buttons in this interface, and the accent is reserved for focus (§4.2). */
.checkbox[data-selected] .box {
border-color: var(--color-text-secondary);
background-color: var(--color-raised-strong);
}
.checkbox[data-selected] .mark {
opacity: 1;
}
.checkbox[data-hovered] .box {
border-color: var(--color-text-secondary);
}
/* Focus is visible from the keyboard only, as on every control of the shell. */
.checkbox[data-focus-visible] .box {
outline: 1px solid var(--color-accent);
outline-offset: 1px;
}

View file

@ -0,0 +1,29 @@
import { Check } from 'lucide-react';
import { Checkbox as AriaCheckbox } from 'react-aria-components';
import { icon } from './icon';
import styles from './Checkbox.module.css';
interface Props {
label: string;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
}
/**
* A parameter of a form that is on or off.
*
* Not `Toggle`, and the difference is not cosmetic: a toggle button is a control that DOES
* something when pressed (`aria-pressed`), while this one carries a value the form sends later.
* Reading a `checkbox` the screen reader says "checked", which is what a form option is.
*/
export function Checkbox({ label, isSelected, onChange }: Props) {
return (
<AriaCheckbox className={styles.checkbox} isSelected={isSelected} onChange={onChange}>
<span className={styles.box} aria-hidden="true">
<Check {...icon} className={styles.mark} />
</span>
{label}
</AriaCheckbox>
);
}

View file

@ -0,0 +1,36 @@
.progress {
display: grid;
gap: var(--space-2);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.label {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.value {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
/* The same 3px track a chapter's progress wears in the tree: one quantity, one shape. */
.track {
overflow: hidden;
height: 3px;
border-radius: var(--radius-bar);
background-color: var(--color-separator);
}
.fill {
display: block;
width: calc(var(--progress) * 100%);
height: 100%;
border-radius: var(--radius-bar);
background-color: var(--color-text-secondary);
}

View file

@ -0,0 +1,32 @@
import { Label, ProgressBar as AriaProgressBar } from 'react-aria-components';
import styles from './ProgressBar.module.css';
interface Props {
label: string;
/** A share from 0 to 1. */
value: number;
/** The value spelled out: the caller knows the unit, this primitive does not. */
valueLabel: string;
}
/**
* How far a long operation has got.
*
* Determinate only, and deliberately so: an indeterminate bar is a stripe that travels forever, and
* this shell photographs itself the screenshot cycle waits for every animation to come to rest
* (scripts/shot.mjs). A share that is not known is a state for words, not for a bar.
*/
export function ProgressBar({ label, value, valueLabel }: Props) {
return (
<AriaProgressBar className={styles.progress} value={value * 100} valueLabel={valueLabel}>
<div className={styles.head}>
<Label className={styles.label}>{label}</Label>
<span className={styles.value}>{valueLabel}</span>
</div>
<div className={styles.track}>
<span className={styles.fill} style={{ '--progress': value }} />
</div>
</AriaProgressBar>
);
}

View file

@ -0,0 +1,69 @@
/* The field's own look is the shared Field.module.css; here only the trigger, the popup and the
rows of the list. */
.select {
display: grid;
gap: var(--space-2);
}
.label {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.trigger {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
}
.trigger[data-focus-visible],
.trigger[data-open] {
border-color: var(--color-accent);
outline: none;
}
.value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chevron {
flex: none;
color: var(--color-text-muted);
}
/* The popup stands on the modal surface and not on the panel one: it is opened from a window that
lies above the shell, and one step of surface is what separates them (tokens.css). */
.popover {
width: var(--trigger-width);
border: 1px solid var(--color-border);
padding: var(--space-2);
border-radius: var(--radius-control);
background-color: var(--color-elevated);
}
.list {
display: grid;
outline: none;
}
.option {
display: flex;
align-items: center;
height: var(--row-height);
padding-inline: var(--space-4);
border-radius: var(--radius-control);
color: var(--color-text);
cursor: default;
}
.option[data-hovered],
.option[data-focused] {
background-color: var(--color-raised);
}
.option[data-selected] {
background-color: var(--color-selected);
}

View file

@ -0,0 +1,58 @@
import { ChevronDown } from 'lucide-react';
import {
Button,
Label,
ListBox,
ListBoxItem,
Popover,
Select as AriaSelect,
SelectValue,
} from 'react-aria-components';
import field from './Field.module.css';
import { icon } from './icon';
import styles from './Select.module.css';
export interface Option {
id: string;
label: string;
}
interface Props {
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
}
/**
* A choice out of a short fixed list the language pair of a book.
*
* A native `<select>` would have been the cheaper answer, and it is refused for the reason the
* stack pins one primitive library: its popup is drawn by the operating system, so it is the one
* control in the application that could not be dressed in the tokens (STACK_DECISIONS §2).
* Everything a native one gives for free the keyboard, type-ahead, the accessible name, closing
* on Escape is the library's here, not ours.
*/
export function Select({ label, options, value, onChange }: Props) {
return (
<AriaSelect
className={styles.select}
selectedKey={value}
onSelectionChange={(key) => {
onChange(String(key));
}}
>
<Label className={styles.label}>{label}</Label>
<Button className={`${field.input} ${field.framed} ${styles.trigger}`}>
<SelectValue className={styles.value} />
<ChevronDown {...icon} className={styles.chevron} />
</Button>
<Popover className={styles.popover}>
<ListBox className={styles.list} items={options}>
{(option) => <ListBoxItem className={styles.option}>{option.label}</ListBoxItem>}
</ListBox>
</Popover>
</AriaSelect>
);
}

View file

@ -0,0 +1,68 @@
.slider {
display: grid;
gap: var(--space-2);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.label {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.value {
color: var(--color-text);
}
/* The track is the height of a chapter's progress bar: the same quantity is drawn the same way
everywhere in the shell. */
.track {
position: relative;
display: flex;
height: var(--row-height);
align-items: center;
}
.track::before {
position: absolute;
right: 0;
left: 0;
height: 3px;
border-radius: var(--radius-bar);
background-color: var(--color-separator);
content: '';
}
.fill {
position: absolute;
left: 0;
width: calc(var(--progress) * 100%);
height: 3px;
border-radius: var(--radius-bar);
background-color: var(--color-text-secondary);
}
.thumb {
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: var(--radius-round);
background-color: var(--color-text);
}
.thumb[data-dragging] {
background-color: var(--color-accent);
}
.thumb[data-focus-visible] {
outline: 1px solid var(--color-accent);
outline-offset: 2px;
}
.hint {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -0,0 +1,72 @@
import { useId } from 'react';
import {
Label,
Slider as AriaSlider,
SliderOutput,
SliderThumb,
SliderTrack,
} from 'react-aria-components';
import styles from './Slider.module.css';
interface Props {
label: string;
/** The value in the caller's own unit; this primitive knows nothing about what it counts. */
value: number;
min: number;
max: number;
onChange: (value: number) => void;
/** The value spelled out — the caller formats it, because only it knows the unit. */
valueLabel: string;
/** The line under the scale: what the chosen value will do. */
hint?: string;
}
/**
* A scale for choosing a bounded quantity.
*
* The bounds come from the caller and are NOT clamped here: they arrive already trimmed by the
* platform, and a second clamp on the client would be a second copy of a policy that lives on the
* server (contract, `CeilingBounds`).
*/
export function Slider({ label, value, min, max, onChange, valueLabel, hint }: Props) {
// The line under the scale is a description OF THE SCALE, and saying so is the only way it
// reaches a screen reader: the thumb is the control, and it came out with an empty
// `aria-describedby` (measured in the browser).
const hintId = useId();
return (
<AriaSlider
className={styles.slider}
value={value}
minValue={min}
maxValue={max}
onChange={onChange}
>
<div className={styles.head}>
<Label className={styles.label}>{label}</Label>
{/* The output is the only place the UNIT is spoken, and that is a measurement rather than
a design: the thumb announces a bare `aria-valuetext="60"`, composed by the library from
the value itself, and a value text handed to `SliderThumb` does not reach the input at
all (probed in the browser). The output, though, is a live region (`status`) bound to the
scale, so every change is read out with the unit the caller wrote into it. */}
<SliderOutput className={styles.value}>{valueLabel}</SliderOutput>
</div>
<SliderTrack className={styles.track}>
{({ state }) => (
<>
<span className={styles.fill} style={{ '--progress': state.getThumbPercent(0) }} />
<SliderThumb
className={styles.thumb}
aria-describedby={hint === undefined ? undefined : hintId}
/>
</>
)}
</SliderTrack>
{hint !== undefined && (
<p className={styles.hint} id={hintId}>
{hint}
</p>
)}
</AriaSlider>
);
}

View file

@ -52,7 +52,13 @@ interface Props {
* open tab does not move), so a read per focused row is a read of chapters nobody asked for.
*/
onPreload?: (id: string) => void;
defaultExpandedIds?: string[];
/**
* Which branches are open. CONTROLLED, because the tree is not the only thing that opens them:
* a chapter chosen in the go-to palette may sit inside a folded group, and an uncontrolled tree
* has no way of being told about it.
*/
expandedIds: Set<string>;
onExpandedChange: (ids: Set<string>) => void;
}
/**
@ -67,7 +73,8 @@ export function Tree({
onSelect,
onActivate,
onPreload,
defaultExpandedIds,
expandedIds,
onExpandedChange,
}: Props) {
// The level indent comes off the library's own data-level, so nesting is not counted by hand.
const renderNode = (node: TreeNode) => (
@ -133,7 +140,10 @@ export function Tree({
onSelectionChange={(keys) => {
if (keys !== 'all') for (const key of keys) onSelect(String(key));
}}
defaultExpandedKeys={defaultExpandedIds}
expandedKeys={expandedIds}
onExpandedChange={(keys) => {
onExpandedChange(new Set([...keys].map(String)));
}}
>
{renderNode}
</AriaTree>