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:
parent
3e6764231c
commit
267aa3569c
55 changed files with 3877 additions and 206 deletions
|
|
@ -61,9 +61,10 @@ npm run scenes # сценарии интеракций: клик → ка
|
||||||
вход в контракт, написанный до него. Всё содержимое ушло в спеку и спутник ниже; остаётся как
|
вход в контракт, написанный до него. Всё содержимое ушло в спеку и спутник ниже; остаётся как
|
||||||
аудиторский след (как каждая просьба выводилась из кода движка), место ему в `docs/archive/`.
|
аудиторский след (как каждая просьба выводилась из кода движка), место ему в `docs/archive/`.
|
||||||
- [`docs/api-contract/openapi.yaml`](docs/api-contract/openapi.yaml) — **сам контракт v0,
|
- [`docs/api-contract/openapi.yaml`](docs/api-contract/openapi.yaml) — **сам контракт v0,
|
||||||
OpenAPI 3.1: нормативная поверхность.** Линтуется spectral шестым шагом `npm run check`,
|
OpenAPI 3.1: нормативная поверхность.** Линтуется spectral шагом `npm run check`, из неё
|
||||||
из неё генерится `src/api/schema.ts`. Черновик на ратификацию: дом готовой копии —
|
генерится `src/api/schema.ts`. ⚠ **Не черновик** (исправлено S4 14.08): контракт ратифицирован
|
||||||
`docs/architecture/14-api-contract/`, зона оркестратора.
|
D39.99, эта копия — байт-зеркало канона `docs/architecture/14-api-contract/`; правит её
|
||||||
|
фронт-сессия, переносит в канон диффом оркестратор при лендинге (прецедент Ф-47/0.2.2).
|
||||||
- [`docs/API_CONTRACT_DRAFT.md`](docs/API_CONTRACT_DRAFT.md) — спутник спеки: откуда взято
|
- [`docs/API_CONTRACT_DRAFT.md`](docs/API_CONTRACT_DRAFT.md) — спутник спеки: откуда взято
|
||||||
каждое решение (выведено из кода движка · предложено фронтом · открыто), обоснования,
|
каждое решение (выведено из кода движка · предложено фронтом · открыто), обоснования,
|
||||||
зависимости и двенадцать открытых вопросов. При расхождении по форме побеждает YAML,
|
зависимости и двенадцать открытых вопросов. При расхождении по форме побеждает YAML,
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -25,9 +25,9 @@
|
||||||
| [../../docs/README.md](../../docs/README.md) | карта: где что лежит и что чем перекрыто | первым делом |
|
| [../../docs/README.md](../../docs/README.md) | карта: где что лежит и что чем перекрыто | первым делом |
|
||||||
| [../../docs/product-requirements.md](../../docs/product-requirements.md) | реестр ПТ-1..ПТ-34; **ПТ-33 и ПТ-34 — жёсткие инварианты интерфейса**, ПТ-21 задаёт якорь чтения | до первого экрана |
|
| [../../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` требует читать его **перед любым кодом стыка** | до любого кода данных |
|
| [../../docs/research/23-engine-platform-seam.md](../../docs/research/23-engine-platform-seam.md) | форма шва движок↔платформа; `docs/README.md` требует читать его **перед любым кодом стыка** | до любого кода данных |
|
||||||
| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет: строки **95** (контракт API) · **99–103** (прогресс, манифест, таблица подписи, trace, эмиттер) · **49** (annot-v1) · **54** (масштаб) | перед планированием этапа |
|
| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет. ⚠ **Актуализировано S4 14.08:** строка 95 (контракт API) ЗАКРЫТА ратификацией D39.99, строки 99/100/103 построены (D39.122/131) — таблица ниже больше не подаёт их как «чего нет»; живые для фронта — **101** (машиночитаемая таблица подписи, вход S5) и **169** (экспорт банка) | перед планированием этапа |
|
||||||
| [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных |
|
| [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-номер, банк, голден, юнит) | при первом непонятном слове |
|
| [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове |
|
||||||
|
|
||||||
Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности
|
Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности
|
||||||
|
|
@ -39,8 +39,10 @@
|
||||||
|
|
||||||
**С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс
|
**С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс
|
||||||
на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится
|
на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится
|
||||||
(`research/23` §4, §0). Между фронтом и движком стоит платформа, у которой пока ноль строк кода
|
(`research/23` §4, §0). Между фронтом и движком стоит платформа. ⚠ **Актуализировано S4 14.08:**
|
||||||
(`../../platform/BACKLOG.md` П-1).
|
«у платформы пока ноль строк кода» устарело — P5 принята D39.130, живы вход OIDC, библиотека,
|
||||||
|
кредитный леджер, раннер и `POST /books`; чего у неё нет, смотреть в `../../platform/BACKLOG.md`,
|
||||||
|
а не здесь.
|
||||||
|
|
||||||
Проводов два, и их легко перепутать:
|
Проводов два, и их легко перепутать:
|
||||||
|
|
||||||
|
|
@ -204,9 +206,12 @@ CJK-шрифт не ставим: иероглифы отдаём системн
|
||||||
добавление нового состояния главы или нового вида замечания правит **один** файл.
|
добавление нового состояния главы или нового вида замечания правит **один** файл.
|
||||||
Правит три — структура неверна, переделать до движения дальше.
|
Правит три — структура неверна, переделать до движения дальше.
|
||||||
|
|
||||||
Ответ этой структуры: состояние главы живёт как строка типа в `src/api/types.ts` и
|
Ответ этой структуры (⚠ актуализировано S4 14.08 — `src/api/types.ts` удалён ещё в S3, типы
|
||||||
как одна запись в карте отображения `src/features/chapters/`. Цвет индикатора — токен.
|
приходят из спеки): состояние книги живёт **одной записью в `src/api/vocabulary.ts`** — там же его
|
||||||
Новый вид замечания — запись в карте видов, компонент выноски не трогается.
|
слово, тон и то, что с ним можно делать (`startable`, `intake`); экраны эту запись читают и ничего
|
||||||
|
о списке значений не знают. Цвет — токен. Новый вид замечания — запись в карте видов, компонент
|
||||||
|
выноски не трогается. Проверено S4: добавление свойства «книгу в этом состоянии можно запустить»
|
||||||
|
тронуло один файл, а новое состояние без слова роняет `tsc` на этой же карте.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@
|
||||||
|
|
||||||
**Бэкенда пока нет** — точнее, движок перевода есть, но HTTP-API к нему ещё не построено. Ты работаешь на моках (см. §6). Это не помеха: контракт данных зафиксирован ниже, замена моков на реальные запросы будет точечной.
|
**Бэкенда пока нет** — точнее, движок перевода есть, но HTTP-API к нему ещё не построено. Ты работаешь на моках (см. §6). Это не помеха: контракт данных зафиксирован ниже, замена моков на реальные запросы будет точечной.
|
||||||
|
|
||||||
> ⚠ **Поправка канона №2 (оркестратор, 04.08, D39.99/100/101):** контракт API v0 РАТИФИЦИРОВАН (`docs/architecture/14-api-contract/` — OpenAPI 3.1, типы генерятся из спеки), строка 95 закрыта; скелет платформы P0 существует (ждёт приёмки); слой данных S3 строится ПО КОНТРАКТУ, не по гипотезам.
|
> ⚠ **Поправка канона №2 (оркестратор, 04.08, D39.99/100/101):** контракт API v0 РАТИФИЦИРОВАН (`docs/architecture/14-api-contract/` — OpenAPI 3.1, типы генерятся из спеки), строка 95 закрыта; слой данных S3 строится ПО КОНТРАКТУ, не по гипотезам.
|
||||||
|
> ⚠ **Поправка S4 (14.08):** «скелет платформы P0 существует, ждёт приёмки» устарело — **P5 принята (D39.130)**, живой `POST /books` построен, и путь загрузки книги S4 стоит на нём.
|
||||||
> ⚠ **Поправка канона (фронт-сессия S1, 02.08).** Формулировка выше читается как «однажды у движка появится HTTP-API». Это не так, и ошибка дорогая: **API будет у платформы, не у движка**, а движок фронту не виден вообще. Прежде чем писать слой данных, прочитай `FRONTEND_PLAN.md` §0.1–0.2 (карта канона и два провода) и первоисточник `docs/research/23-engine-platform-seam.md`. Там же цена ошибки: моки, снятые не с того уровня контракта, разойдутся с API — ровно то, ради предотвращения чего заведена строка 95 единого бэклога.
|
> ⚠ **Поправка канона (фронт-сессия S1, 02.08).** Формулировка выше читается как «однажды у движка появится HTTP-API». Это не так, и ошибка дорогая: **API будет у платформы, не у движка**, а движок фронту не виден вообще. Прежде чем писать слой данных, прочитай `FRONTEND_PLAN.md` §0.1–0.2 (карта канона и два провода) и первоисточник `docs/research/23-engine-platform-seam.md`. Там же цена ошибки: моки, снятые не с того уровня контракта, разойдутся с API — ровно то, ради предотвращения чего заведена строка 95 единого бэклога.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -141,10 +141,14 @@
|
||||||
### Одна команда проверки
|
### Одна команда проверки
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run check # prettier --check → eslint → tsc --noEmit → vitest run
|
npm run check # prettier → eslint → stylelint → spectral (контракт) → tsc → vitest
|
||||||
npm run check:full # + vite build + e2e
|
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'ов
|
CI вызывает **именно их**, а не дублирует список инструментов. Path-фильтры на уровне job'ов
|
||||||
(правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой
|
(правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой
|
||||||
`contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled')`. ~~Git-хуков в MVP нет~~ —
|
`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` | роутер-библиотеку не тянуть |
|
| HTTP | стандартный `net/http` + `ServeMux` | роутер-библиотеку не тянуть |
|
||||||
| Postgres | `pgx v5.10.0` | нижняя допустимая граница 5.9.2, **не** 5.9.0 |
|
| Postgres | `pgx v5.10.0` | нижняя допустимая граница 5.9.2, **не** 5.9.0 |
|
||||||
| Миграции | `goose v3.27.3` | как библиотека, `embed.FS`, `WithLocker` |
|
| Миграции | `goose v3.27.3` | как библиотека, `embed.FS`, `WithLocker` |
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ openapi: 3.1.0
|
||||||
|
|
||||||
info:
|
info:
|
||||||
title: TextMachine API
|
title: TextMachine API
|
||||||
version: 0.2.2
|
version: 0.2.3
|
||||||
summary: Ratified contract between the frontend and the TextMachine platform (D39.99).
|
summary: Ratified contract between the frontend and the TextMachine platform (D39.99).
|
||||||
description: |
|
description: |
|
||||||
**RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in
|
**RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in
|
||||||
|
|
@ -114,8 +114,40 @@ paths:
|
||||||
operationId: createBook
|
operationId: createBook
|
||||||
summary: Add a book.
|
summary: Add a book.
|
||||||
description: |
|
description: |
|
||||||
Accepts the file and the properties declared by the user. Responds immediately; the book
|
Accepts the file and the properties declared by the user.
|
||||||
enters `uploading` and parsing is a separate visible step.
|
|
||||||
|
**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:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|
@ -123,12 +155,14 @@ paths:
|
||||||
schema: { $ref: '#/components/schemas/BookIntake' }
|
schema: { $ref: '#/components/schemas/BookIntake' }
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Book accepted.
|
description: Book accepted; it is being parsed.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Book' }
|
schema: { $ref: '#/components/schemas/Book' }
|
||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'408': { $ref: '#/components/responses/RequestTimeout' }
|
||||||
'413': { $ref: '#/components/responses/TooLarge' }
|
'413': { $ref: '#/components/responses/TooLarge' }
|
||||||
|
|
||||||
/books/{bookId}:
|
/books/{bookId}:
|
||||||
|
|
@ -412,6 +446,11 @@ paths:
|
||||||
|
|
||||||
**Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a
|
**Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a
|
||||||
complete set.
|
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:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Resume accepted.
|
description: Resume accepted.
|
||||||
|
|
@ -421,6 +460,7 @@ paths:
|
||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
'404': { $ref: '#/components/responses/NotFound' }
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
'409': { $ref: '#/components/responses/Conflict' }
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
|
||||||
/usage:
|
/usage:
|
||||||
get:
|
get:
|
||||||
|
|
@ -601,11 +641,20 @@ components:
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/problem+json:
|
||||||
schema: { $ref: '#/components/schemas/Problem' }
|
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:
|
ServiceUnavailable:
|
||||||
description: |
|
description: |
|
||||||
The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a
|
The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting or
|
||||||
run requires the engine seam to be fully configured, and answering with any other code
|
continuing a run requires the engine seam to be fully configured, and answering with any
|
||||||
would misname the state. Temporary by nature — retry later; no Retry-After is promised.
|
other code would misname the state. Temporary by nature — retry later; no Retry-After is
|
||||||
|
promised.
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/problem+json:
|
||||||
schema: { $ref: '#/components/schemas/Problem' }
|
schema: { $ref: '#/components/schemas/Problem' }
|
||||||
|
|
@ -725,6 +774,31 @@ components:
|
||||||
- rejected
|
- rejected
|
||||||
- failed
|
- 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:
|
Book:
|
||||||
type: object
|
type: object
|
||||||
description: A book in the library.
|
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.
|
description: Size in characters. Not an engine field; the platform knows it from intake.
|
||||||
added_at: { type: string, format: date-time }
|
added_at: { type: string, format: date-time }
|
||||||
status: { $ref: '#/components/schemas/BookStatus' }
|
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' }
|
progress: { $ref: '#/components/schemas/Progress' }
|
||||||
note_count: { type: integer, minimum: 0 }
|
note_count: { type: integer, minimum: 0 }
|
||||||
|
|
||||||
|
|
@ -810,13 +896,29 @@ components:
|
||||||
|
|
||||||
BookIntake:
|
BookIntake:
|
||||||
type: object
|
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]
|
required: [file, source_lang, target_lang]
|
||||||
properties:
|
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:
|
file:
|
||||||
type: string
|
type: string
|
||||||
format: binary
|
format: binary
|
||||||
description: Book file.
|
description: Book file. The LAST part of the form.
|
||||||
source_lang: { $ref: '#/components/schemas/LangCode' }
|
source_lang: { $ref: '#/components/schemas/LangCode' }
|
||||||
target_lang: { $ref: '#/components/schemas/LangCode' }
|
target_lang: { $ref: '#/components/schemas/LangCode' }
|
||||||
genre: { type: string }
|
genre: { type: string }
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,29 @@
|
||||||
> платформы, в границах ПТ-33, на утверждение владельцем по отчёту; (3) Ф-52 — `--color-danger`
|
> платформы, в границах ПТ-33, на утверждение владельцем по отчёту; (3) Ф-52 — `--color-danger`
|
||||||
> осветлить на ступень до ≥3:1 силами S4 (закрыть замером).
|
> осветлить на ступень до ≥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.** Долги слоя данных закрыты тестами, падающими на до-фиксном
|
> **Запись фронт-сессии S3.7, 10.08.** Долги слоя данных закрыты тестами, падающими на до-фиксном
|
||||||
> коде (Ф-49…Ф-51), контракт зонной копии бампнут до **0.2.2** (`BankTerm.sense` обязателен),
|
> коде (Ф-49…Ф-51), контракт зонной копии бампнут до **0.2.2** (`BankTerm.sense` обязателен),
|
||||||
> строки интерфейса уехали в КАТАЛОГ (`src/i18n/`, 147 ключей, механизм — `@internationalized/string`,
|
> строки интерфейса уехали в КАТАЛОГ (`src/i18n/`, 147 ключей, механизм — `@internationalized/string`,
|
||||||
|
|
@ -167,7 +190,8 @@
|
||||||
S3.5 (фикс-пак оболочки: 24 замечания владельца, сценарии интеракций, пере-замер полос),
|
S3.5 (фикс-пак оболочки: 24 замечания владельца, сценарии интеракций, пере-замер полос),
|
||||||
**S3.6 (второй круг: палитра по замеру референсов, модель вкладок fleet_2, банк таблицей,
|
**S3.6 (второй круг: палитра по замеру референсов, модель вкладок fleet_2, банк таблицей,
|
||||||
ручка из vojo дословно)**, **S3.7 (долги слоя данных: контракт 0.2.2, каталог i18n 157 ключей,
|
ручка из 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 однажды остановилась, а не как диспозиция.
|
- ⚠ **АБЗАЦ НИЖЕ ИСТОРИЧЕСКИЙ (04.08), читать как хронику:** замок с тех пор открыт (контракт ратифицирован D39.99), S3 исполнен и принят D39.115. Оставлен, потому что объясняет, ПОЧЕМУ S3 однажды остановилась, а не как диспозиция.
|
||||||
- **S3 НЕ сделана: замок на входе закрыт, проверено исполнением 04.08.** Контракта API v0
|
- **S3 НЕ сделана: замок на входе закрыт, проверено исполнением 04.08.** Контракта API v0
|
||||||
по-прежнему нет (`docs/architecture/14-api-contract.md` отсутствует; строка 95 единого
|
по-прежнему нет (`docs/architecture/14-api-contract.md` отсутствует; строка 95 единого
|
||||||
|
|
@ -390,12 +414,333 @@ NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фрон
|
||||||
| В-8 | **Какое слово о состоянии книги стоит в дереве.** Замечание 23 потребовало бейджу ФИКС-место, и оно сделано; но две метки словаря в него не помещаются и усекаются с тултипом — «остановлена: лимиты» (19 знаков) и ветка неизвестного «неизвестное состояние» (21). Укоротить их сессия НЕ вправе: фраза паузы зарезервирована за владельцем (В-6), и первый заход S3.5 её укоротил и был откачен собственным ревью. Варианты: (а) оставить усечение с тултипом, (б) владелец даёт короткое слово для дерева, а полная фраза остаётся в статус-полосе, (в) расширить колонку бейджа за счёт названия книги | продуктовая формулировка, которую видит пользователь |
|
| В-8 | **Какое слово о состоянии книги стоит в дереве.** Замечание 23 потребовало бейджу ФИКС-место, и оно сделано; но две метки словаря в него не помещаются и усекаются с тултипом — «остановлена: лимиты» (19 знаков) и ветка неизвестного «неизвестное состояние» (21). Укоротить их сессия НЕ вправе: фраза паузы зарезервирована за владельцем (В-6), и первый заход S3.5 её укоротил и был откачен собственным ревью. Варианты: (а) оставить усечение с тултипом, (б) владелец даёт короткое слово для дерева, а полная фраза остаётся в статус-полосе, (в) расширить колонку бейджа за счёт названия книги | продуктовая формулировка, которую видит пользователь |
|
||||||
| В-7 | **Физический масштаб интерфейса на мониторе.** Замечание 1 говорит «масштабы великоваты, особенно на 27″ 2K, на 24″ терпимее». Замер при вьюпорте референса расхождения с Fleet не нашёл (§5.5 плана): промежутки, ширины панелей, шаг строки и высота вкладки совпадают. Значит речь про физический размер на конкретном экране, а он зависит от масштабирования ОС, и «просто уменьшить токены» означало бы уехать от референса, который владелец принял. Правильное лекарство — ручка «плотность интерфейса» в настройках (3 ступени), но она требует перевода ВСЕХ размерных токенов в rem и пере-замера, то есть отдельного прохода (Ф-36). Вопрос: нужна ли ручка, и какой масштаб ОС стоит на 27″ 2K — по нему видно, во сколько раз расходится физика | продуктовое решение и цена отдельного пакета |
|
| В-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`), и каркасный пункт настроек честно говорит «сейчас интерфейс только русский» | продуктовое решение и обещание пользователю |
|
| В-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. Сейчас взят объём под проверку плотности и не больше; нужно ли убрать совсем и заменить синтетикой — решать владельцу | юридика |
|
| В-2 | **Авторское право на текст фикстур.** Витрина использует короткую выдержку из настоящего прогона (蛊真人). Правило проекта уже есть: `eval/.gitignore:4` держит копирайтные тексты вне git. Сейчас взят объём под проверку плотности и не больше; нужно ли убрать совсем и заменить синтетикой — решать владельцу | юридика |
|
||||||
| В-4 | **Чем подписывать главу в дереве, если заголовок — ровно «Глава N».** Титул движок рендерит детерминистически из шаблона пары (`configs/langpacks/zh-ru/heading.txt`: `template Глава {n}`), а подзаголовок остаётся телом текста и названием НЕ является. На 2284 разделах это дерево из 2284 одинаковых по форме строк. Оставить так, показывать номер иначе или просить у контракта что-то ещё — продуктовое решение. Артефакт §1.4 и К-3 | продуктовая формулировка, которую видит пользователь |
|
| В-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 единого бэклога | продуктовое: обещание срока пользователю |
|
| В-5 | **Показывать ли оценку времени до конца прогона.** У движка поле есть (`status.go` `eta_seconds`, «secondary: mean fresh-call throughput × remaining»), запретом §4.8 оно не покрыто (это не деньги), но и не запрошено ни одним разделом промта. Решать ДО формы прогресса, иначе поле придётся вводить задним числом в уже нарисованную полосу. Артефакт К-6, резерв строки 54 единого бэклога | продуктовое: обещание срока пользователю |
|
||||||
|
|
||||||
## Хроника
|
## Хроника
|
||||||
|
|
||||||
|
### 14.08 — девятая фронт-сессия (S4): загрузка книги, запуск прогона, библиотека на масштабе, контракт 0.2.3
|
||||||
|
|
||||||
|
Вход: промт `S4_SESSION_PROMPT.md` (семь задач). Дом прежний — мир моков по контракту; живого стенда
|
||||||
|
платформы не поднимала (§10 промта — опция, не гейт), и это отдельной строкой в «чего НЕ сделала».
|
||||||
|
|
||||||
|
#### 1. Контракт 0.2.3 — пять правок в зонной копии
|
||||||
|
|
||||||
|
`info.version` 0.2.2 → **0.2.3**, `npm run contract` (spectral, порог warn) зелёный, типы
|
||||||
|
перегенерены (`npm run contract:types`), тест дрейфа держит равенство спеки и `schema.ts`.
|
||||||
|
|
||||||
|
| Правка | Что записано | Чем грунтовано |
|
||||||
|
|---|---|---|
|
||||||
|
| **PD-172** | у `createBook` — часть `file` идёт ПОСЛЕДНЕЙ, поле после файла = 400; причина одной строкой (платформа читает форму потоково и обязана увидеть языки до тела) | `platform/internal/httpapi/v0.go:311-378` — потоковый `MultipartReader`, ветка «языки пусты на приходе файла → 400» |
|
||||||
|
| **PD-173** | новая схема `RejectReason` (`source_unreadable` · `not_configured` · `parser_unavailable`) и **необязательное** `Book.reject_reason` | `platform/internal/books/parse.go:51-66` — те же три имени + четвёртое `storage_unavailable`, которое на книге не хранится вовсе |
|
||||||
|
| **PD-174** | названы классы, которые провод уже даёт: **404** на `POST /books` у инстанса без интейка, **503** у `resume` на деплое без маркер-команды/шаблона потолка | 404 — `v0.go:72-77` (маршрут не монтируется при `Intake == nil`); 503 — `reconcile.go:715-722` зовёт `s.runnable()`, а `v0.go:540-547` мапит его ошибки в 503 общей для старта и resume функцией `fail` |
|
||||||
|
| **PD-180** | описание `createBook` честное: **201 несёт `parsing`**, `uploading` наблюдаем только параллельным чтением библиотеки; перечень отказов — 400 (>16 частей, поле >1 КиБ, поле после файла, нет файла), 404, 408, 413 (числа порогов в спеку не зашиты — они принадлежат деплою) | `books.go:110-180`: строка книги пишется ДО тела (оттого `uploading` виден вторым чтением), а ответ формируется после `StartParsing`, то есть уже в `parsing`; пороги — `v0.go:296-302`, 408 — `v0.go:410-417` |
|
||||||
|
| **`BookIntake.title`** | необязательное поле: пусто/нет = «название даст разбор» (сегодня платформа берёт имя файла), заполнено = задано рукой и разбор не перепишет | `books.go:136` `titleFrom(in.Filename)`; ратификация — оркестратор 14.08 (исполнение замечания 12 S3.5) |
|
||||||
|
|
||||||
|
**Расхождение с промтом, названное вслух (код первичен).** Промт описывает enum PD-173 как
|
||||||
|
«словарь платформы из трёх значений». Проверка по коду: значений в словаре платформы **четыре**, а
|
||||||
|
на книгу сегодня попадают **два**. `storage_unavailable` не хранится никогда (комментарий
|
||||||
|
`parse.go:62-65`), `not_configured` в `books.reject_reason` не доходит тоже — `defer_` для него
|
||||||
|
никогда не терминален (`parse.go:180-205`, `waitsForTheDeployment`), книга остаётся в `parsing` и
|
||||||
|
ждёт человека. В спеку внесены все три имени промта: enum — это словарь ВЕРСИИ, клиент обязан
|
||||||
|
терпеть незнакомое значение, а `not_configured` — состояние, которое платформа уже умеет назвать и
|
||||||
|
которое станет терминальным ровно в тот день, когда владелец контракта решит, что упавшая на
|
||||||
|
деплой книга должна умирать, а не ждать. Фраза для него написана, и это цена, которую я назвала:
|
||||||
|
одна из трёх фраз сегодня недостижима на живой платформе.
|
||||||
|
|
||||||
|
**`reject_reason` сделан необязательным, а не required+nullable, как `Run.paused_reason`.** Причина
|
||||||
|
записана в самой спеке: причина отказа осмысленна ровно в одном состоянии из одиннадцати, а деплой
|
||||||
|
старше этого минора не пришлёт её вовсе — значит «поля нет» и «поле `null`» означают ОДНО И ТО ЖЕ
|
||||||
|
(«причина неизвестна») и рисуются одинаково. Это противоположность случая `sense` (0.2.2), где две
|
||||||
|
формы несли РАЗНЫЕ факты; прецедент внутри той же спеки — `Usage.paused_reason`.
|
||||||
|
|
||||||
|
#### 2. Загрузка книги (Ф-26, половина) — механизм и почему он такой
|
||||||
|
|
||||||
|
- **XHR, а не `fetch`, и это не вкус.** Стандарт Fetch отдаёт прогресс ОТВЕТА и не имеет
|
||||||
|
эквивалента для тела запроса; единственный стандартный способ следить за отправкой — тело-поток
|
||||||
|
с `duplex: 'half'`, он только HTTP/2 и только Chromium, то есть на Firefox и Safari загрузка бы
|
||||||
|
просто не работала. `XMLHttpRequest.upload` — совместимый механизм, и он легален ровно в одном
|
||||||
|
месте: сетевой гейт ESLint пускает его только в `src/api/`. Шов не протёк: экран получает промис
|
||||||
|
и поток чисел (`src/api/upload.ts`).
|
||||||
|
- **Порядок частей формы — правило провода**, а не привычка клиента: `title · source_lang ·
|
||||||
|
target_lang · genre · file`. Держится с двух сторон — тест сверяет ключи формы, которые ушли на
|
||||||
|
провод, а фикстура ОТКАЗЫВАЕТ форме, где файл не последний (та же проверка, что у платформы).
|
||||||
|
- **Три состояния отправки**, и первое из них — измеренная неожиданность. В браузере мок-сеть это
|
||||||
|
service worker, а Chromium **не сообщает прогресс отправки для запроса, который отвечает
|
||||||
|
воркер**: проба напечатала `loadstart` с верным total (`0 / 300379`) и НИ ОДНОГО `progress`
|
||||||
|
после. Поэтому экран не рисует долю, которой нет: «Файл отправляется: 300 кБ» без процента.
|
||||||
|
Доля и вторая фаза («файл ушёл целиком, ждём ответа») живут для настоящей платформы и доказаны
|
||||||
|
тестом `src/api/upload.test.ts` — там мок перехватывает сам `XMLHttpRequest` и события шлёт.
|
||||||
|
- **Подтверждение видимое** (§3.2): после 201 окно показывает карточку из полей КОНТРАКТА —
|
||||||
|
название, языки, знаки, состояние («разбор»), — и `chapter_count` в ней намеренно нет: до конца
|
||||||
|
разбора он ноль, и печатать его значило бы ответить на «сколько разделов» неправдой.
|
||||||
|
- **Отказы — состояния формы, не алерты**: фраза берётся из `problem+json` платформы (контракт
|
||||||
|
кладёт слова на сервер), а СОВЕТ пишет клиент, потому что провод его не несёт и он разный:
|
||||||
|
«проверьте файл» ≠ «повторите позже» ≠ «этот сервис книги не принимает».
|
||||||
|
|
||||||
|
#### 3. Запуск прогона (остаток строки 126)
|
||||||
|
|
||||||
|
Форма читает `GET /books/{id}/run-options` при ОТКРЫТИИ (потолок принадлежит аккаунту и уезжает,
|
||||||
|
пока книга стоит), шкала строится из `CeilingBounds` в ГЛАВАХ, предустановка — `default_chapters`
|
||||||
|
платформы. Клиент не клампит второй раз. `max_chapters: 0` — не пустая шкала, а честное
|
||||||
|
исчерпанное состояние. `409` на старте перечитывает bounds, и выбранное значение возвращается к
|
||||||
|
новой предустановке ВО ВРЕМЯ РЕНДЕРА, а не размонтированием формы: размонтировав, я бы убрала с
|
||||||
|
экрана ту самую фразу отказа, ради которой всё и затевалось. Галочка «остановиться на подписи
|
||||||
|
банка» — параметр ЭТОГО запуска. ⚠ **По умолчанию ВЫКЛЮЧЕНА, и это решение про сегодня, а не про
|
||||||
|
продукт** (нашло адверсариальное ревью): с включённой галочкой прогон доходит до `awaiting_bank` — и
|
||||||
|
дальше в этой сборке идти НЕКУДА: экран подписи это S5, у чтения банка вообще нет канала (Ф-43), а
|
||||||
|
`resume` отвечает 409, пока набор решений неполон. Предустановить её значило бы предустановить
|
||||||
|
тупик. Сама галочка остаётся — выбор принадлежит человеку и этому прогону (владелец, 02.08), — а
|
||||||
|
дефолт вернётся в тот день, когда S5 даст остановке куда идти.
|
||||||
|
|
||||||
|
**`paused` прожит, и оповещение — НЕ тост.** Решение и аргумент: тост — временный носитель
|
||||||
|
постоянного факта, он исчезает, а состояние остаётся; на десктопном однооконном экране статус-полоса
|
||||||
|
и карточка книги видны всегда; §3.8 запрещает экрану выглядеть тревожным, а всплывающее окно — самая
|
||||||
|
тревожная из форм. Поэтому остановка называется там, где стоит действие: фраза владельца в
|
||||||
|
статус-полосе И выноской на карточке книги, и обе не исчезают. Кадр `EventCeiling` + `status:
|
||||||
|
paused` в мире моков прожит целиком (сцена `intake`). `resume` как лекарство не предлагается нигде;
|
||||||
|
путь вперёд — НОВЫЙ прогон со своим потолком, и он на карточке есть.
|
||||||
|
|
||||||
|
#### 4. Библиотека на масштабе
|
||||||
|
|
||||||
|
- **Группировка по сотням** (хвост Ф-12): дерево свыше 200 разделов складывается в группы по 100
|
||||||
|
(«разделы 1–100»), 2284 раздела дают 23 строки. Аргумент — не кадр в секунду (плоское дерево
|
||||||
|
виртуализовано и стоит 31 строку в DOM), а полоса прокрутки: 59 618px хода и треть пикселя на
|
||||||
|
строку. Группа с выбранным разделом раскрывается сама (переход из палитры не должен терять
|
||||||
|
строку) и остаётся закрываемой рукой — раскрытие стало УПРАВЛЯЕМЫМ (`ui/Tree.tsx`).
|
||||||
|
- **Выбор книги в дереве построен** (§2 промта зоны — «клик по книге открывает её состояние»). До
|
||||||
|
S4 карточка всегда показывала `books[0]`, и половина статусов была недостижима на экране в
|
||||||
|
принципе. Побочный эффект найден axe в первой же сцене: строка книги теперь БЫВАЕТ выбранной, и
|
||||||
|
правило `[data-selected] .badge`, снятое в S3.7 как мёртвое, ожило — бейдж на синем полотне давал
|
||||||
|
3.52:1 при пороге 4.5, а тон-глиф книги 1.7:1 при пороге 3. Оба перекрашены, сторож контраста
|
||||||
|
выбранной строки теперь гоняется и на строке КНИГИ.
|
||||||
|
- **Все одиннадцать `BookStatus` прожиты**: в дереве — словом (фикстура держит книгу каждого
|
||||||
|
состояния), на карточке — те три, у которых есть что сказать сверх слова (`uploading`, `parsing`,
|
||||||
|
`rejected` с причиной и советом, `paused` с фразой владельца); проверено сценой `context`.
|
||||||
|
|
||||||
|
#### 5. Дыра, которую нашла постройка: конца интейка никто не объявляет
|
||||||
|
|
||||||
|
Живой канал принадлежит ПРОГОНУ, а книга в `uploading`/`parsing` прогона не имеет — значит «разбор
|
||||||
|
кончился» не приходит ничем. Без лечения книга висела бы в «разборе» до случайной
|
||||||
|
инвалидации. Сделано: опрос ровно на время интейка (`intakePollMs = 3000`, останавливается вместе
|
||||||
|
со статусом) плюс перечитывание ДЕРЕВА на выходе из интейка (`useIntakeEnd.ts`) — иначе список
|
||||||
|
разделов, прочитанный когда разделов не было, так и оставался пустым (поймано сценой, не
|
||||||
|
рассуждением). Это лечение, а не решение: правильным был бы кадр от контракта — **Ф-56**.
|
||||||
|
|
||||||
|
⚠ Названная цена опроса на фикстуре: в мире `showcase` книга `bk_11` стоит в `uploading` вечно (она
|
||||||
|
там ради того, чтобы состояние было видно на экране), поэтому библиотека на этом маршруте
|
||||||
|
опрашивается каждые три секунды бесконечно. На живой платформе так не бывает — брошенную загрузку
|
||||||
|
подметает свип через час (`books.UploadGrace`), — но фикстура этого не моделирует, и знать об этом
|
||||||
|
надо тому, кто будет мерить на `/showcase` что-нибудь чувствительное к сети.
|
||||||
|
|
||||||
|
#### 6. Диспозиции находок приёмки S3.7 — все девять несущих
|
||||||
|
|
||||||
|
| № | Диспозиция |
|
||||||
|
|---|---|
|
||||||
|
| 1 (high) дубль подстановки строк | **починено.** `scripts/scenes.mjs` больше не держит своего филлера: `say()` зовёт тот же `src/i18n/fill.ts`, что и приложение (харнесс уже умеет грузить `.ts`). Семантика пропуска стала одна |
|
||||||
|
| 2 отметка «перечитывание в пути» ставится без старта чтения | **починено, и дефект оказался хуже описанного.** Отметка снимается ровно тогда, когда кончается чтение, за которое она стоит (`resetQueries().finally`). Настоящая цена прежней версии: чтение, которое ПРОВАЛИЛОСЬ, оставляло запись пустой — и ключ замолкал до конца прогона. Тест `a key whose re-read failed is not silenced for the rest of the run` падает на до-фиксном коде |
|
||||||
|
| 3 `onResync` не чистит `asked` | **починено:** отметки уходят вместе со снимками. Тест написан так, чтобы ловить дефект В ОКНЕ (чтение задержано на 120 мс, кадр приходит, пока перечитывание в полёте) — с быстрым моком он зеленел бы и на дефекте |
|
||||||
|
| 4 клейм «resync не рвёт живой поток» | **клейм ОПРОВЕРГНУТ замером — S3.7 была неправа.** С задержанным ответом (120 мс) поток открывался ДВАЖДЫ: id прогона лежит внутри карточки книги, а reset её опустошает, и подписка снималась на время одного чтения. Починено удержанием id на время книги (`useRunStream`), тест инструментирован задержкой и падает на до-фиксном коде |
|
||||||
|
| 5 сцена `hidden`: прекондиция не ассертится, сторож дифференциальный | **починено тремя проверками вместо одной:** прекондиция «банк ещё не пришёл» ассертится, «таблица вообще нарисована» (4 столбца, 608px) проверяется, «столбцы по содержимому, а не равными долями» — тоже; дифференциальное сравнение осталось четвёртым |
|
||||||
|
| 6 дырка ESLint-гейта строк | **починено, и шире находки.** Гейт знает НОСИТЕЛИ: `??`, тернарник и шаблон — и в читаемом атрибуте, и как ребёнок элемента (`<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` около 8–13 (их питон и `go test`). Порог не
|
||||||
|
трогала — S3.7 уже заменила шумный способ снятия величины на медиану пяти заходов, а двигать порог
|
||||||
|
под чужую нагрузку было бы ровно той подгонкой, которую норма запрещает. На тихой машине (`load` 2.4)
|
||||||
|
`npm run check:full` — **EXIT=0**, 335 тестов, 136 проверок, 11 сценариев, 8 маршрутов.
|
||||||
|
|
||||||
|
#### 10. Чего я НЕ сделала
|
||||||
|
|
||||||
|
- **Живого стенда платформы не поднимала** и smoke `POST /books` через dev-прокси не гоняла (§10 —
|
||||||
|
опция, не гейт). Значит всё, что сказано про поведение НАСТОЯЩЕЙ платформы, — из её кода и из
|
||||||
|
контракта, а не из прогона: сюда попадают определённая доля отправки, вторая фаза «файл ушёл» и
|
||||||
|
фразы `problem+json` (Ф-61).
|
||||||
|
- **Отмена отправки на середине не проверена исполнением.** Тест покрывает только уже отменённый
|
||||||
|
сигнал: мок-сеть перехватывает запрос в тот же тик, и прервать его тиком позже она не даёт.
|
||||||
|
Сам путь — три строки (`signal → xhr.abort()`), но честнее назвать, чем зачесть.
|
||||||
|
- **Добавление ГЛАВЫ (§3.10) не строила** — ручки в контракте нет, кнопка без действия запрещена
|
||||||
|
нормой зоны. Черновика правки спеки не писала: это не хвост загрузки, а отдельный сценарий, и
|
||||||
|
его форму (глава в существующую книгу? пере-разбор?) решать владельцу контракта.
|
||||||
|
- **Референса `antigravity_add_folder.png` на диске нет** (папка под .gitignore, как было с
|
||||||
|
fleet_2.png): форму добавления книги я НЕ перерисовывала — достроила поля в раскладку S3.5.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 11. Гейты на сдаче
|
||||||
|
|
||||||
|
`npm run check` — зелёный, **335 тестов** (было 279). `npm run check:full` — EXIT=0: сборка, **8
|
||||||
|
маршрутов** со снимком и axe (добавился `/intake`), **11 сценариев, 136 проверок** (добавились
|
||||||
|
`intake` и `refusals`), ноль блокирующих нарушений axe и ноль узлов контраста на всех маршрутах и
|
||||||
|
во всех сценах. Числа получены командами `npm run check`, `npm run check:full`, `npm run scenes`.
|
||||||
|
|
||||||
|
Живым нарушением проверено (каждый гейт — своим): расширенный гейт строк роняет все четыре новые
|
||||||
|
формы (`?? 'Слово'`, тернарник, шаблон, литерал ребёнком элемента) и по-прежнему ловит носители
|
||||||
|
инлайн-стиля (объект, переменная) · сужение правила `style` не пропускает ничего, кроме строки ·
|
||||||
|
свип языка ловит русский комментарий в безрасширенном `scripts/githooks/pre-commit` · гейт вызовов
|
||||||
|
каталога ловит лишнюю переменную при вызове через переменную по имени `say` · порог контраста тонов
|
||||||
|
краснеет на прежнем `--color-danger`. Тесты фиксов приёмки (кадр библиотеки, кадр списка глав,
|
||||||
|
отметка после провалившегося чтения, отметки после ресинка, поток при медленном перечитывании)
|
||||||
|
падают на до-фиксном коде — проверено откатом каждого фикса по очереди.
|
||||||
|
|
||||||
### 10.08 — приёмка S3.7 оркестратором: ПРИНЯТО и залендено; арбитраж 8.1 — права сессия
|
### 10.08 — приёмка S3.7 оркестратором: ПРИНЯТО и залендено; арбитраж 8.1 — права сессия
|
||||||
|
|
||||||
Гейты пере-прогнаны исполнением: `npm run check:full` EXIT=0 · vitest **279** · 9 сценариев
|
Гейты пере-прогнаны исполнением: `npm run check:full` EXIT=0 · vitest **279** · 9 сценариев
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,14 @@ const tokenGate = [
|
||||||
// The leftover hole from §5.4: `<div {...props}/>`, where style arrives inside an object. The
|
// 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
|
// `style` property itself is forbidden in object literals — the carrier is then caught where it
|
||||||
// is assembled.
|
// 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.
|
// 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}]`,
|
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`,
|
// 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.
|
// `hint`, `caption`) — a word handed to a component is as printed as a word between its tags.
|
||||||
const readableAttributes =
|
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 = [
|
const noBareText = [
|
||||||
{ selector: `JSXText[value=${letter}]`, message: catalogueOnly },
|
{ selector: `JSXText[value=${letter}]`, message: catalogueOnly },
|
||||||
|
|
@ -165,14 +190,16 @@ const noBareText = [
|
||||||
selector: `JSXAttribute[name.name=${readableAttributes}] > Literal[value=${letter}]`,
|
selector: `JSXAttribute[name.name=${readableAttributes}] > Literal[value=${letter}]`,
|
||||||
message: catalogueOnly,
|
message: catalogueOnly,
|
||||||
},
|
},
|
||||||
{
|
// Inside a readable attribute, and — the second half of the same hole — as the CHILD of an
|
||||||
selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > Literal[value=${letter}]`,
|
// element: `<p>{'a word'}</p>` prints one exactly as `<p>a word</p>` does, and only the second was
|
||||||
message: catalogueOnly,
|
// caught (by the `JSXText` rule).
|
||||||
},
|
...['JSXAttribute[name.name=' + readableAttributes + ']', 'JSXElement', 'JSXFragment'].flatMap(
|
||||||
{
|
(host) =>
|
||||||
selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > TemplateLiteral > TemplateElement[value.raw=${letter}]`,
|
writtenWord.map((shape) => ({
|
||||||
message: catalogueOnly,
|
selector: `${host} > JSXExpressionContainer > ${shape}`,
|
||||||
},
|
message: catalogueOnly,
|
||||||
|
})),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,36 @@ const load = (/** @type {string} */ path) => import(new URL(path, import.meta.ur
|
||||||
|
|
||||||
/** @type {Record<MessageKey, string>} */
|
/** @type {Record<MessageKey, string>} */
|
||||||
const ru = (await load('../src/i18n/ru.ts')).ru;
|
const ru = (await load('../src/i18n/ru.ts')).ru;
|
||||||
/** @type {{ book: Schemas['Book'], chapters: Schemas['Chapter'][], notes: Schemas['Note'][] }} */
|
/** @type {(line: string, variables: Record<string, string | number> | undefined) => string} */
|
||||||
const { book, chapters, notes } = await load('../src/mock/book.ts');
|
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'][] }} */
|
/** @type {{ terms: Schemas['BankTerm'][], bankTerms: Schemas['BankTerm'][] }} */
|
||||||
const { terms, bankTerms } = await load('../src/mock/bank.ts');
|
const { terms, bankTerms } = await load('../src/mock/bank.ts');
|
||||||
/** @type {{ scaleTerms: Schemas['BankTerm'][] }} */
|
/** @type {{ scaleTerms: Schemas['BankTerm'][] }} */
|
||||||
const { scaleTerms } = await load('../src/mock/scale.ts');
|
const { scaleTerms } = await load('../src/mock/scale.ts');
|
||||||
|
|
||||||
/** @typedef {import('playwright').Page} Page */
|
/** @typedef {import('playwright').Page} Page */
|
||||||
/** @typedef {import('playwright').Locator} Locator */
|
/** @typedef {import('playwright').Locator} Locator */
|
||||||
/** @typedef {(name: string) => Promise<unknown>} Shot */
|
/** @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
|
* 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
|
* key throws: a locator built out of `undefined` matches ANY node of its role, and the scene would
|
||||||
* go green having checked nothing.
|
* 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]
|
* @param {MessageKey} key @param {Record<string, string | number>} [variables]
|
||||||
*/
|
*/
|
||||||
function say(key, variables = {}) {
|
function say(key, variables = {}) {
|
||||||
const line = ru[key];
|
const line = ru[key];
|
||||||
if (line === undefined) throw new Error(`no such key in the message catalogue: ${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');
|
const blocking = axe.violations.filter((v) => v.id !== 'color-contrast');
|
||||||
for (const v of 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)`);
|
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) {
|
if (blocking.length > 0) {
|
||||||
for (const v of blocking) {
|
for (const v of blocking) {
|
||||||
|
|
@ -266,6 +298,30 @@ const scenes = {
|
||||||
/** @param {Page} page @param {Shot} shot */
|
/** @param {Page} page @param {Shot} shot */
|
||||||
async scroll(page, shot) {
|
async scroll(page, shot) {
|
||||||
await open(page, '/scale');
|
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')}"]`);
|
const bank = page.locator(`[role="grid"][aria-label="${say('bank.tableLabel')}"]`);
|
||||||
await bank.evaluate((node) => node.scrollTo(0, 9000));
|
await bank.evaluate((node) => node.scrollTo(0, 9000));
|
||||||
const before = await bank.evaluate((node) => node.scrollTop);
|
const before = await bank.evaluate((node) => node.scrollTop);
|
||||||
|
|
@ -343,6 +399,41 @@ const scenes = {
|
||||||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||||||
'a row of the summary opens its chapter as a preview tab',
|
'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
|
// 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');
|
const panel = page.locator('aside');
|
||||||
// We leave the bank BEFORE it has answered: the tabs are drawn at once, the data are not.
|
// 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();
|
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, {
|
await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|
@ -699,6 +797,19 @@ const scenes = {
|
||||||
const neverHidden = await columnSpread(
|
const neverHidden = await columnSpread(
|
||||||
page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') }),
|
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(
|
check(
|
||||||
hiddenThenShown.widths.join('·') === neverHidden.widths.join('·'),
|
hiddenThenShown.widths.join('·') === neverHidden.widths.join('·'),
|
||||||
`a table mounted in a hidden tab measures the same as one that was never left ` +
|
`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
|
// 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
|
// 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.
|
// 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(
|
check(
|
||||||
layout.edges.length === 3 &&
|
layout.edges.length === 3 &&
|
||||||
layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) &&
|
layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) &&
|
||||||
layout.edges.every(({ left }, index) => left >= (layout.edges[index - 1]?.left ?? 0)),
|
layout.edges.every(
|
||||||
`${percent}%: the three panels stand side by side inside the window (${layout.panels.join(' · ')})`,
|
({ 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:
|
// 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
|
// 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.
|
// (useDeferredValue), so that typing does not wait for the collection.
|
||||||
const search = page.getByRole('searchbox', { name: say('bank.search') });
|
const search = page.getByRole('searchbox', { name: say('bank.search') });
|
||||||
await search.click();
|
await search.click();
|
||||||
const before = await table.getByRole('row').count();
|
|
||||||
const rowsSelector = '[role="grid"] [role="row"]';
|
const rowsSelector = '[role="grid"] [role="row"]';
|
||||||
// The query MUST narrow the result SHARPLY: a single syllable is in almost every term of this
|
// 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
|
// 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;
|
let settled = 0;
|
||||||
for (let round = 0; round < 5; round += 1) {
|
for (let round = 0; round < 5; round += 1) {
|
||||||
await search.fill('');
|
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();
|
await search.click();
|
||||||
const typed = Date.now();
|
const typed = Date.now();
|
||||||
await page.keyboard.type(query);
|
await page.keyboard.type(query);
|
||||||
|
|
@ -901,7 +1028,7 @@ const scenes = {
|
||||||
echoes.push(Math.round((Date.now() - typed) / query.length));
|
echoes.push(Math.round((Date.now() - typed) / query.length));
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
({ was, selector }) => document.querySelectorAll(selector).length < was,
|
({ was, selector }) => document.querySelectorAll(selector).length < was,
|
||||||
{ was: before, selector: rowsSelector },
|
{ was: full, selector: rowsSelector },
|
||||||
{ timeout: 5000 },
|
{ timeout: 5000 },
|
||||||
);
|
);
|
||||||
settled = Date.now() - typed;
|
settled = Date.now() - typed;
|
||||||
|
|
@ -933,6 +1060,341 @@ const scenes = {
|
||||||
check(drag.janky <= 3, `long frames over the drag ${drag.janky} (threshold 3)`);
|
check(drag.janky <= 3, `long frames over the drag ${drag.janky} (threshold 3)`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// S4: the path the product begins with — a file becomes a book, the book is parsed, the book is
|
||||||
|
// translated. Everything here runs against the mock network by the form of the contract; the
|
||||||
|
// fixture's own rules (the file last, the caps) are the platform's, so a client that broke one
|
||||||
|
// would fail here rather than in production.
|
||||||
|
/** @param {Page} page @param {Shot} shot */
|
||||||
|
async intake(page, shot) {
|
||||||
|
await open(page, '/intake');
|
||||||
|
// The precondition is ASSERTED and not assumed: this world starts empty, and every count below
|
||||||
|
// is a count of what the form put there.
|
||||||
|
check(
|
||||||
|
(await page.locator('nav [role="row"]').count()) === 0,
|
||||||
|
'the intake world starts with an empty library',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await shot('intake-form');
|
||||||
|
await audit(page, 'the add-a-book form');
|
||||||
|
|
||||||
|
// The pair of languages is a real choice with a real keyboard: the whole path has to be
|
||||||
|
// passable without a mouse.
|
||||||
|
const source = modal.getByLabel(say('addBook.sourceLangLabel'));
|
||||||
|
const before = (await source.innerText()).trim();
|
||||||
|
await source.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const after = (await source.innerText()).trim();
|
||||||
|
// ⚠ A CHANGE, not "there is a value": the control shows one before a key is ever pressed, so
|
||||||
|
// the first version of this check was true of a keyboard that did nothing (found by the
|
||||||
|
// adversarial review).
|
||||||
|
check(
|
||||||
|
after !== before && after !== '',
|
||||||
|
`the language of the original is chosen from the keyboard (${before} → ${after})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: 'gu-zhen-ren.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.alloc(300_000, 'a'),
|
||||||
|
});
|
||||||
|
await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).fill(book.title);
|
||||||
|
check(
|
||||||
|
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
|
||||||
|
'a title entered by hand differs from the automatic parse',
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: say('addBook.submit') }).click();
|
||||||
|
// The sending is a STATE and not a flash. ⚠ WHAT IS CHECKED HERE is what the browser world can
|
||||||
|
// show: under the mock network the request is answered by a service worker, and Chromium reports
|
||||||
|
// no upload progress for such a request at all (probe: `loadstart` with the right total, then
|
||||||
|
// nothing). So the screen honestly says the size going out and draws NO share — the determinate
|
||||||
|
// bar and the "all sent" line belong to the real platform and are proved by src/api/upload.test.ts.
|
||||||
|
// The line is found by the part of the catalogue string that stands BEFORE the place: the size
|
||||||
|
// itself is formatted by `Intl` for the interface's locale, and a scene that spelled it out
|
||||||
|
// would be a second copy of that formatting.
|
||||||
|
const sending = modal.getByText(say('addBook.sendingUnknown').split('{')[0]?.trim() ?? '', {
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
await sending.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await shot('intake-sending');
|
||||||
|
const said = await sending.innerText();
|
||||||
|
check(/\d/.test(said), `the form says how much is going out («${said.trim()}»)`);
|
||||||
|
check(
|
||||||
|
(await modal.getByRole('progressbar').count()) === 0,
|
||||||
|
'and draws no share while the browser reports none — a bar at zero would be a number that means nothing',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 201 carries `parsing` — the confirmation shows what came back, from the contract's own
|
||||||
|
// fields and not from what the form typed in.
|
||||||
|
await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 });
|
||||||
|
await shot('intake-accepted');
|
||||||
|
const confirmation = await modal.innerText();
|
||||||
|
check(
|
||||||
|
confirmation.includes(book.title) && confirmation.includes(say('status.parsing')),
|
||||||
|
'the confirmation names the book and says it is being parsed',
|
||||||
|
);
|
||||||
|
await audit(page, 'the confirmation of the upload');
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: say('action.done') }).click();
|
||||||
|
// Parsing is a visible step of its own, with no number on it: the contract has no counter for
|
||||||
|
// it, so the screen says the state and invents no percentage.
|
||||||
|
const badge = page.locator('nav [role="row"]').first();
|
||||||
|
check(
|
||||||
|
(await badge.innerText()).includes(say('status.parsing')),
|
||||||
|
'the book stands in the library while it is being parsed',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
!/\d+\s*%/.test(await badge.innerText()),
|
||||||
|
'and no percentage of the parsing is invented anywhere on that row',
|
||||||
|
);
|
||||||
|
await shot('intake-parsing');
|
||||||
|
|
||||||
|
// Nothing PUSHES the end of the parse — a book being parsed has no run and therefore no
|
||||||
|
// stream — so this waits for the poll to bring it.
|
||||||
|
await page.waitForFunction(
|
||||||
|
(want) => document.querySelector('nav [role="row"]')?.textContent?.includes(want) === true,
|
||||||
|
say('status.notStarted'),
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
// Waited for and not counted on the spot: the tree is read AGAIN when the poll finds the parse
|
||||||
|
// over, and the answer to that read is a round trip behind the status.
|
||||||
|
const grown = await page
|
||||||
|
.waitForFunction(() => document.querySelectorAll('nav [role="row"]').length > 1, null, {
|
||||||
|
timeout: 15_000,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const chapters = await page.locator('nav [role="row"]').count();
|
||||||
|
check(grown, `the sections of the parsed book are in the tree (${String(chapters)} rows)`);
|
||||||
|
await shot('intake-parsed');
|
||||||
|
|
||||||
|
// The run: the scale is in CHAPTERS, and there is no money on the screen in any form (§4.8).
|
||||||
|
const panel = page.locator('aside');
|
||||||
|
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||||||
|
await panel.getByRole('button', { name: say('run.action') }).click();
|
||||||
|
const scale = modal.getByRole('slider');
|
||||||
|
await scale.waitFor({ timeout: 5000 });
|
||||||
|
await shot('intake-run-form');
|
||||||
|
await audit(page, 'the run form');
|
||||||
|
// The scale is a native range under the hood (that is what gives it the keyboard and the
|
||||||
|
// screen reader for free), so its bounds live on the input and not in aria-* attributes.
|
||||||
|
const boundsOf = () =>
|
||||||
|
scale.evaluate((node) =>
|
||||||
|
node instanceof HTMLInputElement
|
||||||
|
? { now: node.value, min: node.min, max: node.max }
|
||||||
|
: {
|
||||||
|
now: node.getAttribute('aria-valuenow'),
|
||||||
|
min: node.getAttribute('aria-valuemin'),
|
||||||
|
max: node.getAttribute('aria-valuemax'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const bounds = await boundsOf();
|
||||||
|
check(
|
||||||
|
bounds.now === '4' && bounds.min === '1' && bounds.max === '8',
|
||||||
|
`the scale is built from the platform's bounds, preset included (${JSON.stringify(bounds)})`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
!/[$₽€]|\d+[.,]\d{2}\b/.test(await modal.innerText()),
|
||||||
|
'not a sum, not a currency and not a price anywhere on the run form',
|
||||||
|
);
|
||||||
|
// The scale is a real control for the keyboard, not a decoration for the mouse.
|
||||||
|
await scale.focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
check((await boundsOf()).now === '5', 'the ceiling moves from the keyboard');
|
||||||
|
|
||||||
|
// ⚠ THE FIRST PRESS MEETS A 409, and the fixture produces it the way the platform does rather
|
||||||
|
// than by a marker: the bounds moved between the read and the call (the contract's own case — a
|
||||||
|
// hold taken for another book lowers what is left). This is the one refusal a correct client
|
||||||
|
// can meet on a correct form, and §11 of the assignment asks for every branch to be SHOWN.
|
||||||
|
await modal.getByRole('button', { name: say('run.action') }).click();
|
||||||
|
const refused = modal.getByText(say('run.conflictAdvice'), { exact: false });
|
||||||
|
await refused.waitFor({ timeout: 10_000 });
|
||||||
|
await shot('intake-run-conflict');
|
||||||
|
check(
|
||||||
|
await refused.isVisible(),
|
||||||
|
'a ceiling that no longer fits is refused, and the form says so',
|
||||||
|
);
|
||||||
|
// The same shape as the intake refusals: the client's OWN phrase for this class must be absent,
|
||||||
|
// which is what says the sentence on the screen came from the platform.
|
||||||
|
check(
|
||||||
|
!(await modal.innerText()).includes(say('run.conflict')),
|
||||||
|
"and the phrase is the platform's own, not the client's fallback",
|
||||||
|
);
|
||||||
|
// The scale was re-read, so the value now standing on it is the platform's NEW preset — not the
|
||||||
|
// one the person picked against bounds that no longer exist.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
(document.querySelector('[role="dialog"] input[type="range"]')?.getAttribute('max') ??
|
||||||
|
'') === '2',
|
||||||
|
null,
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
check((await boundsOf()).now === '2', 'and the scale comes back with the bounds that are left');
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: say('run.action') }).click();
|
||||||
|
// The run goes, and it ends where the contract says a ceiling stop ends: `paused`, with a
|
||||||
|
// machine reason, never `failed`. The phrase is the owner's (В-6) and the client only picks it.
|
||||||
|
await page.waitForFunction(
|
||||||
|
(want) => document.querySelector('footer')?.textContent?.includes(want) === true,
|
||||||
|
say('paused.creditExhausted'),
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
await shot('intake-paused');
|
||||||
|
// The halt is a LIVED state and not a toast: the strip carries it, the card carries it, and both
|
||||||
|
// are still there a minute later — which a message that fades would not be.
|
||||||
|
check(
|
||||||
|
(await page.locator('footer').innerText()).includes(say('paused.creditExhausted')),
|
||||||
|
'the halt on the ceiling is named in the status strip, in the owner’s own words',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
(await panel.innerText()).includes(say('paused.creditExhausted')),
|
||||||
|
'and on the card of the book, where the action for it would be',
|
||||||
|
);
|
||||||
|
// ⚠ What the card offers is a NEW run — which carries a new ceiling — and never "continue":
|
||||||
|
// after a ceiling stop `resume` does not move the run at all (contract §resumeRun).
|
||||||
|
check(
|
||||||
|
(await panel.innerText()).includes(say('run.action')),
|
||||||
|
'the way on from a halt is a new run with a ceiling of its own, and it is offered',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The other half of the same path: everything that can go wrong with an intake, each as a state
|
||||||
|
// of the form rather than an alert over it (§3.8). The class is asked for by a marker in the name
|
||||||
|
// of the file — the fixture's own switch (src/mock/intake.ts), because a correct client cannot
|
||||||
|
// produce these answers and the screens for them would otherwise be checked by nothing.
|
||||||
|
/** @param {Page} page @param {Shot} shot */
|
||||||
|
async refusals(page, shot) {
|
||||||
|
await open(page, '/intake');
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
|
||||||
|
/** @param {string} name @param {number} size */
|
||||||
|
const send = async (name, size) => {
|
||||||
|
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||||||
|
await modal.locator('input[type="file"]').setInputFiles({
|
||||||
|
name,
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.alloc(size, 'a'),
|
||||||
|
});
|
||||||
|
await modal.getByRole('button', { name: say('addBook.submit') }).click();
|
||||||
|
// Any of the advices the form can end on: which one it is, is what the checks below ask.
|
||||||
|
await modal
|
||||||
|
.getByText(say('upload.retryAdvice'), { exact: false })
|
||||||
|
.or(modal.getByText(say('upload.badRequestAdvice'), { exact: false }))
|
||||||
|
.or(modal.getByText(say('upload.tooLargeAdvice'), { exact: false }))
|
||||||
|
.or(modal.getByText(say('upload.notAcceptedAdvice'), { exact: false }))
|
||||||
|
.waitFor({ timeout: 10_000 });
|
||||||
|
return modal.innerText();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The body over the cap — the one refusal the fixture takes from the FILE itself and not from
|
||||||
|
// a marker, because that rule is real on both sides of the wire.
|
||||||
|
const tooLarge = await send('gu-zhen-ren.txt', 2_400_000);
|
||||||
|
await shot('refusal-413');
|
||||||
|
check(
|
||||||
|
tooLarge.includes(say('upload.tooLargeAdvice')),
|
||||||
|
'a file over the cap is refused with the advice to take a smaller one',
|
||||||
|
);
|
||||||
|
await audit(page, 'the refusal of an oversized file');
|
||||||
|
await closeModal(page);
|
||||||
|
|
||||||
|
for (const { marker, advice, ours, what } of [
|
||||||
|
{
|
||||||
|
marker: 'refuse-400',
|
||||||
|
advice: say('upload.badRequestAdvice'),
|
||||||
|
ours: say('upload.badRequest'),
|
||||||
|
what: 'a form that did not arrive whole',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
marker: 'refuse-404',
|
||||||
|
advice: say('upload.notAcceptedAdvice'),
|
||||||
|
ours: say('upload.notAccepted'),
|
||||||
|
what: 'a deployment that accepts no books',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
marker: 'refuse-408',
|
||||||
|
advice: say('upload.retryAdvice'),
|
||||||
|
ours: say('upload.timeout'),
|
||||||
|
what: 'a body that missed the deadline',
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const shown = await send(`${marker}.txt`, 1000);
|
||||||
|
await shot(`refusal-${marker}`);
|
||||||
|
check(shown.includes(advice), `${what}: the form says what to do next`);
|
||||||
|
// ⚠ The CLIENT'S OWN phrase for this very class must be absent: it is the fallback used when
|
||||||
|
// the platform sends no problem body, so it is reachable — and its absence therefore says the
|
||||||
|
// phrase on the screen came from the server. The first version of this check looked for a
|
||||||
|
// phrase no path can print at all and so could never fail (found by the adversarial review).
|
||||||
|
check(
|
||||||
|
!shown.includes(ours),
|
||||||
|
`${what}: the phrase shown is the platform's, not the client's own («${ours}»)`,
|
||||||
|
);
|
||||||
|
await closeModal(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the other end of the intake: a book that was accepted and could not be turned into
|
||||||
|
// chapters. Three reasons, three different next actions.
|
||||||
|
for (const { marker, reason, advice } of [
|
||||||
|
{
|
||||||
|
marker: 'reject-source',
|
||||||
|
reason: say('rejected.sourceUnreadable'),
|
||||||
|
advice: say('rejected.sourceUnreadableAdvice'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
marker: 'reject-config',
|
||||||
|
reason: say('rejected.notConfigured'),
|
||||||
|
advice: say('rejected.notConfiguredAdvice'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
marker: 'reject-parser',
|
||||||
|
reason: say('rejected.parserUnavailable'),
|
||||||
|
advice: say('rejected.parserUnavailableAdvice'),
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||||||
|
await modal.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: `${marker}.txt`,
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.alloc(1000, 'a'),
|
||||||
|
});
|
||||||
|
await modal.getByRole('button', { name: say('addBook.submit') }).click();
|
||||||
|
await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 });
|
||||||
|
await modal.getByRole('button', { name: say('action.done') }).click();
|
||||||
|
// The book just uploaded is CHOSEN in the tree — the library holds the ones before it, and a
|
||||||
|
// card that went on showing the first book would make every reason below read the same.
|
||||||
|
// (The title is the name of the file: the form left it to the parse.)
|
||||||
|
await page.locator('nav').getByText(marker, { exact: true }).click();
|
||||||
|
const panel = page.locator('aside');
|
||||||
|
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
(want) => document.querySelector('aside')?.textContent?.includes(want) === true,
|
||||||
|
reason,
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
await shot(`refusal-${marker}`);
|
||||||
|
// The row of a book can be SELECTED since S4, so the pairs on the blue ground are checked
|
||||||
|
// here as well: axe does not see the contrast of a badge it considers decorative, and the
|
||||||
|
// rule that recolours it had been removed in S3.7 as dead code.
|
||||||
|
const faint = await contrastInSelectedRow(page);
|
||||||
|
check(
|
||||||
|
faint.length === 0,
|
||||||
|
`${marker}: everything on the selected row of the book is readable ${faint
|
||||||
|
.map((part) => `${part.what} ${part.ratio}:1 < ${part.floor}`)
|
||||||
|
.join(' · ')}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
(await panel.innerText()).includes(advice),
|
||||||
|
`${marker}: the card names the reason and what to do about it`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await audit(page, 'a rejected book on the card');
|
||||||
|
},
|
||||||
|
|
||||||
// Remarks 11, 12, 4: three modal windows instead of stub tabs and a second search button.
|
// Remarks 11, 12, 4: three modal windows instead of stub tabs and a second search button.
|
||||||
/** @param {Page} page @param {Shot} shot */
|
/** @param {Page} page @param {Shot} shot */
|
||||||
async overlays(page, shot) {
|
async overlays(page, shot) {
|
||||||
|
|
@ -959,7 +1421,9 @@ const scenes = {
|
||||||
await modal.getByText(say('addBook.titleHintAuto'), { exact: false }).isVisible(),
|
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',
|
'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(
|
check(
|
||||||
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
|
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
|
||||||
'a title entered by hand differs from the automatic parse',
|
'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;
|
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);
|
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 ratio = (/** @type {string} */ a, /** @type {string} */ b) => {
|
||||||
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
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]');
|
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.
|
* Drags the right separator through a list of offsets from where it started, then lets go.
|
||||||
* @param {Page} page @param {number[]} offsets
|
* @param {Page} page @param {number[]} offsets
|
||||||
|
|
@ -1215,6 +1706,21 @@ const handleStyle = (separator) =>
|
||||||
return { opacity: style.opacity, background: style.backgroundColor, height: style.height };
|
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 */
|
/** @param {Page} page @param {string} route */
|
||||||
async function open(page, route) {
|
async function open(page, route) {
|
||||||
await page.goto(`${origin}${route}`, { waitUntil: 'load' });
|
await page.goto(`${origin}${route}`, { waitUntil: 'load' });
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const KNOWN_ROUTES = [
|
||||||
'/showcase',
|
'/showcase',
|
||||||
'/scale',
|
'/scale',
|
||||||
'/empty',
|
'/empty',
|
||||||
|
'/intake',
|
||||||
'/loading',
|
'/loading',
|
||||||
'/error',
|
'/error',
|
||||||
'/offline',
|
'/offline',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
|
|
||||||
import type { Page, Problem } from './contract';
|
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
|
// 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.
|
// carries no token semantics. Required on same-origin requests too — it is not a CORS mechanism.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import openapiTS, { astToString } from 'openapi-typescript';
|
||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { contractVersion } from '../mock/events';
|
||||||
import { narrow } from './contract';
|
import { narrow } from './contract';
|
||||||
import { majorOf, supportedMajor } from './stream';
|
import { majorOf, supportedMajor } from './stream';
|
||||||
import { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary';
|
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);
|
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', () => {
|
test('an unknown vocabulary value is narrowed, not trusted and not thrown', () => {
|
||||||
expect(bookStatus.read('translating')).toBe('translating');
|
expect(bookStatus.read('translating')).toBe('translating');
|
||||||
expect(bookStatus.read('status-from-a-future-contract')).toBeNull();
|
expect(bookStatus.read('status-from-a-future-contract')).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
bookStatus,
|
bookStatus,
|
||||||
noteSeverity,
|
noteSeverity,
|
||||||
pausedReason,
|
pausedReason,
|
||||||
|
rejectReason,
|
||||||
termKind,
|
termKind,
|
||||||
termOrigin,
|
termOrigin,
|
||||||
termStatus,
|
termStatus,
|
||||||
|
|
@ -24,6 +25,7 @@ type Schemas = components['schemas'];
|
||||||
|
|
||||||
export type BookStatus = Schemas['BookStatus'];
|
export type BookStatus = Schemas['BookStatus'];
|
||||||
export type PausedReason = Schemas['PausedReason'];
|
export type PausedReason = Schemas['PausedReason'];
|
||||||
|
export type RejectReason = Schemas['RejectReason'];
|
||||||
export type NoteSeverity = Schemas['NoteSeverity'];
|
export type NoteSeverity = Schemas['NoteSeverity'];
|
||||||
export type TermKind = Schemas['TermKind'];
|
export type TermKind = Schemas['TermKind'];
|
||||||
export type TermStatus = Schemas['TermStatus'];
|
export type TermStatus = Schemas['TermStatus'];
|
||||||
|
|
@ -40,7 +42,12 @@ export type CeilingBounds = Schemas['CeilingBounds'];
|
||||||
export type BankDecision = Schemas['BankDecision'];
|
export type BankDecision = Schemas['BankDecision'];
|
||||||
export type Problem = Schemas['Problem'];
|
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'> & {
|
export type Run = Omit<Schemas['Run'], 'status' | 'paused_reason'> & {
|
||||||
status: BookStatus | null;
|
status: BookStatus | null;
|
||||||
paused_reason: PausedReason | null;
|
paused_reason: PausedReason | null;
|
||||||
|
|
@ -75,7 +82,14 @@ export interface Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const narrow = {
|
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 => ({
|
run: (wire: Schemas['Run']): Run => ({
|
||||||
...wire,
|
...wire,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,16 @@ export * from './scenarios';
|
||||||
export { ApiError } from './client';
|
export { ApiError } from './client';
|
||||||
export { subscribeToRun, supportedMajor, majorOf, readFrame } from './stream';
|
export { subscribeToRun, supportedMajor, majorOf, readFrame } from './stream';
|
||||||
export type { ConnectionState, Frame } 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';
|
export type { Tone } from './vocabulary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import { replaceEqualDeep } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { request, requestAll } from './client';
|
import { request, requestAll } from './client';
|
||||||
import { narrow } from './contract';
|
import { narrow } from './contract';
|
||||||
|
import { bookStatus } from './vocabulary';
|
||||||
import type {
|
import type {
|
||||||
Bank,
|
Bank,
|
||||||
BankDecision,
|
BankDecision,
|
||||||
BookDetail,
|
BookDetail,
|
||||||
|
BookStatus,
|
||||||
ChapterList,
|
ChapterList,
|
||||||
Id,
|
Id,
|
||||||
Library,
|
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 = {
|
export const keys = {
|
||||||
library: () => ['library'] as const,
|
library: () => ['library'] as const,
|
||||||
book: (bookId: Id) => ['book', bookId] as const,
|
book: (bookId: Id) => ['book', bookId] as const,
|
||||||
|
|
@ -148,11 +167,15 @@ export const libraryQuery = () => ({
|
||||||
queryKey: keys.library(),
|
queryKey: keys.library(),
|
||||||
queryFn: readLibrary,
|
queryFn: readLibrary,
|
||||||
...dropStaleReads,
|
...dropStaleReads,
|
||||||
|
refetchInterval: ({ state }: { state: { data?: Library } }) =>
|
||||||
|
state.data?.books.some((book) => inIntake(book.status)) === true ? intakePollMs : false,
|
||||||
});
|
});
|
||||||
export const bookQuery = (bookId: Id) => ({
|
export const bookQuery = (bookId: Id) => ({
|
||||||
queryKey: keys.book(bookId),
|
queryKey: keys.book(bookId),
|
||||||
queryFn: () => readBook(bookId),
|
queryFn: () => readBook(bookId),
|
||||||
...dropStaleReads,
|
...dropStaleReads,
|
||||||
|
refetchInterval: ({ state }: { state: { data?: BookDetail } }) =>
|
||||||
|
inIntake(state.data?.book.status) ? intakePollMs : false,
|
||||||
});
|
});
|
||||||
export const chaptersQuery = (bookId: Id) => ({
|
export const chaptersQuery = (bookId: Id) => ({
|
||||||
queryKey: keys.chapters(bookId),
|
queryKey: keys.chapters(bookId),
|
||||||
|
|
@ -178,6 +201,11 @@ export const usageQuery = () => ({ queryKey: keys.usage(), queryFn: readUsage })
|
||||||
export const runOptionsQuery = (bookId: Id) => ({
|
export const runOptionsQuery = (bookId: Id) => ({
|
||||||
queryKey: keys.runOptions(bookId),
|
queryKey: keys.runOptions(bookId),
|
||||||
queryFn: () => readRunOptions(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
|
// Actions. Unsafe methods, so every one of them carries `X-TM-Client` — the client half of the
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const scenarios = [
|
||||||
'showcase',
|
'showcase',
|
||||||
'scale',
|
'scale',
|
||||||
'empty',
|
'empty',
|
||||||
|
'intake',
|
||||||
'loading',
|
'loading',
|
||||||
'error',
|
'error',
|
||||||
'offline',
|
'offline',
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,40 @@ export interface paths {
|
||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Add a book.
|
* Add a book.
|
||||||
* @description Accepts the file and the properties declared by the user. Responds immediately; the book
|
* @description Accepts the file and the properties declared by the user.
|
||||||
* enters `uploading` and parsing is a separate visible step.
|
*
|
||||||
|
* **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"];
|
post: operations["createBook"];
|
||||||
delete?: never;
|
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
|
* **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a
|
||||||
* complete set.
|
* 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"];
|
post: operations["resumeRun"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
@ -523,6 +560,30 @@ export interface components {
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
BookStatus: "uploading" | "parsing" | "not_started" | "translating" | "awaiting_bank" | "finalizing" | "ready" | "paused" | "stopped" | "rejected" | "failed";
|
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. */
|
/** @description A book in the library. */
|
||||||
Book: {
|
Book: {
|
||||||
id: components["schemas"]["Id"];
|
id: components["schemas"]["Id"];
|
||||||
|
|
@ -537,6 +598,16 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
added_at: string;
|
added_at: string;
|
||||||
status: components["schemas"]["BookStatus"];
|
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"];
|
progress: components["schemas"]["Progress"];
|
||||||
note_count: number;
|
note_count: number;
|
||||||
};
|
};
|
||||||
|
|
@ -583,11 +654,27 @@ export interface components {
|
||||||
/** @description Current or last run; `null` if the book was never run. */
|
/** @description Current or last run; `null` if the book was never run. */
|
||||||
run?: components["schemas"]["Run"] | null;
|
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: {
|
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
|
* Format: binary
|
||||||
* @description Book file.
|
* @description Book file. The LAST part of the form.
|
||||||
*/
|
*/
|
||||||
file: string;
|
file: string;
|
||||||
source_lang: components["schemas"]["LangCode"];
|
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
|
* @description The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on
|
||||||
* run requires the engine seam to be fully configured, and answering with any other code
|
* a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the
|
||||||
* would misname the state. Temporary by nature — retry later; no Retry-After is promised.
|
* 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: {
|
ServiceUnavailable: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -1115,7 +1216,7 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Book accepted. */
|
/** @description Book accepted; it is being parsed. */
|
||||||
201: {
|
201: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
|
|
@ -1126,6 +1227,8 @@ export interface operations {
|
||||||
};
|
};
|
||||||
400: components["responses"]["BadRequest"];
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
408: components["responses"]["RequestTimeout"];
|
||||||
413: components["responses"]["TooLarge"];
|
413: components["responses"]["TooLarge"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -1482,6 +1585,7 @@ export interface operations {
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
409: components["responses"]["Conflict"];
|
409: components["responses"]["Conflict"];
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getUsage: {
|
getUsage: {
|
||||||
|
|
|
||||||
254
frontend/src/api/upload.test.ts
Normal file
254
frontend/src/api/upload.test.ts
Normal 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
143
frontend/src/api/upload.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
* 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.
|
* 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' },
|
// `intake` — the book is still ARRIVING: it has no run, therefore no event stream, therefore
|
||||||
parsing: { label: 'status.parsing', tone: 'note' },
|
// no way of announcing that it is over. The reads poll while it lasts (src/api/queries.ts).
|
||||||
not_started: { label: 'status.notStarted' },
|
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' },
|
translating: { label: 'status.translating', tone: 'note' },
|
||||||
awaiting_bank: { label: 'status.awaitingBank', tone: 'warn' },
|
awaiting_bank: { label: 'status.awaitingBank', tone: 'warn' },
|
||||||
finalizing: { label: 'status.finalizing', tone: 'note' },
|
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).
|
// 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 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
|
// 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.
|
// 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' },
|
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
|
// 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.
|
// 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' },
|
{ label: 'status.unknown' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -84,6 +98,40 @@ export const pausedReason = vocabulary<Schemas['PausedReason'], { label: Message
|
||||||
{ label: 'paused.unknown' },
|
{ 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.
|
* Severity step of a note → look of the callout.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,14 @@ const roots = [
|
||||||
'vite.config.ts',
|
'vite.config.ts',
|
||||||
'index.html',
|
'index.html',
|
||||||
'tsconfig.json',
|
'tsconfig.json',
|
||||||
|
'package.json',
|
||||||
'.npmrc',
|
'.npmrc',
|
||||||
'.gitignore',
|
'.gitignore',
|
||||||
'.spectral.yaml',
|
'.spectral.yaml',
|
||||||
'.prettierignore',
|
'.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]
|
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.
|
* 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', () => {
|
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[] = [];
|
const seen: string[] = [];
|
||||||
|
|
||||||
for (const path of files) {
|
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) {
|
for (const path of files) {
|
||||||
if (!['.ts', '.tsx'].includes(extname(path))) continue;
|
if (!['.ts', '.tsx'].includes(extname(path))) continue;
|
||||||
const source = readFileSync(join(root, path), 'utf8');
|
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);
|
filled.add(key as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// THE catalogue of Russian interface strings. The only file in `src/` where interface wording
|
// 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,
|
// 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
|
// 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.
|
// translation is invented here.
|
||||||
|
|
||||||
export const ru = {
|
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.collapseLeft': 'Свернуть левую панель',
|
||||||
'shell.collapseRight': 'Свернуть правую панель',
|
'shell.collapseRight': 'Свернуть правую панель',
|
||||||
'shell.documentsPanel': 'Открытые документы',
|
'shell.documentsPanel': 'Открытые документы',
|
||||||
|
|
@ -24,6 +31,7 @@ export const ru = {
|
||||||
'library.addBook': 'Добавить книгу',
|
'library.addBook': 'Добавить книгу',
|
||||||
'library.booksTab': 'Книги',
|
'library.booksTab': 'Книги',
|
||||||
'library.chapterProgress': 'переведено {done} из {total}',
|
'library.chapterProgress': 'переведено {done} из {total}',
|
||||||
|
'library.chapterRange': 'разделы {from}–{to}',
|
||||||
'library.chaptersFailed': 'разделы не загрузились',
|
'library.chaptersFailed': 'разделы не загрузились',
|
||||||
'library.chaptersLoading': 'загрузка разделов…',
|
'library.chaptersLoading': 'загрузка разделов…',
|
||||||
'library.emptyCentreDescription': 'Добавьте книгу — её разделы откроются здесь вкладками.',
|
'library.emptyCentreDescription': 'Добавьте книгу — её разделы откроются здесь вкладками.',
|
||||||
|
|
@ -104,6 +112,9 @@ export const ru = {
|
||||||
'about.noBooksTitle': 'Книг пока нет',
|
'about.noBooksTitle': 'Книг пока нет',
|
||||||
'about.notes': 'Замечаний',
|
'about.notes': 'Замечаний',
|
||||||
'about.panel': 'О читаемой книге',
|
'about.panel': 'О читаемой книге',
|
||||||
|
'about.parsing':
|
||||||
|
'Книга разбирается на разделы. Они появятся в дереве слева, когда разбор дойдёт до них.',
|
||||||
|
'about.uploading': 'Файл ещё загружается. Разбор начнётся, когда он дойдёт целиком.',
|
||||||
'about.runFinished': 'Прогон завершён',
|
'about.runFinished': 'Прогон завершён',
|
||||||
'about.runStarted': 'Прогон начат',
|
'about.runStarted': 'Прогон начат',
|
||||||
'about.status': 'Состояние',
|
'about.status': 'Состояние',
|
||||||
|
|
@ -123,19 +134,70 @@ export const ru = {
|
||||||
'goto.title': 'Перейти',
|
'goto.title': 'Перейти',
|
||||||
'goto.truncated': 'Показаны первые {count} совпадений.',
|
'goto.truncated': 'Показаны первые {count} совпадений.',
|
||||||
|
|
||||||
'addBook.cancel': 'Отмена',
|
'addBook.acceptedDescription': 'Файл принят целиком. Разбор на разделы идёт сам.',
|
||||||
|
'addBook.acceptedTitle': 'Книга загружена',
|
||||||
'addBook.chooseFile': 'Выбрать файл',
|
'addBook.chooseFile': 'Выбрать файл',
|
||||||
'addBook.fileLabel': 'Файл книги',
|
'addBook.fileLabel': 'Файл книги',
|
||||||
'addBook.settingsLabel': 'Настройки книги',
|
'addBook.genreLabel': 'Жанр',
|
||||||
'addBook.settingsSlot': 'Пара языков, жанр и параметры запуска появятся здесь.',
|
'addBook.genrePlaceholder': 'не обязательно',
|
||||||
|
'addBook.sending': 'Файл отправляется',
|
||||||
|
'addBook.sendingSize': 'отправлено {sent} из {total}',
|
||||||
|
'addBook.sendingUnknown': 'Файл отправляется: {total}',
|
||||||
|
'addBook.sent': 'Файл отправлен целиком — ждём ответа сервиса.',
|
||||||
|
'addBook.sourceLangLabel': 'Язык оригинала',
|
||||||
'addBook.submit': 'Добавить',
|
'addBook.submit': 'Добавить',
|
||||||
|
'addBook.targetLangLabel': 'Язык перевода',
|
||||||
'addBook.title': 'Добавить книгу',
|
'addBook.title': 'Добавить книгу',
|
||||||
'addBook.titleHintAuto':
|
// ⚠ Без «поправить позже»: переименовать книгу нечем — на всей поверхности контракта у книги
|
||||||
'Поле пустое: название определит разбор файла — его можно будет поправить позже.',
|
// нет ни одной записи (Ф-62), и обещать правку, которой нет, интерфейс не вправе.
|
||||||
|
'addBook.titleHintAuto': 'Поле пустое: название определит разбор файла.',
|
||||||
'addBook.titleHintManual': 'Название задано вручную: разбор его не перепишет.',
|
'addBook.titleHintManual': 'Название задано вручную: разбор его не перепишет.',
|
||||||
'addBook.titleLabel': 'Название',
|
'addBook.titleLabel': 'Название',
|
||||||
'addBook.titlePlaceholder': 'по умолчанию — из файла',
|
'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.defaultsAbout':
|
||||||
'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.',
|
'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.',
|
||||||
'settings.defaultsName': 'Перевод по умолчанию',
|
'settings.defaultsName': 'Перевод по умолчанию',
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ export const books: Schemas['Book'][] = [
|
||||||
other('bk_6', 'Записки о лисах и духах', 'not_started'),
|
other('bk_6', 'Записки о лисах и духах', 'not_started'),
|
||||||
other('bk_7', 'Небесный чиновник', 'paused'),
|
other('bk_7', 'Небесный чиновник', 'paused'),
|
||||||
other('bk_8', 'Сломанный меч', 'stopped'),
|
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_10', 'Оборванный свиток', 'failed'),
|
||||||
other('bk_11', 'Свежая загрузка', 'uploading'),
|
other('bk_11', 'Свежая загрузка', 'uploading'),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ type Schemas = components['schemas'];
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
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 {
|
function frame(id: number, event: string, data: unknown): Uint8Array {
|
||||||
return encoder.encode(`id: ${String(id)}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
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) {
|
async start(controller) {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
frame(live.revision, 'hello', {
|
frame(live.revision, 'hello', {
|
||||||
contract: '0.2.2',
|
contract: contractVersion,
|
||||||
run_id: String(params.runId),
|
run_id: String(params.runId),
|
||||||
revision: live.revision,
|
revision: live.revision,
|
||||||
} satisfies Schemas['EventHello']),
|
} satisfies Schemas['EventHello']),
|
||||||
|
|
@ -57,6 +67,29 @@ export function runEvents(scenario: Scenario, world: World) {
|
||||||
},
|
},
|
||||||
} satisfies Schemas['EventProgress']),
|
} 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
|
// 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.
|
// and replay the script, which is not what a live run looks like.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { HttpResponse, delay, http } from 'msw';
|
||||||
import type { Scenario } from '../api';
|
import type { Scenario } from '../api';
|
||||||
import type { components } from '../api/schema';
|
import type { components } from '../api/schema';
|
||||||
import { runEvents } from './events';
|
import { runEvents } from './events';
|
||||||
|
import { intakeHandler } from './intake';
|
||||||
import { bookId, worldOf, type World } from './worlds';
|
import { bookId, worldOf, type World } from './worlds';
|
||||||
|
|
||||||
type Schemas = components['schemas'];
|
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) {
|
function reads(world: World) {
|
||||||
|
|
@ -163,18 +173,16 @@ function actions(world: World) {
|
||||||
if (missingHeader(request)) return problem(403, problemTitles.noClientHeader);
|
if (missingHeader(request)) return problem(403, problemTitles.noClientHeader);
|
||||||
const body = (await request.json()) as Schemas['RunRequest'];
|
const body = (await request.json()) as Schemas['RunRequest'];
|
||||||
const ceiling = world.ceiling;
|
const ceiling = world.ceiling;
|
||||||
if (
|
// The world may have its own answer — bounds that moved between the read and this call, which
|
||||||
body.ceiling_chapters < ceiling.min_chapters ||
|
// is what the contract's 409 is for. Without one, the bounds it answered the read with decide.
|
||||||
body.ceiling_chapters > ceiling.max_chapters
|
const fits =
|
||||||
) {
|
world.fits?.(body.ceiling_chapters) ??
|
||||||
return problem(409, problemTitles.ceilingTooHigh);
|
(body.ceiling_chapters >= ceiling.min_chapters &&
|
||||||
}
|
body.ceiling_chapters <= ceiling.max_chapters);
|
||||||
const started = runOf(String(params.bookId));
|
if (!fits) return problem(409, problemTitles.ceilingTooHigh);
|
||||||
|
const started = world.startRun(String(params.bookId), body);
|
||||||
if (!started) return problem(404, problemTitles.bookNotFound);
|
if (!started) return problem(404, problemTitles.bookNotFound);
|
||||||
return json<Schemas['Run']>(
|
return json<Schemas['Run']>(started, { status: 202 });
|
||||||
{ ...started, ceiling_chapters: body.ceiling_chapters, verify_bank: body.verify_bank },
|
|
||||||
{ status: 202 },
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.post('*/v0/runs/:runId/stop', ({ request }) => {
|
http.post('*/v0/runs/:runId/stop', ({ request }) => {
|
||||||
|
|
|
||||||
377
frontend/src/mock/intake.ts
Normal file
377
frontend/src/mock/intake.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -5,19 +5,48 @@
|
||||||
|
|
||||||
import { book } from './book';
|
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 = {
|
export const live = {
|
||||||
revision: 1841,
|
revision: 1841,
|
||||||
draftDone: book.progress.draft.done,
|
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. */
|
/** One burst of the run. Counters JUMP: the contract lets the server coalesce frames. */
|
||||||
export function advance(step: number): void {
|
export function advance(step: number): void {
|
||||||
live.revision += 3;
|
live.revision += 3;
|
||||||
live.draftDone = Math.min(draftTotal, live.draftDone + 7 + step * 4);
|
live.draftDone = Math.min(live.draftTotal, live.draftDone + 7 + step * 4);
|
||||||
live.etaSeconds = Math.max(0, 9240 - step * 900);
|
// 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. */
|
/** 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 {
|
return {
|
||||||
...book,
|
...book,
|
||||||
progress: {
|
progress: {
|
||||||
draft: { done: live.draftDone, total: draftTotal },
|
draft: { done: live.draftDone, total: live.draftTotal },
|
||||||
edit: book.progress.edit,
|
edit: book.progress.edit,
|
||||||
eta_seconds: live.etaSeconds,
|
eta_seconds: live.etaSeconds,
|
||||||
},
|
},
|
||||||
|
|
@ -36,5 +65,7 @@ export function liveBook() {
|
||||||
export function rewind(): void {
|
export function rewind(): void {
|
||||||
live.revision = 1841;
|
live.revision = 1841;
|
||||||
live.draftDone = book.progress.draft.done;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
// showcase — the hard case: mid draft wave, a half-decided bank, chapters without labels
|
// 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
|
// scale — the long tail: 2284 chapters and 1200 terms, served in pages
|
||||||
// empty — a user with no books at all
|
// 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
|
// 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
|
// error — the platform refuses, so the error branch is the final picture
|
||||||
// offline — reads work, the live stream does not
|
// offline — reads work, the live stream does not
|
||||||
|
|
@ -13,6 +14,17 @@ import type { Scenario } from '../api';
|
||||||
import type { components } from '../api/schema';
|
import type { components } from '../api/schema';
|
||||||
import { bank } from './bank';
|
import { bank } from './bank';
|
||||||
import { bookId, books, chapters, notes, run, unitsOf } from './book';
|
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 { live, liveBook } from './live';
|
||||||
import { scaleBook, scaleChapters, scaleNotes, scaleTerms, scaleUnitsOf } from './scale';
|
import { scaleBook, scaleChapters, scaleNotes, scaleTerms, scaleUnitsOf } from './scale';
|
||||||
|
|
||||||
|
|
@ -22,6 +34,12 @@ export interface World {
|
||||||
readonly revision: number;
|
readonly revision: number;
|
||||||
readonly books: Schemas['Book'][];
|
readonly books: Schemas['Book'][];
|
||||||
runOf: (id: string) => Schemas['Run'] | null;
|
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'][];
|
chaptersOf: (id: string) => Schemas['Chapter'][];
|
||||||
unitsOf: (bookId: string, chapterId: string) => Schemas['Unit'][];
|
unitsOf: (bookId: string, chapterId: string) => Schemas['Unit'][];
|
||||||
notesOf: (id: string) => Schemas['Note'][];
|
notesOf: (id: string) => Schemas['Note'][];
|
||||||
|
|
@ -30,8 +48,52 @@ export interface World {
|
||||||
ceiling: Schemas['CeilingBounds'];
|
ceiling: Schemas['CeilingBounds'];
|
||||||
/** Rows per page. The scale world pages for real, so following the cursor is exercised live. */
|
/** Rows per page. The scale world pages for real, so following the cursor is exercised live. */
|
||||||
pageSize: number;
|
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'] = {
|
const pausedRun: Schemas['Run'] = {
|
||||||
...run,
|
...run,
|
||||||
id: 'run_38',
|
id: 'run_38',
|
||||||
|
|
@ -52,6 +114,7 @@ const defaultWorld: World = {
|
||||||
},
|
},
|
||||||
runOf: (id) =>
|
runOf: (id) =>
|
||||||
id === bookId ? { ...run, revision: live.revision } : id === 'bk_7' ? pausedRun : null,
|
id === bookId ? { ...run, revision: live.revision } : id === 'bk_7' ? pausedRun : null,
|
||||||
|
startRun: (id, request) => startExisting(defaultWorld)(id, request),
|
||||||
chaptersOf: (id) => (id === bookId ? chapters : []),
|
chaptersOf: (id) => (id === bookId ? chapters : []),
|
||||||
unitsOf: (id, chapterId) => (id === bookId ? unitsOf(chapterId) : []),
|
unitsOf: (id, chapterId) => (id === bookId ? unitsOf(chapterId) : []),
|
||||||
notesOf: (id) => (id === bookId ? notes : []),
|
notesOf: (id) => (id === bookId ? notes : []),
|
||||||
|
|
@ -73,6 +136,7 @@ const scaleWorld: World = {
|
||||||
revision: 4102,
|
revision: 4102,
|
||||||
books: [scaleBook],
|
books: [scaleBook],
|
||||||
runOf: () => ({ ...run, id: 'run_scale' }),
|
runOf: () => ({ ...run, id: 'run_scale' }),
|
||||||
|
startRun: (id, request) => startExisting(scaleWorld)(id, request),
|
||||||
chaptersOf: () => scaleChapters,
|
chaptersOf: () => scaleChapters,
|
||||||
unitsOf: (_, chapterId) => scaleUnitsOf(chapterId),
|
unitsOf: (_, chapterId) => scaleUnitsOf(chapterId),
|
||||||
notesOf: () => scaleNotes,
|
notesOf: () => scaleNotes,
|
||||||
|
|
@ -88,6 +152,7 @@ const emptyWorld: World = {
|
||||||
revision: 1,
|
revision: 1,
|
||||||
books: [],
|
books: [],
|
||||||
runOf: () => null,
|
runOf: () => null,
|
||||||
|
startRun: () => null,
|
||||||
chaptersOf: () => [],
|
chaptersOf: () => [],
|
||||||
unitsOf: () => [],
|
unitsOf: () => [],
|
||||||
notesOf: () => [],
|
notesOf: () => [],
|
||||||
|
|
@ -97,10 +162,39 @@ const emptyWorld: World = {
|
||||||
pageSize: 5000,
|
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> = {
|
const worlds: Record<Scenario, World> = {
|
||||||
showcase: defaultWorld,
|
showcase: defaultWorld,
|
||||||
scale: scaleWorld,
|
scale: scaleWorld,
|
||||||
empty: emptyWorld,
|
empty: emptyWorld,
|
||||||
|
intake: intakeWorld,
|
||||||
loading: defaultWorld,
|
loading: defaultWorld,
|
||||||
error: defaultWorld,
|
error: defaultWorld,
|
||||||
offline: defaultWorld,
|
offline: defaultWorld,
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
/* A calm list of "field — value" pairs, not cards with statistics (prompt §3.9). */
|
||||||
.about {
|
.about {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import type { BookDetail } from '../api';
|
import { pausedReason as pausedReasonWords, rejectReason as rejectReasonWords } from '../api';
|
||||||
import { useText, type MessageKey } from '../i18n/text';
|
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 { counted, date, languageName, number, statusOf, translatedPercent } from './format';
|
||||||
|
import { chapters } from './units';
|
||||||
import styles from './About.module.css';
|
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
|
* 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.
|
* no models or pipeline stages either.
|
||||||
*/
|
*/
|
||||||
/** Plural forms of the chapter counter; Intl picks the category. */
|
export function About({ detail, onStartRun }: { detail: BookDetail; onStartRun?: () => void }) {
|
||||||
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 }) {
|
|
||||||
const text = useText();
|
const text = useText();
|
||||||
const { book, run } = detail;
|
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.title'), book.title],
|
||||||
[
|
[
|
||||||
text('about.languages'),
|
text('about.languages'),
|
||||||
|
|
@ -32,12 +94,17 @@ export function About({ detail }: { detail: BookDetail }) {
|
||||||
[text('about.translated'), `${String(translatedPercent(book.progress))}%`],
|
[text('about.translated'), `${String(translatedPercent(book.progress))}%`],
|
||||||
[text('about.chapters'), number(book.chapter_count)],
|
[text('about.chapters'), number(book.chapter_count)],
|
||||||
[text('about.blocks'), number(book.progress.draft.total)],
|
[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.notes'), number(book.note_count)],
|
||||||
[text('about.added'), date(book.added_at)],
|
[text('about.added'), date(book.added_at)],
|
||||||
];
|
];
|
||||||
if (run) {
|
if (run) {
|
||||||
fields.push(
|
rows.push(
|
||||||
[text('about.runStarted'), date(run.started_at)],
|
[text('about.runStarted'), date(run.started_at)],
|
||||||
// `finished_at` comes both as `null` and as altogether absent: an unfinished run may have no
|
// `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.
|
// 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')],
|
[text('about.verifyBank'), run.verify_bank ? text('about.yes') : text('about.no')],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return rows;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,73 @@
|
||||||
clip-path: inset(50%);
|
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);
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: var(--space-5);
|
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 {
|
.slot {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-small);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,76 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FilePlus2 } from 'lucide-react';
|
import { FilePlus2 } from 'lucide-react';
|
||||||
import { useRef, useState } from '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 { Button } from '../ui/Button';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { ProgressBar } from '../ui/ProgressBar';
|
||||||
|
import { Select } from '../ui/Select';
|
||||||
import { TextField } from '../ui/TextField';
|
import { TextField } from '../ui/TextField';
|
||||||
import { icon } from '../ui/icon';
|
import { icon } from '../ui/icon';
|
||||||
|
import { fileSize, languageName, number, statusOf } from './format';
|
||||||
|
import { sourceLanguages, targetLanguages } from './languages';
|
||||||
import styles from './AddBook.module.css';
|
import styles from './AddBook.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adding a book — with a modal window after the model of antigravity_add_folder.png (remark 12).
|
* Adding a book: the form, the sending, and what came of it.
|
||||||
* 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).
|
* 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 }) {
|
export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
const text = useText();
|
const text = useText();
|
||||||
const [file, setFile] = useState<string | null>(null);
|
const client = useQueryClient();
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const input = useRef<HTMLInputElement>(null);
|
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 = () => {
|
const close = () => {
|
||||||
|
abort.current?.abort();
|
||||||
|
upload.reset();
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
|
setGenre('');
|
||||||
|
setSent(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
if (file === null) return;
|
||||||
|
upload.mutate({ file, sourceLang, targetLang, title, genre });
|
||||||
|
};
|
||||||
|
|
||||||
|
const accepted = upload.data;
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={text('addBook.title')}
|
title={text('addBook.title')}
|
||||||
|
|
@ -33,60 +79,224 @@ export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => v
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button look="action" onPress={close}>
|
<Button look="action" onPress={close}>
|
||||||
{text('addBook.cancel')}
|
{text(accepted ? 'action.done' : 'action.cancel')}
|
||||||
</Button>
|
|
||||||
<Button look="primary" isDisabled={file === null} onPress={close}>
|
|
||||||
{text('addBook.submit')}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{accepted === undefined && (
|
||||||
|
<Button look="primary" isDisabled={file === null || upload.isPending} onPress={send}>
|
||||||
|
{text('addBook.submit')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.form}>
|
{accepted ? (
|
||||||
<div>
|
// The same reason as the live region below: the form is gone and the answer is what
|
||||||
<p className={styles.label}>{text('addBook.fileLabel')}</p>
|
// replaced it, so it is announced rather than merely drawn.
|
||||||
{/* A real file picker, not a drawn one: the file name is needed for real — the automatic
|
<div role="status">
|
||||||
parse takes the title out of it, and without a real name that branch cannot be
|
<Accepted book={accepted} />
|
||||||
checked by a frame. */}
|
</div>
|
||||||
<input
|
) : (
|
||||||
className={styles.hidden}
|
<div className={styles.form}>
|
||||||
ref={input}
|
<div>
|
||||||
type="file"
|
<p className={styles.label}>{text('addBook.fileLabel')}</p>
|
||||||
tabIndex={-1}
|
{/* A real file picker, not a drawn one: the file name is needed for real — the automatic
|
||||||
aria-hidden="true"
|
parse takes the title out of it, and without a real name that branch cannot be
|
||||||
onChange={(event) => setFile(event.target.files?.[0]?.name ?? null)}
|
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
|
<div className={styles.pair}>
|
||||||
label={text('addBook.titleLabel')}
|
<Select
|
||||||
value={title}
|
label={text('addBook.sourceLangLabel')}
|
||||||
onChange={setTitle}
|
options={sourceLanguages.map((code) => ({ id: code, label: languageName(code) }))}
|
||||||
placeholder={file === null ? text('addBook.titlePlaceholder') : parsedTitle(file)}
|
value={sourceLang}
|
||||||
hint={title === '' ? text('addBook.titleHintAuto') : text('addBook.titleHintManual')}
|
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
|
<TextField
|
||||||
not exist: a button without an action is forbidden (Ф-7). */}
|
label={text('addBook.genreLabel')}
|
||||||
<div className={styles.slots}>
|
value={genre}
|
||||||
<p className={styles.label}>{text('addBook.settingsLabel')}</p>
|
onChange={setGenre}
|
||||||
<p className={styles.slot}>{text('addBook.settingsSlot')}</p>
|
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>
|
||||||
</div>
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The automatic parse of the title out of the file name — that very second source signed in the
|
* How far the file has got — in three states, and the first of them is the one that is easy to get
|
||||||
* form.
|
* 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 {
|
function parsedTitle(file: string): string {
|
||||||
return file
|
return file.replace(/\.[^.]+$/, '').trim();
|
||||||
.replace(/\.[^.]+$/, '')
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ interface Props {
|
||||||
/** The palette's request to show a term. The search string is the bank's own to keep. */
|
/** The palette's request to show a term. The search string is the bank's own to keep. */
|
||||||
request: { term: string; seq: number };
|
request: { term: string; seq: number };
|
||||||
onOpenChapter: (chapterId: string) => void;
|
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,
|
onTabChange,
|
||||||
request,
|
request,
|
||||||
onOpenChapter,
|
onOpenChapter,
|
||||||
|
onStartRun,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const text = useText();
|
const text = useText();
|
||||||
// Telling the reader to pick a book from a panel that says "not a single book" is a screen
|
// 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'),
|
label: text('about.tab'),
|
||||||
content: (
|
content: (
|
||||||
<Loaded query={book} waiting={text('about.tab')} idle={idle}>
|
<Loaded query={book} waiting={text('about.tab')} idle={idle}>
|
||||||
{(detail) => <About detail={detail} />}
|
{(detail) => <About detail={detail} onStartRun={onStartRun} />}
|
||||||
</Loaded>
|
</Loaded>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,16 @@
|
||||||
color: var(--color-text-secondary);
|
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 {
|
[data-selected] .progress {
|
||||||
background-color: var(--color-text-secondary);
|
background-color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
@ -103,3 +113,12 @@
|
||||||
[data-selected] .progressFill[data-done] {
|
[data-selected] .progressFill[data-done] {
|
||||||
background-color: var(--color-text);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { BookIcon, FileText } from 'lucide-react';
|
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 type { Book, Chapter, ChapterList, Library as LibraryData } from '../api';
|
||||||
import { useText, type MessageKey, type Text } from '../i18n/text';
|
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
|
// The chapters of the open book only: a tree that fetched every book's chapters would read the
|
||||||
// whole library to draw one row.
|
// whole library to draw one row.
|
||||||
const rows = useMemo(() => chapters.data?.chapters ?? [], [chapters.data]);
|
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
|
// 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
|
// reads as "this book has none", while the card next to it says 2284. The state goes where the
|
||||||
// chapters would have been.
|
// chapters would have been.
|
||||||
|
|
@ -123,16 +142,48 @@ function Books({
|
||||||
),
|
),
|
||||||
[books, rows, openBookId, state, text],
|
[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 (
|
return (
|
||||||
<Tree
|
<Tree
|
||||||
label={text('library.treeLabel')}
|
label={text('library.treeLabel')}
|
||||||
items={items}
|
items={items}
|
||||||
selectedId={selectedId}
|
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}
|
onActivate={onPin}
|
||||||
onPreload={onPreload}
|
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
|
// One row saying what happened, in the place the sections would occupy. Not selectable: the screen
|
||||||
// only opens ids it found among the chapters.
|
// only opens ids it found among the chapters.
|
||||||
function childrenOf(
|
function childrenOf(
|
||||||
|
|
@ -175,9 +253,21 @@ function childrenOf(
|
||||||
text: Text,
|
text: Text,
|
||||||
): TreeNode[] | undefined {
|
): TreeNode[] | undefined {
|
||||||
if (chaptersState !== null) return [{ id: `${book.id}:chapters-state`, title: chaptersState }];
|
if (chaptersState !== null) return [{ id: `${book.id}:chapters-state`, title: chaptersState }];
|
||||||
return chapters.length > 0
|
if (chapters.length === 0) return undefined;
|
||||||
? chapters.map((chapter, index) => chapterNode(chapter, index, text))
|
if (chapters.length <= groupFrom) {
|
||||||
: undefined;
|
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 {
|
function chapterNode(chapter: Chapter, index: number, text: Text): TreeNode {
|
||||||
|
|
|
||||||
41
frontend/src/showcase/RunStart.module.css
Normal file
41
frontend/src/showcase/RunStart.module.css
Normal 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;
|
||||||
|
}
|
||||||
189
frontend/src/showcase/RunStart.tsx
Normal file
189
frontend/src/showcase/RunStart.tsx
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { Document } from './Document';
|
||||||
import { Goto } from './Goto';
|
import { Goto } from './Goto';
|
||||||
import { Library } from './Library';
|
import { Library } from './Library';
|
||||||
import type { Query } from './Loaded';
|
import type { Query } from './Loaded';
|
||||||
|
import { RunStart } from './RunStart';
|
||||||
import { Settings } from './Settings';
|
import { Settings } from './Settings';
|
||||||
import { Status } from './Status';
|
import { Status } from './Status';
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,10 +29,11 @@ import {
|
||||||
selectDocument,
|
selectDocument,
|
||||||
} from './documents';
|
} from './documents';
|
||||||
import { chapterLabel, isDraft } from './format';
|
import { chapterLabel, isDraft } from './format';
|
||||||
|
import { useIntakeEnd } from './useIntakeEnd';
|
||||||
import { useRunStream } from './useRunStream';
|
import { useRunStream } from './useRunStream';
|
||||||
import { useScreenshotFlag } from './useScreenshotFlag';
|
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
|
* 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() {
|
export function Showcase() {
|
||||||
const text = useText();
|
const text = useText();
|
||||||
const library = useQuery(libraryQuery());
|
const library = useQuery(libraryQuery());
|
||||||
// The library is the entry: which book is open is a consequence of what the platform returned,
|
// Which book is open: the one chosen in the tree, and the first the platform returned until
|
||||||
// not a constant of the fixture.
|
// something is chosen (the zone's §2 — "a click on a book opens its state"). Checked against the
|
||||||
const bookId = library.data?.books[0]?.id;
|
// 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 enabled = bookId !== undefined;
|
||||||
|
|
||||||
const book = useQuery({ ...bookQuery(bookId ?? ''), enabled });
|
const book = useQuery({ ...bookQuery(bookId ?? ''), enabled });
|
||||||
|
|
@ -65,6 +72,7 @@ export function Showcase() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const connection = useRunStream(bookId, book.data?.run?.id);
|
const connection = useRunStream(bookId, book.data?.run?.id);
|
||||||
|
useIntakeEnd(bookId, book.data?.book.status);
|
||||||
const expandPanel = useLayout((state) => state.expand);
|
const expandPanel = useLayout((state) => state.expand);
|
||||||
|
|
||||||
const [overlay, setOverlay] = useState<Overlay>(null);
|
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
|
// 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
|
// 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).
|
// 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) => {
|
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) => {
|
const pin = (id: string) => {
|
||||||
if (positionOf(id) >= 0) setOpened(pinDocument(documents, id));
|
if (positionOf(id) >= 0) setOpened(pinDocument(documents, id));
|
||||||
|
|
@ -131,7 +150,7 @@ export function Showcase() {
|
||||||
<>
|
<>
|
||||||
<ScreenshotFlag />
|
<ScreenshotFlag />
|
||||||
<Shell
|
<Shell
|
||||||
title={book.data?.book.title ?? 'TextMachine'}
|
title={book.data?.book.title ?? text('shell.appName')}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -157,7 +176,9 @@ export function Showcase() {
|
||||||
library={library}
|
library={library}
|
||||||
chapters={awaiting(chapters)}
|
chapters={awaiting(chapters)}
|
||||||
openBookId={bookId}
|
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}
|
onOpen={open}
|
||||||
onPin={pin}
|
onPin={pin}
|
||||||
onPreload={preload}
|
onPreload={preload}
|
||||||
|
|
@ -188,6 +209,7 @@ export function Showcase() {
|
||||||
onTabChange={setContextTab}
|
onTabChange={setContextTab}
|
||||||
request={bankRequest}
|
request={bankRequest}
|
||||||
onOpenChapter={open}
|
onOpenChapter={open}
|
||||||
|
onStartRun={enabled ? () => setOverlay('run') : undefined}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
status={
|
status={
|
||||||
|
|
@ -201,6 +223,9 @@ export function Showcase() {
|
||||||
/>
|
/>
|
||||||
<Settings isOpen={overlay === 'settings'} onClose={() => setOverlay(null)} />
|
<Settings isOpen={overlay === 'settings'} onClose={() => setOverlay(null)} />
|
||||||
<AddBook isOpen={overlay === 'add-book'} onClose={() => setOverlay(null)} />
|
<AddBook isOpen={overlay === 'add-book'} onClose={() => setOverlay(null)} />
|
||||||
|
{bookId !== undefined && (
|
||||||
|
<RunStart bookId={bookId} isOpen={overlay === 'run'} onClose={() => setOverlay(null)} />
|
||||||
|
)}
|
||||||
<Goto
|
<Goto
|
||||||
isOpen={overlay === 'goto'}
|
isOpen={overlay === 'goto'}
|
||||||
onClose={() => setOverlay(null)}
|
onClose={() => setOverlay(null)}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ const build = (locale: string) => ({
|
||||||
languages: new Intl.DisplayNames([locale], { type: 'language' }),
|
languages: new Intl.DisplayNames([locale], { type: 'language' }),
|
||||||
numbers: new Intl.NumberFormat(locale),
|
numbers: new Intl.NumberFormat(locale),
|
||||||
plurals: new Intl.PluralRules(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() {
|
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 languageName = (code: string) => intl().languages.of(code) ?? code;
|
||||||
export const number = (value: number) => intl().numbers.format(value);
|
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
|
* 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
|
* keys, so the words stay in the catalogue — and `Intl` picks the category; no table of endings is
|
||||||
|
|
|
||||||
16
frontend/src/showcase/languages.ts
Normal file
16
frontend/src/showcase/languages.ts
Normal 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;
|
||||||
17
frontend/src/showcase/units.ts
Normal file
17
frontend/src/showcase/units.ts
Normal 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',
|
||||||
|
};
|
||||||
33
frontend/src/showcase/useIntakeEnd.ts
Normal file
33
frontend/src/showcase/useIntakeEnd.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
||||||
import { render, waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, delay, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest';
|
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
|
// Ф-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
|
// 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
|
// 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);
|
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 —
|
// Ф-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.
|
// so the snapshot's revision has to go with the snapshot.
|
||||||
test('a resync drops the book from the cache, marks and all', async () => {
|
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
|
// 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
|
// 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.
|
// 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 />);
|
const client = mount([], <WiredScreen />);
|
||||||
await settled(client);
|
await settled(client);
|
||||||
expect(FakeEventSource.opened).toBe(1);
|
expect(FakeEventSource.opened).toBe(1);
|
||||||
|
|
@ -358,6 +475,12 @@ test('a resync does not restart the live stream', async () => {
|
||||||
hello();
|
hello();
|
||||||
emit('resync_required', { reason: 'the book was rebuilt' }, 12);
|
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 settled(client);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(client.getQueryData<BookDetail>(keys.book(bookId))?.revision).toBe(12);
|
expect(client.getQueryData<BookDetail>(keys.book(bookId))?.revision).toBe(12);
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,31 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [state, setState] = useState<ConnectionState>('connecting');
|
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(() => {
|
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
|
// 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.
|
// 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);
|
apply(frame, revision, bookId, client, asked);
|
||||||
},
|
},
|
||||||
onResync: () => {
|
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
|
// Replaying history is forbidden by the contract, so the only correct answer to "I cannot
|
||||||
// resume you" is to read the snapshots again.
|
// 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));
|
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
|
// 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
|
// 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
|
// (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.
|
// 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>;
|
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
|
// 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.
|
// single counter (K-10) a chapter frame may not arrive once in a whole draft wave.
|
||||||
rereadAll(client, keys.bookUnits(bookId), asked);
|
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;
|
return;
|
||||||
|
|
||||||
case 'chapter':
|
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
|
* 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
|
* 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.
|
* 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 {
|
function reread(client: Client, key: readonly unknown[], asked: Set<string>): void {
|
||||||
const mark = key.join('/');
|
const mark = key.join('/');
|
||||||
|
|
@ -144,7 +190,7 @@ function reread(client: Client, key: readonly unknown[], asked: Set<string>): vo
|
||||||
}
|
}
|
||||||
if (asked.has(mark)) return;
|
if (asked.has(mark)) return;
|
||||||
asked.add(mark);
|
asked.add(mark);
|
||||||
void client.resetQueries({ queryKey: key });
|
void client.resetQueries({ queryKey: key }).finally(() => asked.delete(mark));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@
|
||||||
the panel and the non-text floor is 3:1 (measured, S3.7) */
|
the panel and the non-text floor is 3:1 (measured, S3.7) */
|
||||||
--color-ok: #3c7d4f;
|
--color-ok: #3c7d4f;
|
||||||
--color-warn: #b0863a;
|
--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-separator: #262626; /* rules, and the quiet severity step of a note */
|
||||||
--color-scrollbar-thumb: #333;
|
--color-scrollbar-thumb: #333;
|
||||||
|
|
||||||
|
|
@ -53,6 +54,13 @@
|
||||||
--radius-panel: 6px; /* the corner comes out onto the straight line after 12 phys. */
|
--radius-panel: 6px; /* the corner comes out onto the straight line after 12 phys. */
|
||||||
--radius-control: 6px;
|
--radius-control: 6px;
|
||||||
--radius-bar: 2px; /* the progress bar of a chapter */
|
--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
|
/* 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
|
centred within the bar itself. The former split "8 of margin + 28 of bar" kept the text at the
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ const measured: Record<string, string> = {
|
||||||
'--color-selection': '#164e8d',
|
'--color-selection': '#164e8d',
|
||||||
'--color-tab-active': '#142f4c',
|
'--color-tab-active': '#142f4c',
|
||||||
'--color-accent': '#746deb',
|
'--color-accent': '#746deb',
|
||||||
'--color-danger': '#b82e45',
|
|
||||||
'--color-ok': '#3c7d4f',
|
'--color-ok': '#3c7d4f',
|
||||||
'--gap': '8px',
|
'--gap': '8px',
|
||||||
'--radius-panel': '6px',
|
'--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-raised': '#212121', // fleet_2 #292929 is the strip; hover sits below it
|
||||||
'--color-selected': '#1c4478', // fleet_2 selected row #184176
|
'--color-selected': '#1c4478', // fleet_2 selected row #184176
|
||||||
'--color-note': '#2a66b0', // antigravity NOTE #2964ad, lifted to the 3:1 non-text floor
|
'--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-warn': '#b0863a', // no amber in any reference; between note and danger
|
||||||
'--color-text': '#dedede', // fleet #dfe1e3, neutralised
|
'--color-text': '#dedede', // fleet #dfe1e3, neutralised
|
||||||
'--color-text-secondary': '#9b9b9b', // fleet #8a8e91, neutralised and lifted for WCAG
|
'--color-text-secondary': '#9b9b9b', // fleet #8a8e91, neutralised and lifted for WCAG
|
||||||
|
|
@ -73,6 +73,9 @@ const chosen = [
|
||||||
'--color-border',
|
'--color-border',
|
||||||
'--radius-control',
|
'--radius-control',
|
||||||
'--radius-bar',
|
'--radius-bar',
|
||||||
|
'--radius-round',
|
||||||
|
'--checkbox-size',
|
||||||
|
'--thumb-size',
|
||||||
'--panel-side-min',
|
'--panel-side-min',
|
||||||
'--panel-side-max',
|
'--panel-side-max',
|
||||||
'--row-inset',
|
'--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([]);
|
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', () => {
|
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;')).toBeGreaterThanOrEqual(0);
|
||||||
expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {'));
|
expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {'));
|
||||||
|
|
@ -162,9 +182,11 @@ describe('tokens.css', () => {
|
||||||
const declared = new Set(
|
const declared = new Set(
|
||||||
tokens.match(/^\s*(--[\w-]+)\s*:/gm)?.map((m) => m.trim().slice(0, -1)),
|
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
|
// Not tokens but inputs of a component: `--progress` is set on the element through style, and
|
||||||
// explicit so that "the variable is simply not declared" does not hide behind a general rule.
|
// `--trigger-width` is written by the primitives library on its own popup (the width of the
|
||||||
const local = new Set(['--progress']);
|
// 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>();
|
const used = new Set<string>();
|
||||||
for (const source of Object.values(styleSheets)) {
|
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
|
// 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
|
// 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
|
// import and the tokens go", and until that decision the tones stand in reserve rather than
|
||||||
|
|
|
||||||
55
frontend/src/ui/Checkbox.module.css
Normal file
55
frontend/src/ui/Checkbox.module.css
Normal 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;
|
||||||
|
}
|
||||||
29
frontend/src/ui/Checkbox.tsx
Normal file
29
frontend/src/ui/Checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/ui/ProgressBar.module.css
Normal file
36
frontend/src/ui/ProgressBar.module.css
Normal 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);
|
||||||
|
}
|
||||||
32
frontend/src/ui/ProgressBar.tsx
Normal file
32
frontend/src/ui/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/ui/Select.module.css
Normal file
69
frontend/src/ui/Select.module.css
Normal 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);
|
||||||
|
}
|
||||||
58
frontend/src/ui/Select.tsx
Normal file
58
frontend/src/ui/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/ui/Slider.module.css
Normal file
68
frontend/src/ui/Slider.module.css
Normal 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);
|
||||||
|
}
|
||||||
72
frontend/src/ui/Slider.tsx
Normal file
72
frontend/src/ui/Slider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
* open tab does not move), so a read per focused row is a read of chapters nobody asked for.
|
||||||
*/
|
*/
|
||||||
onPreload?: (id: string) => void;
|
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,
|
onSelect,
|
||||||
onActivate,
|
onActivate,
|
||||||
onPreload,
|
onPreload,
|
||||||
defaultExpandedIds,
|
expandedIds,
|
||||||
|
onExpandedChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// The level indent comes off the library's own data-level, so nesting is not counted by hand.
|
// The level indent comes off the library's own data-level, so nesting is not counted by hand.
|
||||||
const renderNode = (node: TreeNode) => (
|
const renderNode = (node: TreeNode) => (
|
||||||
|
|
@ -133,7 +140,10 @@ export function Tree({
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
if (keys !== 'all') for (const key of keys) onSelect(String(key));
|
if (keys !== 'all') for (const key of keys) onSelect(String(key));
|
||||||
}}
|
}}
|
||||||
defaultExpandedKeys={defaultExpandedIds}
|
expandedKeys={expandedIds}
|
||||||
|
onExpandedChange={(keys) => {
|
||||||
|
onExpandedChange(new Set([...keys].map(String)));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{renderNode}
|
{renderNode}
|
||||||
</AriaTree>
|
</AriaTree>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue