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/api-contract/openapi.yaml`](docs/api-contract/openapi.yaml) — **сам контракт v0,
|
||||
OpenAPI 3.1: нормативная поверхность.** Линтуется spectral шестым шагом `npm run check`,
|
||||
из неё генерится `src/api/schema.ts`. Черновик на ратификацию: дом готовой копии —
|
||||
`docs/architecture/14-api-contract/`, зона оркестратора.
|
||||
OpenAPI 3.1: нормативная поверхность.** Линтуется spectral шагом `npm run check`, из неё
|
||||
генерится `src/api/schema.ts`. ⚠ **Не черновик** (исправлено S4 14.08): контракт ратифицирован
|
||||
D39.99, эта копия — байт-зеркало канона `docs/architecture/14-api-contract/`; правит её
|
||||
фронт-сессия, переносит в канон диффом оркестратор при лендинге (прецедент Ф-47/0.2.2).
|
||||
- [`docs/API_CONTRACT_DRAFT.md`](docs/API_CONTRACT_DRAFT.md) — спутник спеки: откуда взято
|
||||
каждое решение (выведено из кода движка · предложено фронтом · открыто), обоснования,
|
||||
зависимости и двенадцать открытых вопросов. При расхождении по форме побеждает YAML,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -25,9 +25,9 @@
|
|||
| [../../docs/README.md](../../docs/README.md) | карта: где что лежит и что чем перекрыто | первым делом |
|
||||
| [../../docs/product-requirements.md](../../docs/product-requirements.md) | реестр ПТ-1..ПТ-34; **ПТ-33 и ПТ-34 — жёсткие инварианты интерфейса**, ПТ-21 задаёт якорь чтения | до первого экрана |
|
||||
| [../../docs/research/23-engine-platform-seam.md](../../docs/research/23-engine-platform-seam.md) | форма шва движок↔платформа; `docs/README.md` требует читать его **перед любым кодом стыка** | до любого кода данных |
|
||||
| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет: строки **95** (контракт API) · **99–103** (прогресс, манифест, таблица подписи, trace, эмиттер) · **49** (annot-v1) · **54** (масштаб) | перед планированием этапа |
|
||||
| CURRENT-STATE + единый бэклог в [../../docs/PROGRESS.md](../../docs/PROGRESS.md) | что движок обязан отдать фронту и чего ещё нет. ⚠ **Актуализировано S4 14.08:** строка 95 (контракт API) ЗАКРЫТА ратификацией D39.99, строки 99/100/103 построены (D39.122/131) — таблица ниже больше не подаёт их как «чего нет»; живые для фронта — **101** (машиночитаемая таблица подписи, вход S5) и **169** (экспорт банка) | перед планированием этапа |
|
||||
| [STACK_DECISIONS.md](STACK_DECISIONS.md) §5 | транспорт до фронта и правила стрима | до слоя данных |
|
||||
| [API_CONTRACT_INPUT.md](API_CONTRACT_INPUT.md) | что фронт просит у контракта API v0 и почему — с грунтом `file:line` по движку; там же шесть вопросов, на которые у фронта ответа нет | до слоя данных; при появлении контракта — сверить построчно |
|
||||
| [api-contract/openapi.yaml](api-contract/openapi.yaml) + компаньон рядом с каноном (`../../docs/architecture/14-api-contract/README.md`) | **ратифицированный контракт** — форма данных, коды ответов, словари. ⚠ Заменил `API_CONTRACT_INPUT.md`: тот под баннером «исполнено» и живым входом больше не является | до слоя данных и при каждой правке формы |
|
||||
| [../../docs/glossary.md](../../docs/glossary.md) | жаргон проекта (D-номер, банк, голден, юнит) | при первом непонятном слове |
|
||||
|
||||
Правило чтения [05-decisions-log.md](../../docs/architecture/05-decisions-log.md): карта актуальности
|
||||
|
|
@ -39,8 +39,10 @@
|
|||
|
||||
**С движком — никогда.** Это не стилистика, а ратифицированный анти-паттерн: движок — CLI-процесс
|
||||
на прогон под эксклюзивным локом, его SQLite платформой не читается, HTTP внутрь него не тащится
|
||||
(`research/23` §4, §0). Между фронтом и движком стоит платформа, у которой пока ноль строк кода
|
||||
(`../../platform/BACKLOG.md` П-1).
|
||||
(`research/23` §4, §0). Между фронтом и движком стоит платформа. ⚠ **Актуализировано S4 14.08:**
|
||||
«у платформы пока ноль строк кода» устарело — P5 принята D39.130, живы вход OIDC, библиотека,
|
||||
кредитный леджер, раннер и `POST /books`; чего у неё нет, смотреть в `../../platform/BACKLOG.md`,
|
||||
а не здесь.
|
||||
|
||||
Проводов два, и их легко перепутать:
|
||||
|
||||
|
|
@ -204,9 +206,12 @@ CJK-шрифт не ставим: иероглифы отдаём системн
|
|||
добавление нового состояния главы или нового вида замечания правит **один** файл.
|
||||
Правит три — структура неверна, переделать до движения дальше.
|
||||
|
||||
Ответ этой структуры: состояние главы живёт как строка типа в `src/api/types.ts` и
|
||||
как одна запись в карте отображения `src/features/chapters/`. Цвет индикатора — токен.
|
||||
Новый вид замечания — запись в карте видов, компонент выноски не трогается.
|
||||
Ответ этой структуры (⚠ актуализировано S4 14.08 — `src/api/types.ts` удалён ещё в S3, типы
|
||||
приходят из спеки): состояние книги живёт **одной записью в `src/api/vocabulary.ts`** — там же его
|
||||
слово, тон и то, что с ним можно делать (`startable`, `intake`); экраны эту запись читают и ничего
|
||||
о списке значений не знают. Цвет — токен. Новый вид замечания — запись в карте видов, компонент
|
||||
выноски не трогается. Проверено S4: добавление свойства «книгу в этом состоянии можно запустить»
|
||||
тронуло один файл, а новое состояние без слова роняет `tsc` на этой же карте.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@
|
|||
|
||||
**Бэкенда пока нет** — точнее, движок перевода есть, но HTTP-API к нему ещё не построено. Ты работаешь на моках (см. §6). Это не помеха: контракт данных зафиксирован ниже, замена моков на реальные запросы будет точечной.
|
||||
|
||||
> ⚠ **Поправка канона №2 (оркестратор, 04.08, D39.99/100/101):** контракт API v0 РАТИФИЦИРОВАН (`docs/architecture/14-api-contract/` — OpenAPI 3.1, типы генерятся из спеки), строка 95 закрыта; скелет платформы P0 существует (ждёт приёмки); слой данных S3 строится ПО КОНТРАКТУ, не по гипотезам.
|
||||
> ⚠ **Поправка канона №2 (оркестратор, 04.08, D39.99/100/101):** контракт API v0 РАТИФИЦИРОВАН (`docs/architecture/14-api-contract/` — OpenAPI 3.1, типы генерятся из спеки), строка 95 закрыта; слой данных S3 строится ПО КОНТРАКТУ, не по гипотезам.
|
||||
> ⚠ **Поправка S4 (14.08):** «скелет платформы P0 существует, ждёт приёмки» устарело — **P5 принята (D39.130)**, живой `POST /books` построен, и путь загрузки книги S4 стоит на нём.
|
||||
> ⚠ **Поправка канона (фронт-сессия S1, 02.08).** Формулировка выше читается как «однажды у движка появится HTTP-API». Это не так, и ошибка дорогая: **API будет у платформы, не у движка**, а движок фронту не виден вообще. Прежде чем писать слой данных, прочитай `FRONTEND_PLAN.md` §0.1–0.2 (карта канона и два провода) и первоисточник `docs/research/23-engine-platform-seam.md`. Там же цена ошибки: моки, снятые не с того уровня контракта, разойдутся с API — ровно то, ради предотвращения чего заведена строка 95 единого бэклога.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -141,10 +141,14 @@
|
|||
### Одна команда проверки
|
||||
|
||||
```
|
||||
npm run check # prettier --check → eslint → tsc --noEmit → vitest run
|
||||
npm run check:full # + vite build + e2e
|
||||
npm run check # prettier → eslint → stylelint → spectral (контракт) → tsc → vitest
|
||||
npm run check:full # + vite build + npm run shot (маршруты и axe) + npm run scenes (сценарии)
|
||||
```
|
||||
|
||||
⚠ Состав сверен S4 14.08 по `package.json` (`node -e` по `scripts.check`): **шесть** шагов у
|
||||
`check` и три добавочных у `check:full`. Раньше здесь стояли четыре — список отстал на два пака.
|
||||
⚠ Промт S4 §7 назвал семь; это ошибка промта, и первая редакция этой правки её повторила.
|
||||
|
||||
CI вызывает **именно их**, а не дублирует список инструментов. Path-фильтры на уровне job'ов
|
||||
(правка CSS не должна гонять тесты Go) плюс агрегирующий job с явной проверкой
|
||||
`contains(needs.*.result,'failure')||contains(needs.*.result,'cancelled')`. ~~Git-хуков в MVP нет~~ —
|
||||
|
|
@ -176,7 +180,7 @@ CI вызывает **именно их**, а не дублирует списо
|
|||
|
||||
| Что | Пин | Заметка |
|
||||
|---|---|---|
|
||||
| Go | `1.26.4` в `go.mod`, ⚠ тулчейн сборки платформы **≥1.26.5** (`platform/Makefile` `GO_MIN_VERSION` — security-фиксы crypto/tls и os в сетевом модуле); пин живёт в Makefile, здесь только указатель | |
|
||||
| Go | `1.26.4` в `go.mod`, ⚠ тулчейн сборки платформы **≥1.26.6** (`platform/Makefile` `GO_MIN_VERSION`, ратифицирован D39.130; сверено S4 14.08 по самому Makefile); пин живёт в Makefile, здесь только указатель | |
|
||||
| HTTP | стандартный `net/http` + `ServeMux` | роутер-библиотеку не тянуть |
|
||||
| Postgres | `pgx v5.10.0` | нижняя допустимая граница 5.9.2, **не** 5.9.0 |
|
||||
| Миграции | `goose v3.27.3` | как библиотека, `embed.FS`, `WithLocker` |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ openapi: 3.1.0
|
|||
|
||||
info:
|
||||
title: TextMachine API
|
||||
version: 0.2.2
|
||||
version: 0.2.3
|
||||
summary: Ratified contract between the frontend and the TextMachine platform (D39.99).
|
||||
description: |
|
||||
**RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in
|
||||
|
|
@ -114,8 +114,40 @@ paths:
|
|||
operationId: createBook
|
||||
summary: Add a book.
|
||||
description: |
|
||||
Accepts the file and the properties declared by the user. Responds immediately; the book
|
||||
enters `uploading` and parsing is a separate visible step.
|
||||
Accepts the file and the properties declared by the user.
|
||||
|
||||
**The `file` part MUST come LAST in the form** (0.2.3). The platform reads the form as a
|
||||
STREAM, part by part, and stops at the file: the book's row — the record that makes an
|
||||
upload visible while it arrives and findable when it dies halfway — cannot be written
|
||||
before the languages that row requires.
|
||||
|
||||
A part sent after the file is therefore NOT READ AT ALL. What that costs depends on the
|
||||
part: a required one (`source_lang`, `target_lang`) is answered `400`, because to a reader
|
||||
that streams "the languages came late" and "the languages never came" are the same thing;
|
||||
an optional one is silently lost, and the book is created without it. A client that puts
|
||||
anything after the file is a client whose form is partly ignored.
|
||||
|
||||
**The `201` carries `parsing`, not `uploading`** (0.2.3). The answer is written after the
|
||||
last byte has landed, so by the time a client can read it the file is in and the book has
|
||||
moved on. `uploading` is a real state and is observable — by a SECOND read of the library
|
||||
while the upload is still on the wire — but never as the answer to this call.
|
||||
|
||||
Parsing is a separate visible step after that, and it has no numeric progress: no counter of
|
||||
it exists on this contract, and a percentage would have to be invented.
|
||||
|
||||
Refusals of the intake, all of them product states of the form rather than failures of the
|
||||
service:
|
||||
|
||||
- `400` — the form could not be read: more than 16 parts, a text field longer than a
|
||||
kilobyte, a REQUIRED field that arrived after the file (or never), or no file at all;
|
||||
- `404` — this deployment does not accept books at all. An instance with no place to put a
|
||||
file and no engine to cut it with serves the library and refuses this path, which is a
|
||||
property of the DEPLOYMENT and not of the request;
|
||||
- `408` — the body did not finish inside the route's deadline: a slow client on a large
|
||||
book. Retrying is the remedy, which is what separates it from `413`;
|
||||
- `413` — the body is over the intake cap. The threshold belongs to the deployment and is
|
||||
not stated here: a number in the contract would be a second copy of it, and the two would
|
||||
disagree the day it changes.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -123,12 +155,14 @@ paths:
|
|||
schema: { $ref: '#/components/schemas/BookIntake' }
|
||||
responses:
|
||||
'201':
|
||||
description: Book accepted.
|
||||
description: Book accepted; it is being parsed.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Book' }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'408': { $ref: '#/components/responses/RequestTimeout' }
|
||||
'413': { $ref: '#/components/responses/TooLarge' }
|
||||
|
||||
/books/{bookId}:
|
||||
|
|
@ -412,6 +446,11 @@ paths:
|
|||
|
||||
**Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a
|
||||
complete set.
|
||||
|
||||
**503 answers a deployment that cannot run at all** (0.2.3). Continuing a run is starting a
|
||||
process, so it needs exactly what a start needs — the seam that tells the engine its ceiling
|
||||
and the one that records how a run ended. 0.2.1 named that state for `startRun` only, and
|
||||
the omission was in the SPEC and not in the wire: the two calls share one refusal.
|
||||
responses:
|
||||
'202':
|
||||
description: Resume accepted.
|
||||
|
|
@ -421,6 +460,7 @@ paths:
|
|||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
|
||||
/usage:
|
||||
get:
|
||||
|
|
@ -601,11 +641,20 @@ components:
|
|||
content:
|
||||
application/problem+json:
|
||||
schema: { $ref: '#/components/schemas/Problem' }
|
||||
RequestTimeout:
|
||||
description: |
|
||||
The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on
|
||||
a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the
|
||||
remedy — which is what a `413` and a `500` in its place would both hide.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema: { $ref: '#/components/schemas/Problem' }
|
||||
ServiceUnavailable:
|
||||
description: |
|
||||
The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a
|
||||
run requires the engine seam to be fully configured, and answering with any other code
|
||||
would misname the state. Temporary by nature — retry later; no Retry-After is promised.
|
||||
The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting or
|
||||
continuing a run requires the engine seam to be fully configured, and answering with any
|
||||
other code would misname the state. Temporary by nature — retry later; no Retry-After is
|
||||
promised.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema: { $ref: '#/components/schemas/Problem' }
|
||||
|
|
@ -725,6 +774,31 @@ components:
|
|||
- rejected
|
||||
- failed
|
||||
|
||||
RejectReason:
|
||||
type: string
|
||||
description: |
|
||||
Machine reason a book was rejected (0.2.3). The platform's own closed vocabulary; as with
|
||||
`PausedReason` the API carries STATE and the phrase the user reads is drawn by the client,
|
||||
so no wording appears here.
|
||||
|
||||
- `source_unreadable` — the file was read and is not a book this service can cut: it yields
|
||||
no sections at all, or the reader refused it. TERMINAL, and the source does not survive
|
||||
it — there is no path in this contract that re-reads a rejected book, so the remedy is to
|
||||
add the book again;
|
||||
- `not_configured` — this deployment has nothing to read the book AGAINST. It is a state of
|
||||
the service, never of the file, and retrying by itself does not clear it;
|
||||
- `parser_unavailable` — the service could not process the file, repeatedly, until it gave
|
||||
up. A state of the service as well, and a temporary one.
|
||||
|
||||
The three are two different NEXT ACTIONS for the user, and a client that told them apart by
|
||||
colour alone would be telling them apart by nothing: the first means "the file is not one we
|
||||
can read", the other two mean "not us, not now".
|
||||
|
||||
A client MUST tolerate an unknown value arriving under a minor bump, and MUST render a
|
||||
rejected book that carries no reason at all: a deployment older than this minor answers
|
||||
exactly that.
|
||||
enum: [source_unreadable, not_configured, parser_unavailable]
|
||||
|
||||
Book:
|
||||
type: object
|
||||
description: A book in the library.
|
||||
|
|
@ -743,6 +817,18 @@ components:
|
|||
description: Size in characters. Not an engine field; the platform knows it from intake.
|
||||
added_at: { type: string, format: date-time }
|
||||
status: { $ref: '#/components/schemas/BookStatus' }
|
||||
reject_reason:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/RejectReason'
|
||||
- type: 'null'
|
||||
description: |
|
||||
Why the book was rejected; meaningful only while `status` is `rejected` (0.2.3).
|
||||
|
||||
OPTIONAL, unlike `Run.paused_reason`, and the asymmetry is deliberate: a rejection
|
||||
reason is meaningful in exactly one of eleven states, and a deployment that predates
|
||||
this minor sends nothing here at all. "Absent" and "null" therefore mean one and the
|
||||
same thing — the reason is not known — and a client renders both the same way. That is
|
||||
the opposite of the `sense` case (0.2.2), where the two shapes carried DIFFERENT facts.
|
||||
progress: { $ref: '#/components/schemas/Progress' }
|
||||
note_count: { type: integer, minimum: 0 }
|
||||
|
||||
|
|
@ -810,13 +896,29 @@ components:
|
|||
|
||||
BookIntake:
|
||||
type: object
|
||||
description: Add-a-book form.
|
||||
description: |
|
||||
Add-a-book form.
|
||||
|
||||
⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part, and
|
||||
every other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the
|
||||
rule is stated in prose because it cannot be stated in the schema.
|
||||
required: [file, source_lang, target_lang]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
maxLength: 200
|
||||
description: |
|
||||
Title given by hand (0.2.3). OPTIONAL, and the two cases are told apart by the user
|
||||
rather than guessed at: absent or empty means "the parse will name it" — today the
|
||||
platform takes the name of the uploaded file — while a value present means the person
|
||||
named the book themselves, and no later parse overwrites it.
|
||||
|
||||
Bounded like the title the platform derives: the library lists it, and an unbounded
|
||||
string on that screen is the client's problem to draw, not the server's to store.
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: Book file.
|
||||
description: Book file. The LAST part of the form.
|
||||
source_lang: { $ref: '#/components/schemas/LangCode' }
|
||||
target_lang: { $ref: '#/components/schemas/LangCode' }
|
||||
genre: { type: string }
|
||||
|
|
|
|||
|
|
@ -124,6 +124,29 @@
|
|||
> платформы, в границах ПТ-33, на утверждение владельцем по отчёту; (3) Ф-52 — `--color-danger`
|
||||
> осветлить на ступень до ≥3:1 силами S4 (закрыть замером).
|
||||
|
||||
> **Запись фронт-сессии S4, 14.08.** Путь «у меня есть файл → книга переводится» построен и прожит
|
||||
> в мире моков: форма загрузки отправляет книгу (XHR — `fetch` прогресса ТЕЛА запроса не даёт вовсе),
|
||||
> 201 приносит `parsing`, разбор — отдельный видимый шаг БЕЗ выдуманного процента, отказы интейка
|
||||
> (400/404/408/413) и отказ книги (три причины PD-173 с разными советами) — состояния формы и
|
||||
> карточки, а не алерты. Форма запуска даёт потолок в ГЛАВАХ из `CeilingBounds`; потолочный стоп
|
||||
> прожит по контракту (`EventCeiling` + `paused`), и оповещение — не тост, а несъезжающая фраза в
|
||||
> статус-полосе и на карточке (аргумент в записи). Библиотека: дерево свыше 200 разделов
|
||||
> складывается по сотням, книга в дереве стала ВЫБИРАЕМОЙ (без этого половина статусов была
|
||||
> недостижима на карточке), все одиннадцать состояний прожиты. Контракт зонной копии бампнут до
|
||||
> **0.2.3** (PD-172/173/174/180 + `BookIntake.title`); ⚠ по коду платформы `not_configured` на
|
||||
> отклонённую книгу сегодня не попадает вовсе — названо в записи. Девять несущих находок приёмки
|
||||
> S3.7 разобраны, и одна из них ОПРОВЕРГЛА клейм S3.7 замером (resync рвал живой поток, когда
|
||||
> чтение медленное).
|
||||
>
|
||||
> ⚠ **Три ошибки в самом промте S4, найденные вторым проходом ревью и исправленные** (промт писала
|
||||
> нейросеть, и это ровно тот случай): (1) «поле после файла = 400» — платформа так НЕ делает, она
|
||||
> перестаёт читать на файле, и необязательное поле за ним просто теряется (спека и фикстура
|
||||
> исправлены, два теста); (2) §7 велит записать «7 шагов у `npm run check`» — их шесть, и первая
|
||||
> редакция моей правки повторила ошибку промта; (3) §4 сводит 409 на старте к «уехал потолок», а
|
||||
> платформа отвечает им на пять разных состояний — совет на экране сделан причинно-нейтральным.
|
||||
> Ждут владельца: **В-10** (спойлер — защита или вежливость) и **В-11** (фразы отказа PD-173 на
|
||||
> утверждение).
|
||||
|
||||
> **Запись фронт-сессии S3.7, 10.08.** Долги слоя данных закрыты тестами, падающими на до-фиксном
|
||||
> коде (Ф-49…Ф-51), контракт зонной копии бампнут до **0.2.2** (`BankTerm.sense` обязателен),
|
||||
> строки интерфейса уехали в КАТАЛОГ (`src/i18n/`, 147 ключей, механизм — `@internationalized/string`,
|
||||
|
|
@ -167,7 +190,8 @@
|
|||
S3.5 (фикс-пак оболочки: 24 замечания владельца, сценарии интеракций, пере-замер полос),
|
||||
**S3.6 (второй круг: палитра по замеру референсов, модель вкладок fleet_2, банк таблицей,
|
||||
ручка из vojo дословно)**, **S3.7 (долги слоя данных: контракт 0.2.2, каталог i18n 157 ключей,
|
||||
код зоны на английском; D39.129)**.
|
||||
код зоны на английском; D39.129)**, **S4 (загрузка книги и запуск прогона на контракте 0.2.3,
|
||||
библиотека на масштабе, находки приёмки S3.7 — ждёт приёмки)**.
|
||||
- ⚠ **АБЗАЦ НИЖЕ ИСТОРИЧЕСКИЙ (04.08), читать как хронику:** замок с тех пор открыт (контракт ратифицирован D39.99), S3 исполнен и принят D39.115. Оставлен, потому что объясняет, ПОЧЕМУ S3 однажды остановилась, а не как диспозиция.
|
||||
- **S3 НЕ сделана: замок на входе закрыт, проверено исполнением 04.08.** Контракта API v0
|
||||
по-прежнему нет (`docs/architecture/14-api-contract.md` отсутствует; строка 95 единого
|
||||
|
|
@ -390,12 +414,333 @@ NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фрон
|
|||
| В-8 | **Какое слово о состоянии книги стоит в дереве.** Замечание 23 потребовало бейджу ФИКС-место, и оно сделано; но две метки словаря в него не помещаются и усекаются с тултипом — «остановлена: лимиты» (19 знаков) и ветка неизвестного «неизвестное состояние» (21). Укоротить их сессия НЕ вправе: фраза паузы зарезервирована за владельцем (В-6), и первый заход S3.5 её укоротил и был откачен собственным ревью. Варианты: (а) оставить усечение с тултипом, (б) владелец даёт короткое слово для дерева, а полная фраза остаётся в статус-полосе, (в) расширить колонку бейджа за счёт названия книги | продуктовая формулировка, которую видит пользователь |
|
||||
| В-7 | **Физический масштаб интерфейса на мониторе.** Замечание 1 говорит «масштабы великоваты, особенно на 27″ 2K, на 24″ терпимее». Замер при вьюпорте референса расхождения с Fleet не нашёл (§5.5 плана): промежутки, ширины панелей, шаг строки и высота вкладки совпадают. Значит речь про физический размер на конкретном экране, а он зависит от масштабирования ОС, и «просто уменьшить токены» означало бы уехать от референса, который владелец принял. Правильное лекарство — ручка «плотность интерфейса» в настройках (3 ступени), но она требует перевода ВСЕХ размерных токенов в rem и пере-замера, то есть отдельного прохода (Ф-36). Вопрос: нужна ли ручка, и какой масштаб ОС стоит на 27″ 2K — по нему видно, во сколько раз расходится физика | продуктовое решение и цена отдельного пакета |
|
||||
| В-9 | **Какие языки интерфейса продукт обещает.** Механизм переводимости построен (S3.7, Ф-35): слова интерфейса живут в каталоге `src/i18n/ru.ts`, второй язык — второй файл того же вида плюс строка в `src/i18n/language.ts`, ни один компонент при этом не трогается. Чего фронт сделать не может и не должен: решить, какие языки продукт обещает, и выдумать сами переводы. Пока список из одного значения (`ru`), и каркасный пункт настроек честно говорит «сейчас интерфейс только русский» | продуктовое решение и обещание пользователю |
|
||||
| В-10 | **Спойлер банка — это защита или вежливость?** Замер S4: закрытое описание лежит в тексте страницы, `window.find()` его находит, то есть Ctrl+F подсвечивает закрытую ячейку; под `@media print` размытие сохраняется, но текстовый слой PDF строится из DOM. Если спойлер — ЗАЩИТА от спойлера сюжета, описание не должно приезжать в браузер до раскрытия, а это правка контракта (признак «показывать смыслы» на чтении банка). Если ВЕЖЛИВОСТЬ («не мозолит глаза, но достать можно») — сделанного достаточно, и цена названа. Строка Ф-59 | продуктовое решение и правка контракта |
|
||||
| В-11 | **Фразы отказа книги (PD-173) — на утверждение.** Контракт 0.2.3 несёт машинную причину (`source_unreadable` · `not_configured` · `parser_unavailable`), а слова пишет клиент — их сочинила эта сессия по слову владельца 14.08 и они ждут его «да»: «Файл не удалось разобрать на разделы» / «Проверьте файл — возможно, это не текст книги — и добавьте её заново»; «Сервис пока не готов принять эту книгу» / «Файл здесь ни при чём: повторная загрузка поможет не раньше, чем мы это наладим»; «Не удалось обработать файл на нашей стороне» / «Файл здесь ни при чём: попробуйте добавить книгу позже»; неизвестная причина — «Книгу не удалось принять» / «Причина не названа. Попробуйте добавить книгу заново или позже» | продуктовая формулировка, которую видит пользователь |
|
||||
| В-2 | **Авторское право на текст фикстур.** Витрина использует короткую выдержку из настоящего прогона (蛊真人). Правило проекта уже есть: `eval/.gitignore:4` держит копирайтные тексты вне git. Сейчас взят объём под проверку плотности и не больше; нужно ли убрать совсем и заменить синтетикой — решать владельцу | юридика |
|
||||
| В-4 | **Чем подписывать главу в дереве, если заголовок — ровно «Глава N».** Титул движок рендерит детерминистически из шаблона пары (`configs/langpacks/zh-ru/heading.txt`: `template Глава {n}`), а подзаголовок остаётся телом текста и названием НЕ является. На 2284 разделах это дерево из 2284 одинаковых по форме строк. Оставить так, показывать номер иначе или просить у контракта что-то ещё — продуктовое решение. Артефакт §1.4 и К-3 | продуктовая формулировка, которую видит пользователь |
|
||||
| В-5 | **Показывать ли оценку времени до конца прогона.** У движка поле есть (`status.go` `eta_seconds`, «secondary: mean fresh-call throughput × remaining»), запретом §4.8 оно не покрыто (это не деньги), но и не запрошено ни одним разделом промта. Решать ДО формы прогресса, иначе поле придётся вводить задним числом в уже нарисованную полосу. Артефакт К-6, резерв строки 54 единого бэклога | продуктовое: обещание срока пользователю |
|
||||
|
||||
## Хроника
|
||||
|
||||
### 14.08 — девятая фронт-сессия (S4): загрузка книги, запуск прогона, библиотека на масштабе, контракт 0.2.3
|
||||
|
||||
Вход: промт `S4_SESSION_PROMPT.md` (семь задач). Дом прежний — мир моков по контракту; живого стенда
|
||||
платформы не поднимала (§10 промта — опция, не гейт), и это отдельной строкой в «чего НЕ сделала».
|
||||
|
||||
#### 1. Контракт 0.2.3 — пять правок в зонной копии
|
||||
|
||||
`info.version` 0.2.2 → **0.2.3**, `npm run contract` (spectral, порог warn) зелёный, типы
|
||||
перегенерены (`npm run contract:types`), тест дрейфа держит равенство спеки и `schema.ts`.
|
||||
|
||||
| Правка | Что записано | Чем грунтовано |
|
||||
|---|---|---|
|
||||
| **PD-172** | у `createBook` — часть `file` идёт ПОСЛЕДНЕЙ, поле после файла = 400; причина одной строкой (платформа читает форму потоково и обязана увидеть языки до тела) | `platform/internal/httpapi/v0.go:311-378` — потоковый `MultipartReader`, ветка «языки пусты на приходе файла → 400» |
|
||||
| **PD-173** | новая схема `RejectReason` (`source_unreadable` · `not_configured` · `parser_unavailable`) и **необязательное** `Book.reject_reason` | `platform/internal/books/parse.go:51-66` — те же три имени + четвёртое `storage_unavailable`, которое на книге не хранится вовсе |
|
||||
| **PD-174** | названы классы, которые провод уже даёт: **404** на `POST /books` у инстанса без интейка, **503** у `resume` на деплое без маркер-команды/шаблона потолка | 404 — `v0.go:72-77` (маршрут не монтируется при `Intake == nil`); 503 — `reconcile.go:715-722` зовёт `s.runnable()`, а `v0.go:540-547` мапит его ошибки в 503 общей для старта и resume функцией `fail` |
|
||||
| **PD-180** | описание `createBook` честное: **201 несёт `parsing`**, `uploading` наблюдаем только параллельным чтением библиотеки; перечень отказов — 400 (>16 частей, поле >1 КиБ, поле после файла, нет файла), 404, 408, 413 (числа порогов в спеку не зашиты — они принадлежат деплою) | `books.go:110-180`: строка книги пишется ДО тела (оттого `uploading` виден вторым чтением), а ответ формируется после `StartParsing`, то есть уже в `parsing`; пороги — `v0.go:296-302`, 408 — `v0.go:410-417` |
|
||||
| **`BookIntake.title`** | необязательное поле: пусто/нет = «название даст разбор» (сегодня платформа берёт имя файла), заполнено = задано рукой и разбор не перепишет | `books.go:136` `titleFrom(in.Filename)`; ратификация — оркестратор 14.08 (исполнение замечания 12 S3.5) |
|
||||
|
||||
**Расхождение с промтом, названное вслух (код первичен).** Промт описывает enum PD-173 как
|
||||
«словарь платформы из трёх значений». Проверка по коду: значений в словаре платформы **четыре**, а
|
||||
на книгу сегодня попадают **два**. `storage_unavailable` не хранится никогда (комментарий
|
||||
`parse.go:62-65`), `not_configured` в `books.reject_reason` не доходит тоже — `defer_` для него
|
||||
никогда не терминален (`parse.go:180-205`, `waitsForTheDeployment`), книга остаётся в `parsing` и
|
||||
ждёт человека. В спеку внесены все три имени промта: enum — это словарь ВЕРСИИ, клиент обязан
|
||||
терпеть незнакомое значение, а `not_configured` — состояние, которое платформа уже умеет назвать и
|
||||
которое станет терминальным ровно в тот день, когда владелец контракта решит, что упавшая на
|
||||
деплой книга должна умирать, а не ждать. Фраза для него написана, и это цена, которую я назвала:
|
||||
одна из трёх фраз сегодня недостижима на живой платформе.
|
||||
|
||||
**`reject_reason` сделан необязательным, а не required+nullable, как `Run.paused_reason`.** Причина
|
||||
записана в самой спеке: причина отказа осмысленна ровно в одном состоянии из одиннадцати, а деплой
|
||||
старше этого минора не пришлёт её вовсе — значит «поля нет» и «поле `null`» означают ОДНО И ТО ЖЕ
|
||||
(«причина неизвестна») и рисуются одинаково. Это противоположность случая `sense` (0.2.2), где две
|
||||
формы несли РАЗНЫЕ факты; прецедент внутри той же спеки — `Usage.paused_reason`.
|
||||
|
||||
#### 2. Загрузка книги (Ф-26, половина) — механизм и почему он такой
|
||||
|
||||
- **XHR, а не `fetch`, и это не вкус.** Стандарт Fetch отдаёт прогресс ОТВЕТА и не имеет
|
||||
эквивалента для тела запроса; единственный стандартный способ следить за отправкой — тело-поток
|
||||
с `duplex: 'half'`, он только HTTP/2 и только Chromium, то есть на Firefox и Safari загрузка бы
|
||||
просто не работала. `XMLHttpRequest.upload` — совместимый механизм, и он легален ровно в одном
|
||||
месте: сетевой гейт ESLint пускает его только в `src/api/`. Шов не протёк: экран получает промис
|
||||
и поток чисел (`src/api/upload.ts`).
|
||||
- **Порядок частей формы — правило провода**, а не привычка клиента: `title · source_lang ·
|
||||
target_lang · genre · file`. Держится с двух сторон — тест сверяет ключи формы, которые ушли на
|
||||
провод, а фикстура ОТКАЗЫВАЕТ форме, где файл не последний (та же проверка, что у платформы).
|
||||
- **Три состояния отправки**, и первое из них — измеренная неожиданность. В браузере мок-сеть это
|
||||
service worker, а Chromium **не сообщает прогресс отправки для запроса, который отвечает
|
||||
воркер**: проба напечатала `loadstart` с верным total (`0 / 300379`) и НИ ОДНОГО `progress`
|
||||
после. Поэтому экран не рисует долю, которой нет: «Файл отправляется: 300 кБ» без процента.
|
||||
Доля и вторая фаза («файл ушёл целиком, ждём ответа») живут для настоящей платформы и доказаны
|
||||
тестом `src/api/upload.test.ts` — там мок перехватывает сам `XMLHttpRequest` и события шлёт.
|
||||
- **Подтверждение видимое** (§3.2): после 201 окно показывает карточку из полей КОНТРАКТА —
|
||||
название, языки, знаки, состояние («разбор»), — и `chapter_count` в ней намеренно нет: до конца
|
||||
разбора он ноль, и печатать его значило бы ответить на «сколько разделов» неправдой.
|
||||
- **Отказы — состояния формы, не алерты**: фраза берётся из `problem+json` платформы (контракт
|
||||
кладёт слова на сервер), а СОВЕТ пишет клиент, потому что провод его не несёт и он разный:
|
||||
«проверьте файл» ≠ «повторите позже» ≠ «этот сервис книги не принимает».
|
||||
|
||||
#### 3. Запуск прогона (остаток строки 126)
|
||||
|
||||
Форма читает `GET /books/{id}/run-options` при ОТКРЫТИИ (потолок принадлежит аккаунту и уезжает,
|
||||
пока книга стоит), шкала строится из `CeilingBounds` в ГЛАВАХ, предустановка — `default_chapters`
|
||||
платформы. Клиент не клампит второй раз. `max_chapters: 0` — не пустая шкала, а честное
|
||||
исчерпанное состояние. `409` на старте перечитывает bounds, и выбранное значение возвращается к
|
||||
новой предустановке ВО ВРЕМЯ РЕНДЕРА, а не размонтированием формы: размонтировав, я бы убрала с
|
||||
экрана ту самую фразу отказа, ради которой всё и затевалось. Галочка «остановиться на подписи
|
||||
банка» — параметр ЭТОГО запуска. ⚠ **По умолчанию ВЫКЛЮЧЕНА, и это решение про сегодня, а не про
|
||||
продукт** (нашло адверсариальное ревью): с включённой галочкой прогон доходит до `awaiting_bank` — и
|
||||
дальше в этой сборке идти НЕКУДА: экран подписи это S5, у чтения банка вообще нет канала (Ф-43), а
|
||||
`resume` отвечает 409, пока набор решений неполон. Предустановить её значило бы предустановить
|
||||
тупик. Сама галочка остаётся — выбор принадлежит человеку и этому прогону (владелец, 02.08), — а
|
||||
дефолт вернётся в тот день, когда S5 даст остановке куда идти.
|
||||
|
||||
**`paused` прожит, и оповещение — НЕ тост.** Решение и аргумент: тост — временный носитель
|
||||
постоянного факта, он исчезает, а состояние остаётся; на десктопном однооконном экране статус-полоса
|
||||
и карточка книги видны всегда; §3.8 запрещает экрану выглядеть тревожным, а всплывающее окно — самая
|
||||
тревожная из форм. Поэтому остановка называется там, где стоит действие: фраза владельца в
|
||||
статус-полосе И выноской на карточке книги, и обе не исчезают. Кадр `EventCeiling` + `status:
|
||||
paused` в мире моков прожит целиком (сцена `intake`). `resume` как лекарство не предлагается нигде;
|
||||
путь вперёд — НОВЫЙ прогон со своим потолком, и он на карточке есть.
|
||||
|
||||
#### 4. Библиотека на масштабе
|
||||
|
||||
- **Группировка по сотням** (хвост Ф-12): дерево свыше 200 разделов складывается в группы по 100
|
||||
(«разделы 1–100»), 2284 раздела дают 23 строки. Аргумент — не кадр в секунду (плоское дерево
|
||||
виртуализовано и стоит 31 строку в DOM), а полоса прокрутки: 59 618px хода и треть пикселя на
|
||||
строку. Группа с выбранным разделом раскрывается сама (переход из палитры не должен терять
|
||||
строку) и остаётся закрываемой рукой — раскрытие стало УПРАВЛЯЕМЫМ (`ui/Tree.tsx`).
|
||||
- **Выбор книги в дереве построен** (§2 промта зоны — «клик по книге открывает её состояние»). До
|
||||
S4 карточка всегда показывала `books[0]`, и половина статусов была недостижима на экране в
|
||||
принципе. Побочный эффект найден axe в первой же сцене: строка книги теперь БЫВАЕТ выбранной, и
|
||||
правило `[data-selected] .badge`, снятое в S3.7 как мёртвое, ожило — бейдж на синем полотне давал
|
||||
3.52:1 при пороге 4.5, а тон-глиф книги 1.7:1 при пороге 3. Оба перекрашены, сторож контраста
|
||||
выбранной строки теперь гоняется и на строке КНИГИ.
|
||||
- **Все одиннадцать `BookStatus` прожиты**: в дереве — словом (фикстура держит книгу каждого
|
||||
состояния), на карточке — те три, у которых есть что сказать сверх слова (`uploading`, `parsing`,
|
||||
`rejected` с причиной и советом, `paused` с фразой владельца); проверено сценой `context`.
|
||||
|
||||
#### 5. Дыра, которую нашла постройка: конца интейка никто не объявляет
|
||||
|
||||
Живой канал принадлежит ПРОГОНУ, а книга в `uploading`/`parsing` прогона не имеет — значит «разбор
|
||||
кончился» не приходит ничем. Без лечения книга висела бы в «разборе» до случайной
|
||||
инвалидации. Сделано: опрос ровно на время интейка (`intakePollMs = 3000`, останавливается вместе
|
||||
со статусом) плюс перечитывание ДЕРЕВА на выходе из интейка (`useIntakeEnd.ts`) — иначе список
|
||||
разделов, прочитанный когда разделов не было, так и оставался пустым (поймано сценой, не
|
||||
рассуждением). Это лечение, а не решение: правильным был бы кадр от контракта — **Ф-56**.
|
||||
|
||||
⚠ Названная цена опроса на фикстуре: в мире `showcase` книга `bk_11` стоит в `uploading` вечно (она
|
||||
там ради того, чтобы состояние было видно на экране), поэтому библиотека на этом маршруте
|
||||
опрашивается каждые три секунды бесконечно. На живой платформе так не бывает — брошенную загрузку
|
||||
подметает свип через час (`books.UploadGrace`), — но фикстура этого не моделирует, и знать об этом
|
||||
надо тому, кто будет мерить на `/showcase` что-нибудь чувствительное к сети.
|
||||
|
||||
#### 6. Диспозиции находок приёмки S3.7 — все девять несущих
|
||||
|
||||
| № | Диспозиция |
|
||||
|---|---|
|
||||
| 1 (high) дубль подстановки строк | **починено.** `scripts/scenes.mjs` больше не держит своего филлера: `say()` зовёт тот же `src/i18n/fill.ts`, что и приложение (харнесс уже умеет грузить `.ts`). Семантика пропуска стала одна |
|
||||
| 2 отметка «перечитывание в пути» ставится без старта чтения | **починено, и дефект оказался хуже описанного.** Отметка снимается ровно тогда, когда кончается чтение, за которое она стоит (`resetQueries().finally`). Настоящая цена прежней версии: чтение, которое ПРОВАЛИЛОСЬ, оставляло запись пустой — и ключ замолкал до конца прогона. Тест `a key whose re-read failed is not silenced for the rest of the run` падает на до-фиксном коде |
|
||||
| 3 `onResync` не чистит `asked` | **починено:** отметки уходят вместе со снимками. Тест написан так, чтобы ловить дефект В ОКНЕ (чтение задержано на 120 мс, кадр приходит, пока перечитывание в полёте) — с быстрым моком он зеленел бы и на дефекте |
|
||||
| 4 клейм «resync не рвёт живой поток» | **клейм ОПРОВЕРГНУТ замером — S3.7 была неправа.** С задержанным ответом (120 мс) поток открывался ДВАЖДЫ: id прогона лежит внутри карточки книги, а reset её опустошает, и подписка снималась на время одного чтения. Починено удержанием id на время книги (`useRunStream`), тест инструментирован задержкой и падает на до-фиксном коде |
|
||||
| 5 сцена `hidden`: прекондиция не ассертится, сторож дифференциальный | **починено тремя проверками вместо одной:** прекондиция «банк ещё не пришёл» ассертится, «таблица вообще нарисована» (4 столбца, 608px) проверяется, «столбцы по содержимому, а не равными долями» — тоже; дифференциальное сравнение осталось четвёртым |
|
||||
| 6 дырка ESLint-гейта строк | **починено, и шире находки.** Гейт знает НОСИТЕЛИ: `??`, тернарник и шаблон — и в читаемом атрибуте, и как ребёнок элемента (`<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 — права сессия
|
||||
|
||||
Гейты пере-прогнаны исполнением: `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
|
||||
// `style` property itself is forbidden in object literals — the carrier is then caught where it
|
||||
// is assembled.
|
||||
{ selector: "Property[key.name='style']", message: inlineStyle },
|
||||
{ selector: "Property[key.value='style']", message: inlineStyle },
|
||||
//
|
||||
// ⚠ Except when its value is a plain string, and that is a narrowing with a reason rather than an
|
||||
// exemption: React refuses a string `style` outright ("expects a mapping … not a string"), so
|
||||
// such a property cannot carry styling anywhere — while `{ style: 'unit' }` is how `Intl`
|
||||
// formats a size in megabytes, and the words of a unit belong to `Intl` and not to us. Every
|
||||
// carrier that CAN style — an object, a variable, a template — is still caught.
|
||||
{ selector: "Property[key.name='style'][value.type!='Literal']", message: inlineStyle },
|
||||
{ selector: "Property[key.value='style'][value.type!='Literal']", message: inlineStyle },
|
||||
// Named colours in the markup: forbidden in CSS, they used to pass in TSX — the asymmetry of Ф-9.
|
||||
{
|
||||
selector: `JSXAttribute[name.name=${colorAttributes}] > Literal[value!=${allowedColorValue}]`,
|
||||
|
|
@ -157,7 +163,26 @@ const letter = String.raw`/[A-Za-z\u0400-\u04FF]/`;
|
|||
// Includes the props our own primitives take text in (`label`, `empty`, `waiting`, `description`,
|
||||
// `hint`, `caption`) — a word handed to a component is as printed as a word between its tags.
|
||||
const readableAttributes =
|
||||
'/^(title|placeholder|alt|label|empty|waiting|description|hint|caption|aria-label|aria-description)$/';
|
||||
'/^(title|placeholder|alt|label|empty|waiting|description|hint|caption|valueLabel|aria-label|aria-description|aria-valuetext)$/';
|
||||
|
||||
// The shapes a written-out word takes inside `{…}`. A bare literal was the only one the gate knew,
|
||||
// and the hole it left was found by the acceptance of S3.7 with a live specimen (`Showcase.tsx`:
|
||||
// `title={book?.title ?? 'TextMachine'}`): a word behind `??` or behind a ternary is as printed as
|
||||
// one standing on its own.
|
||||
//
|
||||
// ⚠ Carriers are listed rather than caught by descent (`JSXExpressionContainer Literal`), and that
|
||||
// is not timidity: `{text('bank.tab')}` is a literal inside an expression container too, and a
|
||||
// descending selector would flag every call of the catalogue — the very mechanism this rule exists
|
||||
// to enforce. Nesting deeper than one carrier goes through a named variable, where a computed
|
||||
// value is a deliberate step rather than an inline one (the same rule the colour gate applies).
|
||||
const writtenWord = [
|
||||
`Literal[value=${letter}]`,
|
||||
`TemplateLiteral > TemplateElement[value.raw=${letter}]`,
|
||||
...['ConditionalExpression', 'LogicalExpression'].flatMap((carrier) => [
|
||||
`${carrier} > Literal[value=${letter}]`,
|
||||
`${carrier} > TemplateLiteral > TemplateElement[value.raw=${letter}]`,
|
||||
]),
|
||||
];
|
||||
|
||||
const noBareText = [
|
||||
{ selector: `JSXText[value=${letter}]`, message: catalogueOnly },
|
||||
|
|
@ -165,14 +190,16 @@ const noBareText = [
|
|||
selector: `JSXAttribute[name.name=${readableAttributes}] > Literal[value=${letter}]`,
|
||||
message: catalogueOnly,
|
||||
},
|
||||
{
|
||||
selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > Literal[value=${letter}]`,
|
||||
message: catalogueOnly,
|
||||
},
|
||||
{
|
||||
selector: `JSXAttribute[name.name=${readableAttributes}] > JSXExpressionContainer > TemplateLiteral > TemplateElement[value.raw=${letter}]`,
|
||||
message: catalogueOnly,
|
||||
},
|
||||
// Inside a readable attribute, and — the second half of the same hole — as the CHILD of an
|
||||
// element: `<p>{'a word'}</p>` prints one exactly as `<p>a word</p>` does, and only the second was
|
||||
// caught (by the `JSXText` rule).
|
||||
...['JSXAttribute[name.name=' + readableAttributes + ']', 'JSXElement', 'JSXFragment'].flatMap(
|
||||
(host) =>
|
||||
writtenWord.map((shape) => ({
|
||||
selector: `${host} > JSXExpressionContainer > ${shape}`,
|
||||
message: catalogueOnly,
|
||||
})),
|
||||
),
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
|
|
|
|||
|
|
@ -41,13 +41,36 @@ const load = (/** @type {string} */ path) => import(new URL(path, import.meta.ur
|
|||
|
||||
/** @type {Record<MessageKey, string>} */
|
||||
const ru = (await load('../src/i18n/ru.ts')).ru;
|
||||
/** @type {{ book: Schemas['Book'], chapters: Schemas['Chapter'][], notes: Schemas['Note'][] }} */
|
||||
const { book, chapters, notes } = await load('../src/mock/book.ts');
|
||||
/** @type {(line: string, variables: Record<string, string | number> | undefined) => string} */
|
||||
const fill = (await load('../src/i18n/fill.ts')).fill;
|
||||
/**
|
||||
* @type {{
|
||||
* book: Schemas['Book'],
|
||||
* books: Schemas['Book'][],
|
||||
* chapters: Schemas['Chapter'][],
|
||||
* notes: Schemas['Note'][],
|
||||
* }}
|
||||
*/
|
||||
const { book, books, chapters, notes } = await load('../src/mock/book.ts');
|
||||
|
||||
/**
|
||||
* The book of a given status, by the fixture's own hand: a title written out here would go stale in
|
||||
* silence the moment the fixture is edited.
|
||||
* @param {Schemas['BookStatus']} status
|
||||
*/
|
||||
function bookOf(status) {
|
||||
const found = books.find((row) => row.status === status);
|
||||
if (!found) throw new Error(`the fixture has no book in status ${status}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const uploadingBook = bookOf('uploading');
|
||||
const rejectedBook = bookOf('rejected');
|
||||
const pausedBook = bookOf('paused');
|
||||
/** @type {{ terms: Schemas['BankTerm'][], bankTerms: Schemas['BankTerm'][] }} */
|
||||
const { terms, bankTerms } = await load('../src/mock/bank.ts');
|
||||
/** @type {{ scaleTerms: Schemas['BankTerm'][] }} */
|
||||
const { scaleTerms } = await load('../src/mock/scale.ts');
|
||||
|
||||
/** @typedef {import('playwright').Page} Page */
|
||||
/** @typedef {import('playwright').Locator} Locator */
|
||||
/** @typedef {(name: string) => Promise<unknown>} Shot */
|
||||
|
|
@ -56,12 +79,17 @@ const { scaleTerms } = await load('../src/mock/scale.ts');
|
|||
* Interface wording by catalogue key — a scene looks for exactly what the screen prints. An unknown
|
||||
* key throws: a locator built out of `undefined` matches ANY node of its role, and the scene would
|
||||
* go green having checked nothing.
|
||||
*
|
||||
* ⚠ The FILLING is the application's own (`src/i18n/fill.ts`), not a copy of it. S3.7 wrote a
|
||||
* second one right here, with a different rule for a place left without a variable, and its own
|
||||
* report claimed the module was shared — the acceptance found the claim false. A scene has to
|
||||
* build the phrase the screen prints, which means by the same code.
|
||||
* @param {MessageKey} key @param {Record<string, string | number>} [variables]
|
||||
*/
|
||||
function say(key, variables = {}) {
|
||||
const line = ru[key];
|
||||
if (line === undefined) throw new Error(`no such key in the message catalogue: ${key}`);
|
||||
return line.replace(/\{(\w+)\}/g, (whole, name) => String(variables[name] ?? whole));
|
||||
return fill(line, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,6 +138,10 @@ async function audit(page, what) {
|
|||
const blocking = axe.violations.filter((v) => v.id !== 'color-contrast');
|
||||
for (const v of axe.violations.filter((v) => v.id === 'color-contrast')) {
|
||||
console.log(` contrast (accepted as is): ${v.nodes.length} node(s)`);
|
||||
// The NODE, not only the count: a bare number is a thing nobody can act on, and the same
|
||||
// omission cost the acceptance of S3.6 a hunt by hand.
|
||||
for (const node of v.nodes)
|
||||
console.log(` ${node.target.join(' ')} — ${node.html.slice(0, 120)}`);
|
||||
}
|
||||
if (blocking.length > 0) {
|
||||
for (const v of blocking) {
|
||||
|
|
@ -266,6 +298,30 @@ const scenes = {
|
|||
/** @param {Page} page @param {Shot} shot */
|
||||
async scroll(page, shot) {
|
||||
await open(page, '/scale');
|
||||
|
||||
// The long tail folds by hundreds (Ф-12): 2284 sections stand as 23 folds, and a click on a fold
|
||||
// is a fold — not a row that answers with nothing. Checked here because this scene already opens
|
||||
// the book that has the tail. The FIRST fold is the one in the DOM: the tree is virtualized, so
|
||||
// a fold a thousand rows down is not drawn at all.
|
||||
const fold = page
|
||||
.locator('nav [role="row"]')
|
||||
.filter({ hasText: say('library.chapterRange', { from: 1, to: 100 }) });
|
||||
check((await fold.count()) === 1, 'the tree of 2284 sections is folded by hundreds');
|
||||
const opened = await page.locator('nav [role="row"]').count();
|
||||
await fold.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
const closed = await page.locator('nav [role="row"]').count();
|
||||
check(
|
||||
closed < opened,
|
||||
`a click on a fold closes it (${String(opened)} → ${String(closed)} rows in the DOM)`,
|
||||
);
|
||||
await fold.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
check(
|
||||
(await page.locator('nav [role="row"]').count()) === opened,
|
||||
'and a second click opens it again',
|
||||
);
|
||||
|
||||
const bank = page.locator(`[role="grid"][aria-label="${say('bank.tableLabel')}"]`);
|
||||
await bank.evaluate((node) => node.scrollTo(0, 9000));
|
||||
const before = await bank.evaluate((node) => node.scrollTop);
|
||||
|
|
@ -343,6 +399,41 @@ const scenes = {
|
|||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||||
'a row of the summary opens its chapter as a preview tab',
|
||||
);
|
||||
|
||||
// S4: a click on a BOOK opens its state (the zone's §2), and the states that have something to
|
||||
// say beyond a word say it on the card. The fixture holds every one of the eleven; these three
|
||||
// are the ones with a sentence attached, and each is reached by choosing its book.
|
||||
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||||
for (const { title, phrase, what } of [
|
||||
{ title: uploadingBook.title, phrase: say('about.uploading'), what: 'a book still arriving' },
|
||||
{
|
||||
title: rejectedBook.title,
|
||||
phrase: say('rejected.sourceUnreadable'),
|
||||
what: 'a book that could not be parsed',
|
||||
},
|
||||
{
|
||||
title: pausedBook.title,
|
||||
phrase: say('paused.creditExhausted'),
|
||||
what: 'a run halted on its ceiling',
|
||||
},
|
||||
]) {
|
||||
await page.locator('nav').getByText(title, { exact: true }).click();
|
||||
// The wait is the assertion's own timeout, and its verdict is what `check` prints: a bare
|
||||
// `check(true, …)` after a wait reads as a tautology to whoever comes next.
|
||||
const said = await page
|
||||
.waitForFunction(
|
||||
(want) => document.querySelector('aside')?.textContent?.includes(want) === true,
|
||||
phrase,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check(said, `${what}: the card says so in words, not by colour alone («${phrase}»)`);
|
||||
}
|
||||
await shot('context-book-state');
|
||||
await audit(page, 'the card of a book in an intake state');
|
||||
},
|
||||
|
||||
// Second-round remark 7: the bank is a TABLE in the right panel (not a tab in the centre), with
|
||||
|
|
@ -679,6 +770,13 @@ const scenes = {
|
|||
const panel = page.locator('aside');
|
||||
// We leave the bank BEFORE it has answered: the tabs are drawn at once, the data are not.
|
||||
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||||
// ⚠ The PRECONDITION is asserted and not hoped for (acceptance of S3.7, finding 5): if the bank
|
||||
// had already arrived by the time the tab was left, this scene would be measuring an ordinary
|
||||
// table and going green about nothing.
|
||||
check(
|
||||
(await panel.getByRole('grid', { name: say('bank.tableLabel') }).count()) === 0,
|
||||
'the bank had not arrived by the time its tab was left — which is what this scene is about',
|
||||
);
|
||||
await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
|
@ -699,6 +797,19 @@ const scenes = {
|
|||
const neverHidden = await columnSpread(
|
||||
page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') }),
|
||||
);
|
||||
// ⚠ Three checks and not one, because the differential alone is a guard a SYMMETRIC regression
|
||||
// walks straight through (acceptance of S3.7, finding 5): two tables that both fell back to
|
||||
// equal shares — or both drew nothing at all — measure the same as each other.
|
||||
check(
|
||||
hiddenThenShown.widths.length === 4 && hiddenThenShown.sum > 0,
|
||||
`the table is drawn at all: ${String(hiddenThenShown.widths.length)} columns, ` +
|
||||
`${String(hiddenThenShown.sum)}px`,
|
||||
);
|
||||
check(
|
||||
new Set(hiddenThenShown.widths.slice(0, 3)).size > 1,
|
||||
`and its columns are sized by their CONTENT rather than by equal shares ` +
|
||||
`(${hiddenThenShown.widths.join('·')})`,
|
||||
);
|
||||
check(
|
||||
hiddenThenShown.widths.join('·') === neverHidden.widths.join('·'),
|
||||
`a table mounted in a hidden tab measures the same as one that was never left ` +
|
||||
|
|
@ -769,11 +880,22 @@ const scenes = {
|
|||
// Not "each is wider than zero" — the library cannot give a panel a negative width, so that
|
||||
// form would hold on any layout at all. What is asked is that the three still stand side by
|
||||
// side inside the window, in order and without lying on top of one another.
|
||||
// ⚠ STRICTLY to the right, not "no further left": equal lefts are exactly what a stack has,
|
||||
// and the check passed on one (acceptance of S3.7, finding 8).
|
||||
//
|
||||
// The stronger form — each panel begins where the previous one ENDS — is deliberately NOT a
|
||||
// gate: from 125% the centre already runs under the right panel by the number printed below,
|
||||
// and that is Ф-54, an open question of interface density that belongs to the owner (В-7).
|
||||
// A gate red by construction teaches people to skip the command, so the overlap is printed
|
||||
// and the order is asserted.
|
||||
check(
|
||||
layout.edges.length === 3 &&
|
||||
layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) &&
|
||||
layout.edges.every(({ left }, index) => left >= (layout.edges[index - 1]?.left ?? 0)),
|
||||
`${percent}%: the three panels stand side by side inside the window (${layout.panels.join(' · ')})`,
|
||||
layout.edges.every(
|
||||
({ left }, index) => index === 0 || left > (layout.edges[index - 1]?.left ?? 0),
|
||||
),
|
||||
`${percent}%: the three panels stand side by side inside the window ` +
|
||||
`(${layout.edges.map(({ left, right }) => `${left}‥${right}`).join(' · ')})`,
|
||||
);
|
||||
// The centre keeps a floor of its own and its CONTENT is cut by it long before the window is:
|
||||
// printed rather than asserted, because the floor is the owner's question of interface density
|
||||
|
|
@ -869,7 +991,6 @@ const scenes = {
|
|||
// (useDeferredValue), so that typing does not wait for the collection.
|
||||
const search = page.getByRole('searchbox', { name: say('bank.search') });
|
||||
await search.click();
|
||||
const before = await table.getByRole('row').count();
|
||||
const rowsSelector = '[role="grid"] [role="row"]';
|
||||
// The query MUST narrow the result SHARPLY: a single syllable is in almost every term of this
|
||||
// fixture, the number of rows in the DOM would not change — the measurement would be timing the
|
||||
|
|
@ -887,7 +1008,13 @@ const scenes = {
|
|||
let settled = 0;
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await search.fill('');
|
||||
await page.waitForTimeout(300);
|
||||
// ⚠ The round starts from a table that has SETTLED, and the number it settled at is measured
|
||||
// here rather than inherited: the count of rows in the DOM belongs to the virtualizer and
|
||||
// depends on where the list stands, so the one taken before the scrolling above is not the
|
||||
// one this round narrows from. With a fixed pause instead, the rows could still be missing
|
||||
// when the stopwatch started — "fewer rows than before" would be true on arrival and the
|
||||
// settling would be measured as nothing (acceptance of S3.7, finding 7).
|
||||
const full = await rowsSettled(page, rowsSelector);
|
||||
await search.click();
|
||||
const typed = Date.now();
|
||||
await page.keyboard.type(query);
|
||||
|
|
@ -901,7 +1028,7 @@ const scenes = {
|
|||
echoes.push(Math.round((Date.now() - typed) / query.length));
|
||||
await page.waitForFunction(
|
||||
({ was, selector }) => document.querySelectorAll(selector).length < was,
|
||||
{ was: before, selector: rowsSelector },
|
||||
{ was: full, selector: rowsSelector },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
settled = Date.now() - typed;
|
||||
|
|
@ -933,6 +1060,341 @@ const scenes = {
|
|||
check(drag.janky <= 3, `long frames over the drag ${drag.janky} (threshold 3)`);
|
||||
},
|
||||
|
||||
// S4: the path the product begins with — a file becomes a book, the book is parsed, the book is
|
||||
// translated. Everything here runs against the mock network by the form of the contract; the
|
||||
// fixture's own rules (the file last, the caps) are the platform's, so a client that broke one
|
||||
// would fail here rather than in production.
|
||||
/** @param {Page} page @param {Shot} shot */
|
||||
async intake(page, shot) {
|
||||
await open(page, '/intake');
|
||||
// The precondition is ASSERTED and not assumed: this world starts empty, and every count below
|
||||
// is a count of what the form put there.
|
||||
check(
|
||||
(await page.locator('nav [role="row"]').count()) === 0,
|
||||
'the intake world starts with an empty library',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||||
const modal = page.getByRole('dialog');
|
||||
await shot('intake-form');
|
||||
await audit(page, 'the add-a-book form');
|
||||
|
||||
// The pair of languages is a real choice with a real keyboard: the whole path has to be
|
||||
// passable without a mouse.
|
||||
const source = modal.getByLabel(say('addBook.sourceLangLabel'));
|
||||
const before = (await source.innerText()).trim();
|
||||
await source.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
const after = (await source.innerText()).trim();
|
||||
// ⚠ A CHANGE, not "there is a value": the control shows one before a key is ever pressed, so
|
||||
// the first version of this check was true of a keyboard that did nothing (found by the
|
||||
// adversarial review).
|
||||
check(
|
||||
after !== before && after !== '',
|
||||
`the language of the original is chosen from the keyboard (${before} → ${after})`,
|
||||
);
|
||||
|
||||
await modal.locator('input[type="file"]').setInputFiles({
|
||||
name: 'gu-zhen-ren.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.alloc(300_000, 'a'),
|
||||
});
|
||||
await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).fill(book.title);
|
||||
check(
|
||||
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
|
||||
'a title entered by hand differs from the automatic parse',
|
||||
);
|
||||
|
||||
await modal.getByRole('button', { name: say('addBook.submit') }).click();
|
||||
// The sending is a STATE and not a flash. ⚠ WHAT IS CHECKED HERE is what the browser world can
|
||||
// show: under the mock network the request is answered by a service worker, and Chromium reports
|
||||
// no upload progress for such a request at all (probe: `loadstart` with the right total, then
|
||||
// nothing). So the screen honestly says the size going out and draws NO share — the determinate
|
||||
// bar and the "all sent" line belong to the real platform and are proved by src/api/upload.test.ts.
|
||||
// The line is found by the part of the catalogue string that stands BEFORE the place: the size
|
||||
// itself is formatted by `Intl` for the interface's locale, and a scene that spelled it out
|
||||
// would be a second copy of that formatting.
|
||||
const sending = modal.getByText(say('addBook.sendingUnknown').split('{')[0]?.trim() ?? '', {
|
||||
exact: false,
|
||||
});
|
||||
await sending.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await shot('intake-sending');
|
||||
const said = await sending.innerText();
|
||||
check(/\d/.test(said), `the form says how much is going out («${said.trim()}»)`);
|
||||
check(
|
||||
(await modal.getByRole('progressbar').count()) === 0,
|
||||
'and draws no share while the browser reports none — a bar at zero would be a number that means nothing',
|
||||
);
|
||||
|
||||
// 201 carries `parsing` — the confirmation shows what came back, from the contract's own
|
||||
// fields and not from what the form typed in.
|
||||
await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 });
|
||||
await shot('intake-accepted');
|
||||
const confirmation = await modal.innerText();
|
||||
check(
|
||||
confirmation.includes(book.title) && confirmation.includes(say('status.parsing')),
|
||||
'the confirmation names the book and says it is being parsed',
|
||||
);
|
||||
await audit(page, 'the confirmation of the upload');
|
||||
|
||||
await modal.getByRole('button', { name: say('action.done') }).click();
|
||||
// Parsing is a visible step of its own, with no number on it: the contract has no counter for
|
||||
// it, so the screen says the state and invents no percentage.
|
||||
const badge = page.locator('nav [role="row"]').first();
|
||||
check(
|
||||
(await badge.innerText()).includes(say('status.parsing')),
|
||||
'the book stands in the library while it is being parsed',
|
||||
);
|
||||
check(
|
||||
!/\d+\s*%/.test(await badge.innerText()),
|
||||
'and no percentage of the parsing is invented anywhere on that row',
|
||||
);
|
||||
await shot('intake-parsing');
|
||||
|
||||
// Nothing PUSHES the end of the parse — a book being parsed has no run and therefore no
|
||||
// stream — so this waits for the poll to bring it.
|
||||
await page.waitForFunction(
|
||||
(want) => document.querySelector('nav [role="row"]')?.textContent?.includes(want) === true,
|
||||
say('status.notStarted'),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
// Waited for and not counted on the spot: the tree is read AGAIN when the poll finds the parse
|
||||
// over, and the answer to that read is a round trip behind the status.
|
||||
const grown = await page
|
||||
.waitForFunction(() => document.querySelectorAll('nav [role="row"]').length > 1, null, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const chapters = await page.locator('nav [role="row"]').count();
|
||||
check(grown, `the sections of the parsed book are in the tree (${String(chapters)} rows)`);
|
||||
await shot('intake-parsed');
|
||||
|
||||
// The run: the scale is in CHAPTERS, and there is no money on the screen in any form (§4.8).
|
||||
const panel = page.locator('aside');
|
||||
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||||
await panel.getByRole('button', { name: say('run.action') }).click();
|
||||
const scale = modal.getByRole('slider');
|
||||
await scale.waitFor({ timeout: 5000 });
|
||||
await shot('intake-run-form');
|
||||
await audit(page, 'the run form');
|
||||
// The scale is a native range under the hood (that is what gives it the keyboard and the
|
||||
// screen reader for free), so its bounds live on the input and not in aria-* attributes.
|
||||
const boundsOf = () =>
|
||||
scale.evaluate((node) =>
|
||||
node instanceof HTMLInputElement
|
||||
? { now: node.value, min: node.min, max: node.max }
|
||||
: {
|
||||
now: node.getAttribute('aria-valuenow'),
|
||||
min: node.getAttribute('aria-valuemin'),
|
||||
max: node.getAttribute('aria-valuemax'),
|
||||
},
|
||||
);
|
||||
const bounds = await boundsOf();
|
||||
check(
|
||||
bounds.now === '4' && bounds.min === '1' && bounds.max === '8',
|
||||
`the scale is built from the platform's bounds, preset included (${JSON.stringify(bounds)})`,
|
||||
);
|
||||
check(
|
||||
!/[$₽€]|\d+[.,]\d{2}\b/.test(await modal.innerText()),
|
||||
'not a sum, not a currency and not a price anywhere on the run form',
|
||||
);
|
||||
// The scale is a real control for the keyboard, not a decoration for the mouse.
|
||||
await scale.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
check((await boundsOf()).now === '5', 'the ceiling moves from the keyboard');
|
||||
|
||||
// ⚠ THE FIRST PRESS MEETS A 409, and the fixture produces it the way the platform does rather
|
||||
// than by a marker: the bounds moved between the read and the call (the contract's own case — a
|
||||
// hold taken for another book lowers what is left). This is the one refusal a correct client
|
||||
// can meet on a correct form, and §11 of the assignment asks for every branch to be SHOWN.
|
||||
await modal.getByRole('button', { name: say('run.action') }).click();
|
||||
const refused = modal.getByText(say('run.conflictAdvice'), { exact: false });
|
||||
await refused.waitFor({ timeout: 10_000 });
|
||||
await shot('intake-run-conflict');
|
||||
check(
|
||||
await refused.isVisible(),
|
||||
'a ceiling that no longer fits is refused, and the form says so',
|
||||
);
|
||||
// The same shape as the intake refusals: the client's OWN phrase for this class must be absent,
|
||||
// which is what says the sentence on the screen came from the platform.
|
||||
check(
|
||||
!(await modal.innerText()).includes(say('run.conflict')),
|
||||
"and the phrase is the platform's own, not the client's fallback",
|
||||
);
|
||||
// The scale was re-read, so the value now standing on it is the platform's NEW preset — not the
|
||||
// one the person picked against bounds that no longer exist.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(document.querySelector('[role="dialog"] input[type="range"]')?.getAttribute('max') ??
|
||||
'') === '2',
|
||||
null,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check((await boundsOf()).now === '2', 'and the scale comes back with the bounds that are left');
|
||||
|
||||
await modal.getByRole('button', { name: say('run.action') }).click();
|
||||
// The run goes, and it ends where the contract says a ceiling stop ends: `paused`, with a
|
||||
// machine reason, never `failed`. The phrase is the owner's (В-6) and the client only picks it.
|
||||
await page.waitForFunction(
|
||||
(want) => document.querySelector('footer')?.textContent?.includes(want) === true,
|
||||
say('paused.creditExhausted'),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await shot('intake-paused');
|
||||
// The halt is a LIVED state and not a toast: the strip carries it, the card carries it, and both
|
||||
// are still there a minute later — which a message that fades would not be.
|
||||
check(
|
||||
(await page.locator('footer').innerText()).includes(say('paused.creditExhausted')),
|
||||
'the halt on the ceiling is named in the status strip, in the owner’s own words',
|
||||
);
|
||||
check(
|
||||
(await panel.innerText()).includes(say('paused.creditExhausted')),
|
||||
'and on the card of the book, where the action for it would be',
|
||||
);
|
||||
// ⚠ What the card offers is a NEW run — which carries a new ceiling — and never "continue":
|
||||
// after a ceiling stop `resume` does not move the run at all (contract §resumeRun).
|
||||
check(
|
||||
(await panel.innerText()).includes(say('run.action')),
|
||||
'the way on from a halt is a new run with a ceiling of its own, and it is offered',
|
||||
);
|
||||
},
|
||||
|
||||
// The other half of the same path: everything that can go wrong with an intake, each as a state
|
||||
// of the form rather than an alert over it (§3.8). The class is asked for by a marker in the name
|
||||
// of the file — the fixture's own switch (src/mock/intake.ts), because a correct client cannot
|
||||
// produce these answers and the screens for them would otherwise be checked by nothing.
|
||||
/** @param {Page} page @param {Shot} shot */
|
||||
async refusals(page, shot) {
|
||||
await open(page, '/intake');
|
||||
const modal = page.getByRole('dialog');
|
||||
|
||||
/** @param {string} name @param {number} size */
|
||||
const send = async (name, size) => {
|
||||
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||||
await modal.locator('input[type="file"]').setInputFiles({
|
||||
name,
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.alloc(size, 'a'),
|
||||
});
|
||||
await modal.getByRole('button', { name: say('addBook.submit') }).click();
|
||||
// Any of the advices the form can end on: which one it is, is what the checks below ask.
|
||||
await modal
|
||||
.getByText(say('upload.retryAdvice'), { exact: false })
|
||||
.or(modal.getByText(say('upload.badRequestAdvice'), { exact: false }))
|
||||
.or(modal.getByText(say('upload.tooLargeAdvice'), { exact: false }))
|
||||
.or(modal.getByText(say('upload.notAcceptedAdvice'), { exact: false }))
|
||||
.waitFor({ timeout: 10_000 });
|
||||
return modal.innerText();
|
||||
};
|
||||
|
||||
// The body over the cap — the one refusal the fixture takes from the FILE itself and not from
|
||||
// a marker, because that rule is real on both sides of the wire.
|
||||
const tooLarge = await send('gu-zhen-ren.txt', 2_400_000);
|
||||
await shot('refusal-413');
|
||||
check(
|
||||
tooLarge.includes(say('upload.tooLargeAdvice')),
|
||||
'a file over the cap is refused with the advice to take a smaller one',
|
||||
);
|
||||
await audit(page, 'the refusal of an oversized file');
|
||||
await closeModal(page);
|
||||
|
||||
for (const { marker, advice, ours, what } of [
|
||||
{
|
||||
marker: 'refuse-400',
|
||||
advice: say('upload.badRequestAdvice'),
|
||||
ours: say('upload.badRequest'),
|
||||
what: 'a form that did not arrive whole',
|
||||
},
|
||||
{
|
||||
marker: 'refuse-404',
|
||||
advice: say('upload.notAcceptedAdvice'),
|
||||
ours: say('upload.notAccepted'),
|
||||
what: 'a deployment that accepts no books',
|
||||
},
|
||||
{
|
||||
marker: 'refuse-408',
|
||||
advice: say('upload.retryAdvice'),
|
||||
ours: say('upload.timeout'),
|
||||
what: 'a body that missed the deadline',
|
||||
},
|
||||
]) {
|
||||
const shown = await send(`${marker}.txt`, 1000);
|
||||
await shot(`refusal-${marker}`);
|
||||
check(shown.includes(advice), `${what}: the form says what to do next`);
|
||||
// ⚠ The CLIENT'S OWN phrase for this very class must be absent: it is the fallback used when
|
||||
// the platform sends no problem body, so it is reachable — and its absence therefore says the
|
||||
// phrase on the screen came from the server. The first version of this check looked for a
|
||||
// phrase no path can print at all and so could never fail (found by the adversarial review).
|
||||
check(
|
||||
!shown.includes(ours),
|
||||
`${what}: the phrase shown is the platform's, not the client's own («${ours}»)`,
|
||||
);
|
||||
await closeModal(page);
|
||||
}
|
||||
|
||||
// And the other end of the intake: a book that was accepted and could not be turned into
|
||||
// chapters. Three reasons, three different next actions.
|
||||
for (const { marker, reason, advice } of [
|
||||
{
|
||||
marker: 'reject-source',
|
||||
reason: say('rejected.sourceUnreadable'),
|
||||
advice: say('rejected.sourceUnreadableAdvice'),
|
||||
},
|
||||
{
|
||||
marker: 'reject-config',
|
||||
reason: say('rejected.notConfigured'),
|
||||
advice: say('rejected.notConfiguredAdvice'),
|
||||
},
|
||||
{
|
||||
marker: 'reject-parser',
|
||||
reason: say('rejected.parserUnavailable'),
|
||||
advice: say('rejected.parserUnavailableAdvice'),
|
||||
},
|
||||
]) {
|
||||
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||||
await modal.locator('input[type="file"]').setInputFiles({
|
||||
name: `${marker}.txt`,
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.alloc(1000, 'a'),
|
||||
});
|
||||
await modal.getByRole('button', { name: say('addBook.submit') }).click();
|
||||
await modal.getByText(say('addBook.acceptedTitle')).waitFor({ timeout: 10_000 });
|
||||
await modal.getByRole('button', { name: say('action.done') }).click();
|
||||
// The book just uploaded is CHOSEN in the tree — the library holds the ones before it, and a
|
||||
// card that went on showing the first book would make every reason below read the same.
|
||||
// (The title is the name of the file: the form left it to the parse.)
|
||||
await page.locator('nav').getByText(marker, { exact: true }).click();
|
||||
const panel = page.locator('aside');
|
||||
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||||
await page.waitForFunction(
|
||||
(want) => document.querySelector('aside')?.textContent?.includes(want) === true,
|
||||
reason,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await shot(`refusal-${marker}`);
|
||||
// The row of a book can be SELECTED since S4, so the pairs on the blue ground are checked
|
||||
// here as well: axe does not see the contrast of a badge it considers decorative, and the
|
||||
// rule that recolours it had been removed in S3.7 as dead code.
|
||||
const faint = await contrastInSelectedRow(page);
|
||||
check(
|
||||
faint.length === 0,
|
||||
`${marker}: everything on the selected row of the book is readable ${faint
|
||||
.map((part) => `${part.what} ${part.ratio}:1 < ${part.floor}`)
|
||||
.join(' · ')}`,
|
||||
);
|
||||
check(
|
||||
(await panel.innerText()).includes(advice),
|
||||
`${marker}: the card names the reason and what to do about it`,
|
||||
);
|
||||
}
|
||||
await audit(page, 'a rejected book on the card');
|
||||
},
|
||||
|
||||
// Remarks 11, 12, 4: three modal windows instead of stub tabs and a second search button.
|
||||
/** @param {Page} page @param {Shot} shot */
|
||||
async overlays(page, shot) {
|
||||
|
|
@ -959,7 +1421,9 @@ const scenes = {
|
|||
await modal.getByText(say('addBook.titleHintAuto'), { exact: false }).isVisible(),
|
||||
'an empty title field honestly says that the title will be given by parsing the file',
|
||||
);
|
||||
await modal.getByRole('textbox').last().fill(book.title);
|
||||
// By NAME and not by position: the form has gained the pair of languages and the genre since
|
||||
// this scene was written, and `.last()` had quietly become the genre field.
|
||||
await modal.getByRole('textbox', { name: say('addBook.titleLabel') }).fill(book.title);
|
||||
check(
|
||||
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
|
||||
'a title entered by hand differs from the automatic parse',
|
||||
|
|
@ -1087,9 +1551,12 @@ function contrastInSelectedRow(page) {
|
|||
c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4;
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
};
|
||||
// ⚠ NOT rounded before the comparison: two places would accept 2.995 as three, which is the
|
||||
// hole the token gate closed on its own copy of this arithmetic (adversarial review). The
|
||||
// rounding that remains is for the MESSAGE and happens after the verdict.
|
||||
const ratio = (/** @type {string} */ a, /** @type {string} */ b) => {
|
||||
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return Math.round((100 * (high + 0.05)) / (low + 0.05)) / 100;
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
};
|
||||
|
||||
const row = document.querySelector('nav [role="row"][data-selected]');
|
||||
|
|
@ -1121,10 +1588,34 @@ function contrastInSelectedRow(page) {
|
|||
});
|
||||
}
|
||||
}
|
||||
return parts.filter((part) => part.ratio < part.floor);
|
||||
return parts
|
||||
.filter((part) => part.ratio < part.floor)
|
||||
.map((part) => ({ ...part, ratio: Math.round(part.ratio * 100) / 100 }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of rows the table has come to rest at. The repaint of the collection is deferred on
|
||||
* purpose (`useDeferredValue`), so "how many rows are there" has no answer until it stops changing —
|
||||
* and a fixed pause in its place is a number that is either too short to be true or too long to be
|
||||
* a measurement.
|
||||
* @param {Page} page @param {string} selector
|
||||
*/
|
||||
async function rowsSettled(page, selector) {
|
||||
// THREE readings in a row, not two: the deferred repaint has plateaus of its own, and two equal
|
||||
// samples 50 ms apart can both land on one (found by the adversarial review).
|
||||
let previous = -1;
|
||||
let steady = 0;
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const now = await page.locator(selector).count();
|
||||
steady = now === previous ? steady + 1 : 0;
|
||||
if (steady >= 2 && now > 0) return now;
|
||||
previous = now;
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
throw new Error('the table never settled on a number of rows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags the right separator through a list of offsets from where it started, then lets go.
|
||||
* @param {Page} page @param {number[]} offsets
|
||||
|
|
@ -1215,6 +1706,21 @@ const handleStyle = (separator) =>
|
|||
return { opacity: style.opacity, background: style.backgroundColor, height: style.height };
|
||||
});
|
||||
|
||||
/**
|
||||
* Closes the modal that is open and WAITS for it to be gone.
|
||||
*
|
||||
* Escape alone is a race: the window plays a transition, and the next click lands on the overlay
|
||||
* that is still there — which is how this scene began failing once in every few runs, with the
|
||||
* pointer intercepted by `_overlay_`. Waiting for the dialog to detach is the only honest signal.
|
||||
* @param {Page} page
|
||||
*/
|
||||
async function closeModal(page) {
|
||||
const modal = page.getByRole('dialog');
|
||||
if ((await modal.count()) === 0) return;
|
||||
await page.keyboard.press('Escape');
|
||||
await modal.waitFor({ state: 'detached', timeout: 5000 });
|
||||
}
|
||||
|
||||
/** @param {Page} page @param {string} route */
|
||||
async function open(page, route) {
|
||||
await page.goto(`${origin}${route}`, { waitUntil: 'load' });
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const KNOWN_ROUTES = [
|
|||
'/showcase',
|
||||
'/scale',
|
||||
'/empty',
|
||||
'/intake',
|
||||
'/loading',
|
||||
'/error',
|
||||
'/offline',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
|
||||
import type { Page, Problem } from './contract';
|
||||
|
||||
const basePath = '/v0';
|
||||
/** The version prefix, in ONE place: the upload speaks the same surface and must not carry a copy. */
|
||||
export const basePath = '/v0';
|
||||
|
||||
// CSRF on the cookie path. What protects is the PRESENCE of the header; the value is arbitrary and
|
||||
// carries no token semantics. Required on same-origin requests too — it is not a CORS mechanism.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import openapiTS, { astToString } from 'openapi-typescript';
|
|||
import { expect, test } from 'vitest';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { contractVersion } from '../mock/events';
|
||||
import { narrow } from './contract';
|
||||
import { majorOf, supportedMajor } from './stream';
|
||||
import { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary';
|
||||
|
|
@ -46,6 +47,14 @@ test('the major version this build speaks is the one the spec declares', async (
|
|||
expect(majorOf(declared as string)).toBe(supportedMajor);
|
||||
});
|
||||
|
||||
// The fixture announces a version in its handshake, and nothing at run time can read the spec to
|
||||
// check it: the copy is locked here. It drifted a whole session unnoticed, because the client
|
||||
// compares the MAJOR — so a wrong minor breaks no test and tells no one.
|
||||
test('the version the fixture announces is the version the spec declares', async () => {
|
||||
const spec = await readFile(specPath, 'utf8');
|
||||
expect(contractVersion).toBe(/^\s{2}version:\s*(\S+)\s*$/m.exec(spec)?.[1]);
|
||||
});
|
||||
|
||||
test('an unknown vocabulary value is narrowed, not trusted and not thrown', () => {
|
||||
expect(bookStatus.read('translating')).toBe('translating');
|
||||
expect(bookStatus.read('status-from-a-future-contract')).toBeNull();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
bookStatus,
|
||||
noteSeverity,
|
||||
pausedReason,
|
||||
rejectReason,
|
||||
termKind,
|
||||
termOrigin,
|
||||
termStatus,
|
||||
|
|
@ -24,6 +25,7 @@ type Schemas = components['schemas'];
|
|||
|
||||
export type BookStatus = Schemas['BookStatus'];
|
||||
export type PausedReason = Schemas['PausedReason'];
|
||||
export type RejectReason = Schemas['RejectReason'];
|
||||
export type NoteSeverity = Schemas['NoteSeverity'];
|
||||
export type TermKind = Schemas['TermKind'];
|
||||
export type TermStatus = Schemas['TermStatus'];
|
||||
|
|
@ -40,7 +42,12 @@ export type CeilingBounds = Schemas['CeilingBounds'];
|
|||
export type BankDecision = Schemas['BankDecision'];
|
||||
export type Problem = Schemas['Problem'];
|
||||
|
||||
export type Book = Omit<Schemas['Book'], 'status'> & { status: BookStatus | null };
|
||||
export type Book = Omit<Schemas['Book'], 'status' | 'reject_reason'> & {
|
||||
status: BookStatus | null;
|
||||
// Optional on the wire and narrowed to `null` when unknown: the contract says an absent reason
|
||||
// and one this build does not know are the same fact — "why is not known" — and are shown alike.
|
||||
reject_reason?: RejectReason | null;
|
||||
};
|
||||
export type Run = Omit<Schemas['Run'], 'status' | 'paused_reason'> & {
|
||||
status: BookStatus | null;
|
||||
paused_reason: PausedReason | null;
|
||||
|
|
@ -75,7 +82,14 @@ export interface Page {
|
|||
}
|
||||
|
||||
export const narrow = {
|
||||
book: (wire: Schemas['Book']): Book => ({ ...wire, status: bookStatus.read(wire.status) }),
|
||||
book: (wire: Schemas['Book']): Book => ({
|
||||
...wire,
|
||||
status: bookStatus.read(wire.status),
|
||||
reject_reason:
|
||||
wire.reject_reason === undefined || wire.reject_reason === null
|
||||
? wire.reject_reason
|
||||
: rejectReason.read(wire.reject_reason),
|
||||
}),
|
||||
|
||||
run: (wire: Schemas['Run']): Run => ({
|
||||
...wire,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,16 @@ export * from './scenarios';
|
|||
export { ApiError } from './client';
|
||||
export { subscribeToRun, supportedMajor, majorOf, readFrame } from './stream';
|
||||
export type { ConnectionState, Frame } from './stream';
|
||||
export { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary';
|
||||
export { uploadBook, UploadAborted } from './upload';
|
||||
export type { BookIntake, UploadProgress } from './upload';
|
||||
export {
|
||||
bookStatus,
|
||||
noteSeverity,
|
||||
pausedReason,
|
||||
rejectReason,
|
||||
termKind,
|
||||
termStatus,
|
||||
} from './vocabulary';
|
||||
export type { Tone } from './vocabulary';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { replaceEqualDeep } from '@tanstack/react-query';
|
|||
|
||||
import { request, requestAll } from './client';
|
||||
import { narrow } from './contract';
|
||||
import { bookStatus } from './vocabulary';
|
||||
import type {
|
||||
Bank,
|
||||
BankDecision,
|
||||
BookDetail,
|
||||
BookStatus,
|
||||
ChapterList,
|
||||
Id,
|
||||
Library,
|
||||
|
|
@ -59,6 +61,23 @@ const dropStaleReads = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* How often a book still in intake is asked about again, in milliseconds.
|
||||
*
|
||||
* ⚠ POLLING, and it is the contract's shape that forces it rather than a shortcut: the live channel
|
||||
* belongs to a RUN (`/runs/{runId}/events`), and a book being uploaded or parsed has no run — so
|
||||
* nothing pushes "the parsing is over". Without asking again, a book stays `parsing` on the screen
|
||||
* until something else happens to invalidate the read. The interval is deliberately unhurried: this
|
||||
* is a step that takes minutes on a real book, and the answer is a small JSON. It stops the moment
|
||||
* the book leaves intake — a poll that never stops is the thing to be afraid of here (BACKLOG Ф-56:
|
||||
* an event for the end of the intake is a question for the contract's owner).
|
||||
*/
|
||||
export const intakePollMs = 3000;
|
||||
|
||||
/** Whether a book is still arriving. The answer is a property of the STATUS and lives with it. */
|
||||
export const inIntake = (status: BookStatus | null | undefined) =>
|
||||
status !== undefined && bookStatus.describe(status).intake === true;
|
||||
|
||||
export const keys = {
|
||||
library: () => ['library'] as const,
|
||||
book: (bookId: Id) => ['book', bookId] as const,
|
||||
|
|
@ -148,11 +167,15 @@ export const libraryQuery = () => ({
|
|||
queryKey: keys.library(),
|
||||
queryFn: readLibrary,
|
||||
...dropStaleReads,
|
||||
refetchInterval: ({ state }: { state: { data?: Library } }) =>
|
||||
state.data?.books.some((book) => inIntake(book.status)) === true ? intakePollMs : false,
|
||||
});
|
||||
export const bookQuery = (bookId: Id) => ({
|
||||
queryKey: keys.book(bookId),
|
||||
queryFn: () => readBook(bookId),
|
||||
...dropStaleReads,
|
||||
refetchInterval: ({ state }: { state: { data?: BookDetail } }) =>
|
||||
inIntake(state.data?.book.status) ? intakePollMs : false,
|
||||
});
|
||||
export const chaptersQuery = (bookId: Id) => ({
|
||||
queryKey: keys.chapters(bookId),
|
||||
|
|
@ -178,6 +201,11 @@ export const usageQuery = () => ({ queryKey: keys.usage(), queryFn: readUsage })
|
|||
export const runOptionsQuery = (bookId: Id) => ({
|
||||
queryKey: keys.runOptions(bookId),
|
||||
queryFn: () => readRunOptions(bookId),
|
||||
// ⚠ NEVER fresh from the cache, against the client's own 15-second default: the whole reason
|
||||
// these bounds are a resource of their own is that the maximum belongs to the ACCOUNT and moves
|
||||
// while the book does not (contract, `run-options`). Re-opening the form within the window
|
||||
// otherwise showed a scale read before somebody else's hold was taken.
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Actions. Unsafe methods, so every one of them carries `X-TM-Client` — the client half of the
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const scenarios = [
|
|||
'showcase',
|
||||
'scale',
|
||||
'empty',
|
||||
'intake',
|
||||
'loading',
|
||||
'error',
|
||||
'offline',
|
||||
|
|
|
|||
|
|
@ -24,8 +24,40 @@ export interface paths {
|
|||
put?: never;
|
||||
/**
|
||||
* Add a book.
|
||||
* @description Accepts the file and the properties declared by the user. Responds immediately; the book
|
||||
* enters `uploading` and parsing is a separate visible step.
|
||||
* @description Accepts the file and the properties declared by the user.
|
||||
*
|
||||
* **The `file` part MUST come LAST in the form** (0.2.3). The platform reads the form as a
|
||||
* STREAM, part by part, and stops at the file: the book's row — the record that makes an
|
||||
* upload visible while it arrives and findable when it dies halfway — cannot be written
|
||||
* before the languages that row requires.
|
||||
*
|
||||
* A part sent after the file is therefore NOT READ AT ALL. What that costs depends on the
|
||||
* part: a required one (`source_lang`, `target_lang`) is answered `400`, because to a reader
|
||||
* that streams "the languages came late" and "the languages never came" are the same thing;
|
||||
* an optional one is silently lost, and the book is created without it. A client that puts
|
||||
* anything after the file is a client whose form is partly ignored.
|
||||
*
|
||||
* **The `201` carries `parsing`, not `uploading`** (0.2.3). The answer is written after the
|
||||
* last byte has landed, so by the time a client can read it the file is in and the book has
|
||||
* moved on. `uploading` is a real state and is observable — by a SECOND read of the library
|
||||
* while the upload is still on the wire — but never as the answer to this call.
|
||||
*
|
||||
* Parsing is a separate visible step after that, and it has no numeric progress: no counter of
|
||||
* it exists on this contract, and a percentage would have to be invented.
|
||||
*
|
||||
* Refusals of the intake, all of them product states of the form rather than failures of the
|
||||
* service:
|
||||
*
|
||||
* - `400` — the form could not be read: more than 16 parts, a text field longer than a
|
||||
* kilobyte, a REQUIRED field that arrived after the file (or never), or no file at all;
|
||||
* - `404` — this deployment does not accept books at all. An instance with no place to put a
|
||||
* file and no engine to cut it with serves the library and refuses this path, which is a
|
||||
* property of the DEPLOYMENT and not of the request;
|
||||
* - `408` — the body did not finish inside the route's deadline: a slow client on a large
|
||||
* book. Retrying is the remedy, which is what separates it from `413`;
|
||||
* - `413` — the body is over the intake cap. The threshold belongs to the deployment and is
|
||||
* not stated here: a number in the contract would be a second copy of it, and the two would
|
||||
* disagree the day it changes.
|
||||
*/
|
||||
post: operations["createBook"];
|
||||
delete?: never;
|
||||
|
|
@ -342,6 +374,11 @@ export interface paths {
|
|||
*
|
||||
* **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a
|
||||
* complete set.
|
||||
*
|
||||
* **503 answers a deployment that cannot run at all** (0.2.3). Continuing a run is starting a
|
||||
* process, so it needs exactly what a start needs — the seam that tells the engine its ceiling
|
||||
* and the one that records how a run ended. 0.2.1 named that state for `startRun` only, and
|
||||
* the omission was in the SPEC and not in the wire: the two calls share one refusal.
|
||||
*/
|
||||
post: operations["resumeRun"];
|
||||
delete?: never;
|
||||
|
|
@ -523,6 +560,30 @@ export interface components {
|
|||
* @enum {string}
|
||||
*/
|
||||
BookStatus: "uploading" | "parsing" | "not_started" | "translating" | "awaiting_bank" | "finalizing" | "ready" | "paused" | "stopped" | "rejected" | "failed";
|
||||
/**
|
||||
* @description Machine reason a book was rejected (0.2.3). The platform's own closed vocabulary; as with
|
||||
* `PausedReason` the API carries STATE and the phrase the user reads is drawn by the client,
|
||||
* so no wording appears here.
|
||||
*
|
||||
* - `source_unreadable` — the file was read and is not a book this service can cut: it yields
|
||||
* no sections at all, or the reader refused it. TERMINAL, and the source does not survive
|
||||
* it — there is no path in this contract that re-reads a rejected book, so the remedy is to
|
||||
* add the book again;
|
||||
* - `not_configured` — this deployment has nothing to read the book AGAINST. It is a state of
|
||||
* the service, never of the file, and retrying by itself does not clear it;
|
||||
* - `parser_unavailable` — the service could not process the file, repeatedly, until it gave
|
||||
* up. A state of the service as well, and a temporary one.
|
||||
*
|
||||
* The three are two different NEXT ACTIONS for the user, and a client that told them apart by
|
||||
* colour alone would be telling them apart by nothing: the first means "the file is not one we
|
||||
* can read", the other two mean "not us, not now".
|
||||
*
|
||||
* A client MUST tolerate an unknown value arriving under a minor bump, and MUST render a
|
||||
* rejected book that carries no reason at all: a deployment older than this minor answers
|
||||
* exactly that.
|
||||
* @enum {string}
|
||||
*/
|
||||
RejectReason: "source_unreadable" | "not_configured" | "parser_unavailable";
|
||||
/** @description A book in the library. */
|
||||
Book: {
|
||||
id: components["schemas"]["Id"];
|
||||
|
|
@ -537,6 +598,16 @@ export interface components {
|
|||
/** Format: date-time */
|
||||
added_at: string;
|
||||
status: components["schemas"]["BookStatus"];
|
||||
/**
|
||||
* @description Why the book was rejected; meaningful only while `status` is `rejected` (0.2.3).
|
||||
*
|
||||
* OPTIONAL, unlike `Run.paused_reason`, and the asymmetry is deliberate: a rejection
|
||||
* reason is meaningful in exactly one of eleven states, and a deployment that predates
|
||||
* this minor sends nothing here at all. "Absent" and "null" therefore mean one and the
|
||||
* same thing — the reason is not known — and a client renders both the same way. That is
|
||||
* the opposite of the `sense` case (0.2.2), where the two shapes carried DIFFERENT facts.
|
||||
*/
|
||||
reject_reason?: components["schemas"]["RejectReason"] | null;
|
||||
progress: components["schemas"]["Progress"];
|
||||
note_count: number;
|
||||
};
|
||||
|
|
@ -583,11 +654,27 @@ export interface components {
|
|||
/** @description Current or last run; `null` if the book was never run. */
|
||||
run?: components["schemas"]["Run"] | null;
|
||||
};
|
||||
/** @description Add-a-book form. */
|
||||
/**
|
||||
* @description Add-a-book form.
|
||||
*
|
||||
* ⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part, and
|
||||
* every other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the
|
||||
* rule is stated in prose because it cannot be stated in the schema.
|
||||
*/
|
||||
BookIntake: {
|
||||
/**
|
||||
* @description Title given by hand (0.2.3). OPTIONAL, and the two cases are told apart by the user
|
||||
* rather than guessed at: absent or empty means "the parse will name it" — today the
|
||||
* platform takes the name of the uploaded file — while a value present means the person
|
||||
* named the book themselves, and no later parse overwrites it.
|
||||
*
|
||||
* Bounded like the title the platform derives: the library lists it, and an unbounded
|
||||
* string on that screen is the client's problem to draw, not the server's to store.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Format: binary
|
||||
* @description Book file.
|
||||
* @description Book file. The LAST part of the form.
|
||||
*/
|
||||
file: string;
|
||||
source_lang: components["schemas"]["LangCode"];
|
||||
|
|
@ -1022,9 +1109,23 @@ export interface components {
|
|||
};
|
||||
};
|
||||
/**
|
||||
* @description The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a
|
||||
* run requires the engine seam to be fully configured, and answering with any other code
|
||||
* would misname the state. Temporary by nature — retry later; no Retry-After is promised.
|
||||
* @description The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on
|
||||
* a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the
|
||||
* remedy — which is what a `413` and a `500` in its place would both hide.
|
||||
*/
|
||||
RequestTimeout: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["Problem"];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* @description The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting or
|
||||
* continuing a run requires the engine seam to be fully configured, and answering with any
|
||||
* other code would misname the state. Temporary by nature — retry later; no Retry-After is
|
||||
* promised.
|
||||
*/
|
||||
ServiceUnavailable: {
|
||||
headers: {
|
||||
|
|
@ -1115,7 +1216,7 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Book accepted. */
|
||||
/** @description Book accepted; it is being parsed. */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
|
|
@ -1126,6 +1227,8 @@ export interface operations {
|
|||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
404: components["responses"]["NotFound"];
|
||||
408: components["responses"]["RequestTimeout"];
|
||||
413: components["responses"]["TooLarge"];
|
||||
};
|
||||
};
|
||||
|
|
@ -1482,6 +1585,7 @@ export interface operations {
|
|||
401: components["responses"]["Unauthorized"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
getUsage: {
|
||||
|
|
|
|||
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
|
||||
* meaning and colour only doubles it, for attention and refusal.
|
||||
*/
|
||||
export const bookStatus = vocabulary<Schemas['BookStatus'], { label: MessageKey; tone?: Tone }>(
|
||||
export const bookStatus = vocabulary<
|
||||
Schemas['BookStatus'],
|
||||
{ label: MessageKey; tone?: Tone; startable?: boolean; intake?: boolean }
|
||||
>(
|
||||
{
|
||||
uploading: { label: 'status.uploading', tone: 'note' },
|
||||
parsing: { label: 'status.parsing', tone: 'note' },
|
||||
not_started: { label: 'status.notStarted' },
|
||||
// `intake` — the book is still ARRIVING: it has no run, therefore no event stream, therefore
|
||||
// no way of announcing that it is over. The reads poll while it lasts (src/api/queries.ts).
|
||||
uploading: { label: 'status.uploading', tone: 'note', intake: true },
|
||||
parsing: { label: 'status.parsing', tone: 'note', intake: true },
|
||||
not_started: { label: 'status.notStarted', startable: true },
|
||||
translating: { label: 'status.translating', tone: 'note' },
|
||||
awaiting_bank: { label: 'status.awaitingBank', tone: 'warn' },
|
||||
finalizing: { label: 'status.finalizing', tone: 'note' },
|
||||
ready: { label: 'status.ready', tone: 'ok' },
|
||||
ready: { label: 'status.ready', tone: 'ok', startable: true },
|
||||
// Resumable stop, so no danger tone: the screen must not read as a failure (D39.100 K-8).
|
||||
// ⚠ The wording is the OWNER'S and is not ours to shorten (В-6). S3.5 did shorten it to fit
|
||||
// the badge slot and had to put it back: the badge truncates with a tooltip instead, and
|
||||
// which word belongs in the tree is a question for the owner, not a layout decision.
|
||||
paused: { label: 'status.paused', tone: 'warn' },
|
||||
stopped: { label: 'status.stopped' },
|
||||
//
|
||||
// Startable, and that is not the same as resumable: a run stopped by a ceiling is not moved by
|
||||
// `resume` at all (contract §resumeRun), while a NEW run carries a new ceiling of its own —
|
||||
// which is the whole reason the ceiling travels with the start.
|
||||
paused: { label: 'status.paused', tone: 'warn', startable: true },
|
||||
stopped: { label: 'status.stopped', startable: true },
|
||||
rejected: { label: 'status.rejected', tone: 'danger' },
|
||||
failed: { label: 'status.failed', tone: 'danger' },
|
||||
failed: { label: 'status.failed', tone: 'danger', startable: true },
|
||||
},
|
||||
// Neutral on purpose: a state from a future contract is not an error, and calling it one would
|
||||
// put an alarm on a screen that the product forbids to look alarming.
|
||||
//
|
||||
// ⚠ NOT startable, and the direction is picked rather than defaulted: a minor bump adds states,
|
||||
// and the states a pipeline gains are stages of a run in flight — exactly where starting a second
|
||||
// one is refused. An action offered for a state this build cannot read would be a promise made on
|
||||
// a guess; the state itself is still named on the card.
|
||||
{ label: 'status.unknown' },
|
||||
);
|
||||
|
||||
|
|
@ -84,6 +98,40 @@ export const pausedReason = vocabulary<Schemas['PausedReason'], { label: Message
|
|||
{ label: 'paused.unknown' },
|
||||
);
|
||||
|
||||
/**
|
||||
* Why a book was rejected → what the reader is told and what they can do about it (0.2.3).
|
||||
*
|
||||
* Two things ride together because they are one decision: the phrase names what happened, the
|
||||
* advice names the NEXT ACTION, and the next action is the whole reason the contract carries three
|
||||
* values instead of a boolean. "The file is not one we can read" ends in checking the file; "not
|
||||
* us, not now" ends in waiting. A vocabulary that carried only the phrase would leave the second
|
||||
* half to be re-invented by every screen that shows a rejected book.
|
||||
*
|
||||
* ⚠ The wording is this session's own (owner's word 14.08) and awaits his approval. The boundary it
|
||||
* is written inside is the one `Problem` states for the platform's phrases: no stages, no models,
|
||||
* nothing about how the parsing works.
|
||||
*/
|
||||
export const rejectReason = vocabulary<
|
||||
Schemas['RejectReason'],
|
||||
{ label: MessageKey; advice: MessageKey }
|
||||
>(
|
||||
{
|
||||
source_unreadable: {
|
||||
label: 'rejected.sourceUnreadable',
|
||||
advice: 'rejected.sourceUnreadableAdvice',
|
||||
},
|
||||
not_configured: { label: 'rejected.notConfigured', advice: 'rejected.notConfiguredAdvice' },
|
||||
parser_unavailable: {
|
||||
label: 'rejected.parserUnavailable',
|
||||
advice: 'rejected.parserUnavailableAdvice',
|
||||
},
|
||||
},
|
||||
// A reason this build does not know — and an absent one, which the contract says to render the
|
||||
// same way — claims nothing about whose fault it is. It must not send the reader to check a file
|
||||
// that may be perfectly good, nor promise that waiting will help.
|
||||
{ label: 'rejected.unknown', advice: 'rejected.unknownAdvice' },
|
||||
);
|
||||
|
||||
/**
|
||||
* Severity step of a note → look of the callout.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -51,10 +51,14 @@ const roots = [
|
|||
'vite.config.ts',
|
||||
'index.html',
|
||||
'tsconfig.json',
|
||||
'package.json',
|
||||
'.npmrc',
|
||||
'.gitignore',
|
||||
'.spectral.yaml',
|
||||
'.prettierignore',
|
||||
// Extensionless, and therefore invisible to the sweep by extension above — while it is a script
|
||||
// of this zone like any other (acceptance of S3.7, minor).
|
||||
'scripts/githooks/pre-commit',
|
||||
];
|
||||
|
||||
const files = [...sources('src'), ...sources('scripts'), ...roots]
|
||||
|
|
@ -95,7 +99,11 @@ test('a place left without a variable stays visible rather than blank', () => {
|
|||
* rendering every screen, and a gate that needs a browser is a gate nobody runs.
|
||||
*/
|
||||
test('every call passes exactly the variables its line has places for', () => {
|
||||
const calls = /\btext\(\s*'([\w.]+)'\s*,\s*\{([^{}]*)\}/g;
|
||||
// ANY call of the shape `f('a.key', { … })`, not only one made through a local variable named
|
||||
// `text`: the rule is about the KEY and its places, and a gate that hung on the caller's name
|
||||
// stopped seeing a screen that named it anything else (acceptance of S3.7, minor). A false match
|
||||
// is impossible by construction — a key that is not in the catalogue is skipped below.
|
||||
const calls = /\b\w+\(\s*'([\w.]+)'\s*,\s*\{([^{}]*)\}/g;
|
||||
const seen: string[] = [];
|
||||
|
||||
for (const path of files) {
|
||||
|
|
@ -132,7 +140,7 @@ test('every line with a place is called with variables somewhere', () => {
|
|||
for (const path of files) {
|
||||
if (!['.ts', '.tsx'].includes(extname(path))) continue;
|
||||
const source = readFileSync(join(root, path), 'utf8');
|
||||
for (const [, key] of source.matchAll(/\btext\(\s*'([\w.]+)'\s*,\s*\{/g))
|
||||
for (const [, key] of source.matchAll(/\b\w+\(\s*'([\w.]+)'\s*,\s*\{/g))
|
||||
filled.add(key as string);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// THE catalogue of Russian interface strings. The only file in `src/` where interface wording
|
||||
// lives, and the only one the Cyrillic gate lets through (eslint.config.js, `src/i18n.test.ts`).
|
||||
// lives, and one of the two the Cyrillic gate lets through (eslint.config.js, and the sweep in
|
||||
// `src/i18n/catalogue.test.ts` — the other one is the fixtures).
|
||||
//
|
||||
// What belongs here: every word the user reads — labels, hints, empty states, accessible names,
|
||||
// tooltips. What does NOT: the prose of the fixtures (`src/mock/`), which is DATA standing in for a
|
||||
|
|
@ -13,6 +14,12 @@
|
|||
// translation is invented here.
|
||||
|
||||
export const ru = {
|
||||
'action.cancel': 'Отмена',
|
||||
// The window is closed by the cross in its corner; this is the word for "I have read it, we are
|
||||
// done" — a different act, and a different button.
|
||||
'action.done': 'Готово',
|
||||
|
||||
'shell.appName': 'TextMachine',
|
||||
'shell.collapseLeft': 'Свернуть левую панель',
|
||||
'shell.collapseRight': 'Свернуть правую панель',
|
||||
'shell.documentsPanel': 'Открытые документы',
|
||||
|
|
@ -24,6 +31,7 @@ export const ru = {
|
|||
'library.addBook': 'Добавить книгу',
|
||||
'library.booksTab': 'Книги',
|
||||
'library.chapterProgress': 'переведено {done} из {total}',
|
||||
'library.chapterRange': 'разделы {from}–{to}',
|
||||
'library.chaptersFailed': 'разделы не загрузились',
|
||||
'library.chaptersLoading': 'загрузка разделов…',
|
||||
'library.emptyCentreDescription': 'Добавьте книгу — её разделы откроются здесь вкладками.',
|
||||
|
|
@ -104,6 +112,9 @@ export const ru = {
|
|||
'about.noBooksTitle': 'Книг пока нет',
|
||||
'about.notes': 'Замечаний',
|
||||
'about.panel': 'О читаемой книге',
|
||||
'about.parsing':
|
||||
'Книга разбирается на разделы. Они появятся в дереве слева, когда разбор дойдёт до них.',
|
||||
'about.uploading': 'Файл ещё загружается. Разбор начнётся, когда он дойдёт целиком.',
|
||||
'about.runFinished': 'Прогон завершён',
|
||||
'about.runStarted': 'Прогон начат',
|
||||
'about.status': 'Состояние',
|
||||
|
|
@ -123,19 +134,70 @@ export const ru = {
|
|||
'goto.title': 'Перейти',
|
||||
'goto.truncated': 'Показаны первые {count} совпадений.',
|
||||
|
||||
'addBook.cancel': 'Отмена',
|
||||
'addBook.acceptedDescription': 'Файл принят целиком. Разбор на разделы идёт сам.',
|
||||
'addBook.acceptedTitle': 'Книга загружена',
|
||||
'addBook.chooseFile': 'Выбрать файл',
|
||||
'addBook.fileLabel': 'Файл книги',
|
||||
'addBook.settingsLabel': 'Настройки книги',
|
||||
'addBook.settingsSlot': 'Пара языков, жанр и параметры запуска появятся здесь.',
|
||||
'addBook.genreLabel': 'Жанр',
|
||||
'addBook.genrePlaceholder': 'не обязательно',
|
||||
'addBook.sending': 'Файл отправляется',
|
||||
'addBook.sendingSize': 'отправлено {sent} из {total}',
|
||||
'addBook.sendingUnknown': 'Файл отправляется: {total}',
|
||||
'addBook.sent': 'Файл отправлен целиком — ждём ответа сервиса.',
|
||||
'addBook.sourceLangLabel': 'Язык оригинала',
|
||||
'addBook.submit': 'Добавить',
|
||||
'addBook.targetLangLabel': 'Язык перевода',
|
||||
'addBook.title': 'Добавить книгу',
|
||||
'addBook.titleHintAuto':
|
||||
'Поле пустое: название определит разбор файла — его можно будет поправить позже.',
|
||||
// ⚠ Без «поправить позже»: переименовать книгу нечем — на всей поверхности контракта у книги
|
||||
// нет ни одной записи (Ф-62), и обещать правку, которой нет, интерфейс не вправе.
|
||||
'addBook.titleHintAuto': 'Поле пустое: название определит разбор файла.',
|
||||
'addBook.titleHintManual': 'Название задано вручную: разбор его не перепишет.',
|
||||
'addBook.titleLabel': 'Название',
|
||||
'addBook.titlePlaceholder': 'по умолчанию — из файла',
|
||||
|
||||
'upload.badRequest': 'Форма не дошла целиком',
|
||||
// Отдельный совет от общего «повторите»: если форма не дошла второй раз, повтор третий раз
|
||||
// ничего не изменит — и честно сказать, что это уже наша сторона.
|
||||
'upload.badRequestAdvice': 'Попробуйте ещё раз. Если повторится — дело не в файле, а в нас.',
|
||||
'upload.failedTitle': 'Книга не загрузилась',
|
||||
'upload.notAccepted': 'Этот сервис не принимает книги',
|
||||
'upload.notAcceptedAdvice': 'Файл здесь ни при чём — загрузка недоступна на этом сервисе.',
|
||||
'upload.refused': 'Сервис не принял файл',
|
||||
'upload.refusedAdvice': 'Попробуйте ещё раз позже.',
|
||||
'upload.retryAdvice': 'Попробуйте отправить файл ещё раз.',
|
||||
'upload.timeout': 'Файл не успел дойти',
|
||||
'upload.tooLarge': 'Файл больше, чем сервис принимает',
|
||||
'upload.tooLargeAdvice': 'Выберите файл поменьше.',
|
||||
|
||||
'rejected.notConfigured': 'Сервис пока не готов принять эту книгу',
|
||||
'rejected.notConfiguredAdvice':
|
||||
'Файл здесь ни при чём: повторная загрузка поможет не раньше, чем мы это наладим.',
|
||||
'rejected.parserUnavailable': 'Не удалось обработать файл на нашей стороне',
|
||||
'rejected.parserUnavailableAdvice': 'Файл здесь ни при чём: попробуйте добавить книгу позже.',
|
||||
'rejected.sourceUnreadable': 'Файл не удалось разобрать на разделы',
|
||||
'rejected.sourceUnreadableAdvice':
|
||||
'Проверьте файл — возможно, это не текст книги — и добавьте её заново.',
|
||||
'rejected.unknown': 'Книгу не удалось принять',
|
||||
'rejected.unknownAdvice': 'Причина не названа. Попробуйте добавить книгу заново или позже.',
|
||||
|
||||
'run.action': 'Перевести',
|
||||
'run.ceilingHint': 'Прогон остановится, дойдя до этого числа разделов.',
|
||||
// Не называет причину: 409 приходит и на уехавший потолок, и на прогон, который уже идёт, и на
|
||||
// книгу, которая ещё не готова — платформа называет причину сама, а это совет на все случаи.
|
||||
'run.conflict': 'Запустить не удалось: состояние книги изменилось',
|
||||
'run.conflictAdvice': 'Форма и карточка книги обновлены — посмотрите их и попробуйте снова.',
|
||||
// Не называет причину: ноль на шкале приходит и от исчерпанного остатка, и от книги, которая
|
||||
// переведена целиком (контракт: максимум уже подрезан обоими).
|
||||
'run.exhaustedDescription': 'Сейчас доступно ноль разделов — начинать нечего.',
|
||||
'run.exhaustedTitle': 'Запустить прогон сейчас нельзя',
|
||||
'run.failedTitle': 'Прогон не начался',
|
||||
'run.title': 'Перевести книгу',
|
||||
'run.unavailable': 'Перевод сейчас запустить нельзя',
|
||||
'run.unavailableAdvice': 'Попробуйте позже.',
|
||||
'run.verifyBank': 'Остановиться на подписи банка',
|
||||
'run.verifyBankHint': 'Перед финальным проходом прогон остановится, чтобы вы подписали термины.',
|
||||
'run.waiting': 'Параметры запуска',
|
||||
|
||||
'settings.defaultsAbout':
|
||||
'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.',
|
||||
'settings.defaultsName': 'Перевод по умолчанию',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ export const books: Schemas['Book'][] = [
|
|||
other('bk_6', 'Записки о лисах и духах', 'not_started'),
|
||||
other('bk_7', 'Небесный чиновник', 'paused'),
|
||||
other('bk_8', 'Сломанный меч', 'stopped'),
|
||||
other('bk_9', 'Повреждённая выгрузка', 'rejected'),
|
||||
// With a machine reason (0.2.3): a rejected book without one is legal too — that is what a
|
||||
// deployment older than this minor answers — and the intake world carries that case.
|
||||
other('bk_9', 'Повреждённая выгрузка', 'rejected', { reject_reason: 'source_unreadable' }),
|
||||
other('bk_10', 'Оборванный свиток', 'failed'),
|
||||
other('bk_11', 'Свежая загрузка', 'uploading'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ type Schemas = components['schemas'];
|
|||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* The version the fake platform announces in its handshake.
|
||||
*
|
||||
* ⚠ It has to be the SPEC's own, and nothing at run time can read the spec — so the copy is locked
|
||||
* by a test instead (`src/api/contract.test.ts`). Bought by this pack: the number stood at 0.2.2 for
|
||||
* a whole session after the spec moved on, and the client compares only the MAJOR, so nothing failed
|
||||
* and nothing said a word.
|
||||
*/
|
||||
export const contractVersion = '0.2.3';
|
||||
|
||||
function frame(id: number, event: string, data: unknown): Uint8Array {
|
||||
return encoder.encode(`id: ${String(id)}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
|
@ -39,7 +49,7 @@ export function runEvents(scenario: Scenario, world: World) {
|
|||
async start(controller) {
|
||||
controller.enqueue(
|
||||
frame(live.revision, 'hello', {
|
||||
contract: '0.2.2',
|
||||
contract: contractVersion,
|
||||
run_id: String(params.runId),
|
||||
revision: live.revision,
|
||||
} satisfies Schemas['EventHello']),
|
||||
|
|
@ -57,6 +67,29 @@ export function runEvents(scenario: Scenario, world: World) {
|
|||
},
|
||||
} satisfies Schemas['EventProgress']),
|
||||
);
|
||||
// A chapter of its own on every other burst, where the world can move one: the tree draws
|
||||
// progress per chapter, and a book whose own counter runs while every chapter stands at
|
||||
// zero is two answers to one question.
|
||||
const chapter = step % 2 === 1 ? world.advanceChapter?.() : null;
|
||||
if (chapter) controller.enqueue(frame(live.revision, 'chapter', chapter));
|
||||
}
|
||||
|
||||
// The end of the path: the run reaches its ceiling and halts. Two frames, in the order the
|
||||
// contract puts them in — the fact of the halt, then the state it left behind — and the
|
||||
// world is told FIRST, because a read the frames provoke must not answer with a run that
|
||||
// is still going (the fixture's own invariant).
|
||||
if (world.halt) {
|
||||
await delay(400);
|
||||
world.halt();
|
||||
controller.enqueue(
|
||||
frame(live.revision, 'ceiling', { halted: true } satisfies Schemas['EventCeiling']),
|
||||
);
|
||||
controller.enqueue(
|
||||
frame(live.revision, 'status', {
|
||||
status: 'paused',
|
||||
paused_reason: 'credit_exhausted',
|
||||
} satisfies Schemas['EventStatus']),
|
||||
);
|
||||
}
|
||||
// No close: a run outlives its bursts, and closing here would make the browser reconnect
|
||||
// and replay the script, which is not what a live run looks like.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { HttpResponse, delay, http } from 'msw';
|
|||
import type { Scenario } from '../api';
|
||||
import type { components } from '../api/schema';
|
||||
import { runEvents } from './events';
|
||||
import { intakeHandler } from './intake';
|
||||
import { bookId, worldOf, type World } from './worlds';
|
||||
|
||||
type Schemas = components['schemas'];
|
||||
|
|
@ -79,7 +80,16 @@ export function handlersFor(scenario: Scenario) {
|
|||
];
|
||||
}
|
||||
|
||||
return [...reads(world), ...actions(world), runEvents(scenario, world)];
|
||||
return [
|
||||
// The intake stands FIRST: MSW takes the first handler that matches, and `POST /books` has to
|
||||
// win over nothing else — the reads below answer `GET` only. A world that does not accept books
|
||||
// does not mount it at all, which is exactly how the platform answers 404 on a deployment
|
||||
// without an intake (contract 0.2.3, PD-174).
|
||||
...(world.intake === true ? [intakeHandler] : []),
|
||||
...reads(world),
|
||||
...actions(world),
|
||||
runEvents(scenario, world),
|
||||
];
|
||||
}
|
||||
|
||||
function reads(world: World) {
|
||||
|
|
@ -163,18 +173,16 @@ function actions(world: World) {
|
|||
if (missingHeader(request)) return problem(403, problemTitles.noClientHeader);
|
||||
const body = (await request.json()) as Schemas['RunRequest'];
|
||||
const ceiling = world.ceiling;
|
||||
if (
|
||||
body.ceiling_chapters < ceiling.min_chapters ||
|
||||
body.ceiling_chapters > ceiling.max_chapters
|
||||
) {
|
||||
return problem(409, problemTitles.ceilingTooHigh);
|
||||
}
|
||||
const started = runOf(String(params.bookId));
|
||||
// The world may have its own answer — bounds that moved between the read and this call, which
|
||||
// is what the contract's 409 is for. Without one, the bounds it answered the read with decide.
|
||||
const fits =
|
||||
world.fits?.(body.ceiling_chapters) ??
|
||||
(body.ceiling_chapters >= ceiling.min_chapters &&
|
||||
body.ceiling_chapters <= ceiling.max_chapters);
|
||||
if (!fits) return problem(409, problemTitles.ceilingTooHigh);
|
||||
const started = world.startRun(String(params.bookId), body);
|
||||
if (!started) return problem(404, problemTitles.bookNotFound);
|
||||
return json<Schemas['Run']>(
|
||||
{ ...started, ceiling_chapters: body.ceiling_chapters, verify_bank: body.verify_bank },
|
||||
{ status: 202 },
|
||||
);
|
||||
return json<Schemas['Run']>(started, { status: 202 });
|
||||
}),
|
||||
|
||||
http.post('*/v0/runs/:runId/stop', ({ request }) => {
|
||||
|
|
|
|||
377
frontend/src/mock/intake.ts
Normal file
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';
|
||||
|
||||
const draftTotal = book.progress.draft.total;
|
||||
const initialEta = book.progress.eta_seconds ?? null;
|
||||
|
||||
/**
|
||||
* Seconds per unit, taken from the hand-written book's own numbers: its estimate covers the units it
|
||||
* has left. A book of another size gets an estimate of its own from this — a fixture that handed a
|
||||
* twelve-unit book the two-and-a-half hours of a 4276-unit one was printing a number nothing in it
|
||||
* supported (found by the adversarial review).
|
||||
*/
|
||||
const secondsPerUnit =
|
||||
(initialEta ?? 0) / Math.max(1, book.progress.draft.total - book.progress.draft.done);
|
||||
|
||||
export const live = {
|
||||
revision: 1841,
|
||||
draftDone: book.progress.draft.done,
|
||||
etaSeconds: book.progress.eta_seconds ?? null,
|
||||
draftTotal: book.progress.draft.total,
|
||||
etaSeconds: initialEta,
|
||||
/** What the estimate was when the run began; the one below is derived from it and the progress. */
|
||||
etaAtStart: initialEta,
|
||||
};
|
||||
|
||||
/**
|
||||
* A run that starts from nothing — a book just uploaded and just parsed. The counters belong to the
|
||||
* run and not to the fixture that was written by hand, so a world where the book arrives from the
|
||||
* form has to be able to set them.
|
||||
*/
|
||||
export function begin(draftTotal: number): void {
|
||||
live.draftDone = 0;
|
||||
live.draftTotal = draftTotal;
|
||||
live.etaAtStart = Math.round(draftTotal * secondsPerUnit);
|
||||
live.etaSeconds = live.etaAtStart;
|
||||
}
|
||||
|
||||
/** One burst of the run. Counters JUMP: the contract lets the server coalesce frames. */
|
||||
export function advance(step: number): void {
|
||||
live.revision += 3;
|
||||
live.draftDone = Math.min(draftTotal, live.draftDone + 7 + step * 4);
|
||||
live.etaSeconds = Math.max(0, 9240 - step * 900);
|
||||
live.draftDone = Math.min(live.draftTotal, live.draftDone + 7 + step * 4);
|
||||
// Derived from the progress rather than counted down by a constant: an estimate that falls as the
|
||||
// work is done is what the field means, and it then fits a book of any size.
|
||||
live.etaSeconds =
|
||||
live.etaAtStart === null
|
||||
? null
|
||||
: Math.max(0, Math.round(live.etaAtStart * (1 - live.draftDone / live.draftTotal)));
|
||||
}
|
||||
|
||||
/** The book as it stands right now — what a read must answer while the run is going. */
|
||||
|
|
@ -25,7 +54,7 @@ export function liveBook() {
|
|||
return {
|
||||
...book,
|
||||
progress: {
|
||||
draft: { done: live.draftDone, total: draftTotal },
|
||||
draft: { done: live.draftDone, total: live.draftTotal },
|
||||
edit: book.progress.edit,
|
||||
eta_seconds: live.etaSeconds,
|
||||
},
|
||||
|
|
@ -36,5 +65,7 @@ export function liveBook() {
|
|||
export function rewind(): void {
|
||||
live.revision = 1841;
|
||||
live.draftDone = book.progress.draft.done;
|
||||
live.etaSeconds = book.progress.eta_seconds ?? null;
|
||||
live.draftTotal = book.progress.draft.total;
|
||||
live.etaSeconds = initialEta;
|
||||
live.etaAtStart = initialEta;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// showcase — the hard case: mid draft wave, a half-decided bank, chapters without labels
|
||||
// scale — the long tail: 2284 chapters and 1200 terms, served in pages
|
||||
// empty — a user with no books at all
|
||||
// intake — the first path of the product: upload → parsing → a book that can be translated
|
||||
// loading — reads that never answer, so the waiting branch is the final picture
|
||||
// error — the platform refuses, so the error branch is the final picture
|
||||
// offline — reads work, the live stream does not
|
||||
|
|
@ -13,6 +14,17 @@ import type { Scenario } from '../api';
|
|||
import type { components } from '../api/schema';
|
||||
import { bank } from './bank';
|
||||
import { bookId, books, chapters, notes, run, unitsOf } from './book';
|
||||
import {
|
||||
advanceIntakeChapter,
|
||||
haltIntakeRun,
|
||||
intakeBooks,
|
||||
intakeBounds,
|
||||
intakeCeilingFits,
|
||||
intakeChapters,
|
||||
intakeRun,
|
||||
intakeUnits,
|
||||
startIntakeRun,
|
||||
} from './intake';
|
||||
import { live, liveBook } from './live';
|
||||
import { scaleBook, scaleChapters, scaleNotes, scaleTerms, scaleUnitsOf } from './scale';
|
||||
|
||||
|
|
@ -22,6 +34,12 @@ export interface World {
|
|||
readonly revision: number;
|
||||
readonly books: Schemas['Book'][];
|
||||
runOf: (id: string) => Schemas['Run'] | null;
|
||||
/**
|
||||
* Starting a run. A world where the book comes from the FORM has no run written in advance, so
|
||||
* what "start" means belongs to the world; the hand-written ones hand back the run they always
|
||||
* had, with the ceiling and the stop the request asked for.
|
||||
*/
|
||||
startRun: (id: string, request: Schemas['RunRequest']) => Schemas['Run'] | null;
|
||||
chaptersOf: (id: string) => Schemas['Chapter'][];
|
||||
unitsOf: (bookId: string, chapterId: string) => Schemas['Unit'][];
|
||||
notesOf: (id: string) => Schemas['Note'][];
|
||||
|
|
@ -30,8 +48,52 @@ export interface World {
|
|||
ceiling: Schemas['CeilingBounds'];
|
||||
/** Rows per page. The scale world pages for real, so following the cursor is exercised live. */
|
||||
pageSize: number;
|
||||
/**
|
||||
* Whether a ceiling asked for still fits. Its own answer per world, because the bounds a world
|
||||
* answers a READ with may move before the START — which is the contract's 409 and the one refusal
|
||||
* a correct client meets on a correct form.
|
||||
*/
|
||||
fits?: (chapters: number) => boolean;
|
||||
/** The world accepts uploads. Only the intake one does; elsewhere `POST /books` is not mounted. */
|
||||
intake?: boolean;
|
||||
/**
|
||||
* The run halts on its ceiling at the end of the script. Present only where the whole path is
|
||||
* played out — the reads have to agree with the stream, so the world is told before the frames
|
||||
* announcing the halt are sent.
|
||||
*/
|
||||
halt?: () => void;
|
||||
/**
|
||||
* One chapter of the running book finishes, and the world moves with it. Present in the same
|
||||
* world and for the same reason: the tree's per-chapter indicators have to be fed by something,
|
||||
* and what feeds them must also be what a re-read answers.
|
||||
*/
|
||||
advanceChapter?: () => Schemas['EventChapter'] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting a run in a hand-written world.
|
||||
*
|
||||
* ⚠ A run is MINTED for the book that was asked about, and not taken out of the fixture: on the
|
||||
* platform a start creates a run, so a fixture that answered only for the one book it had written
|
||||
* down refused every other with "book not found" — which is what the new action on the card ran
|
||||
* into on four of the five startable books (found by the adversarial review of this pack). The
|
||||
* book's own run, where the fixture has one, gives the id and the revision; everything else comes
|
||||
* from the request, because that is what a start is.
|
||||
*/
|
||||
const startExisting =
|
||||
(world: Pick<World, 'runOf'>) => (id: string, request: Schemas['RunRequest']) => {
|
||||
const existing = world.runOf(id);
|
||||
return {
|
||||
...(existing ?? run),
|
||||
id: existing?.id ?? `run_${id}`,
|
||||
status: 'translating' as const,
|
||||
paused_reason: null,
|
||||
finished_at: null,
|
||||
ceiling_chapters: request.ceiling_chapters,
|
||||
verify_bank: request.verify_bank,
|
||||
};
|
||||
};
|
||||
|
||||
const pausedRun: Schemas['Run'] = {
|
||||
...run,
|
||||
id: 'run_38',
|
||||
|
|
@ -52,6 +114,7 @@ const defaultWorld: World = {
|
|||
},
|
||||
runOf: (id) =>
|
||||
id === bookId ? { ...run, revision: live.revision } : id === 'bk_7' ? pausedRun : null,
|
||||
startRun: (id, request) => startExisting(defaultWorld)(id, request),
|
||||
chaptersOf: (id) => (id === bookId ? chapters : []),
|
||||
unitsOf: (id, chapterId) => (id === bookId ? unitsOf(chapterId) : []),
|
||||
notesOf: (id) => (id === bookId ? notes : []),
|
||||
|
|
@ -73,6 +136,7 @@ const scaleWorld: World = {
|
|||
revision: 4102,
|
||||
books: [scaleBook],
|
||||
runOf: () => ({ ...run, id: 'run_scale' }),
|
||||
startRun: (id, request) => startExisting(scaleWorld)(id, request),
|
||||
chaptersOf: () => scaleChapters,
|
||||
unitsOf: (_, chapterId) => scaleUnitsOf(chapterId),
|
||||
notesOf: () => scaleNotes,
|
||||
|
|
@ -88,6 +152,7 @@ const emptyWorld: World = {
|
|||
revision: 1,
|
||||
books: [],
|
||||
runOf: () => null,
|
||||
startRun: () => null,
|
||||
chaptersOf: () => [],
|
||||
unitsOf: () => [],
|
||||
notesOf: () => [],
|
||||
|
|
@ -97,10 +162,39 @@ const emptyWorld: World = {
|
|||
pageSize: 5000,
|
||||
};
|
||||
|
||||
// The path the product begins with: an empty library, a book that arrives through the form, gets
|
||||
// cut into chapters and is translated. The only world that accepts an upload.
|
||||
const intakeWorld: World = {
|
||||
get revision() {
|
||||
return live.revision;
|
||||
},
|
||||
get books() {
|
||||
return intakeBooks();
|
||||
},
|
||||
runOf: (id) => intakeRun(id),
|
||||
startRun: (id, request) => startIntakeRun(id, request),
|
||||
chaptersOf: (id) => intakeChapters(id),
|
||||
unitsOf: (id, chapterId) => intakeUnits(id, chapterId),
|
||||
notesOf: () => [],
|
||||
bankOf: () => ({ total: 0, signed: 0, terms: [] }),
|
||||
usage: { state: 'ok', remaining_percent: 100, paused_reason: null },
|
||||
// A getter: these bounds MOVE, once, when the first start meets them — the contract's own 409
|
||||
// (a hold taken for another book lowers what is left between the read and the call).
|
||||
get ceiling() {
|
||||
return intakeBounds();
|
||||
},
|
||||
fits: intakeCeilingFits,
|
||||
pageSize: 5000,
|
||||
intake: true,
|
||||
halt: haltIntakeRun,
|
||||
advanceChapter: advanceIntakeChapter,
|
||||
};
|
||||
|
||||
const worlds: Record<Scenario, World> = {
|
||||
showcase: defaultWorld,
|
||||
scale: scaleWorld,
|
||||
empty: emptyWorld,
|
||||
intake: intakeWorld,
|
||||
loading: defaultWorld,
|
||||
error: defaultWorld,
|
||||
offline: defaultWorld,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
/* The card is a column: what the book is DOING right now stands above the pairs, and the one
|
||||
action of the card between them — a person looks for it where the state was just named. */
|
||||
.card {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* A calm list of "field — value" pairs, not cards with statistics (prompt §3.9). */
|
||||
.about {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { BookDetail } from '../api';
|
||||
import { useText, type MessageKey } from '../i18n/text';
|
||||
import { pausedReason as pausedReasonWords, rejectReason as rejectReasonWords } from '../api';
|
||||
import type { Book, BookDetail, Run } from '../api';
|
||||
import { useText, type Text } from '../i18n/text';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { counted, date, languageName, number, statusOf, translatedPercent } from './format';
|
||||
import { chapters } from './units';
|
||||
import styles from './About.module.css';
|
||||
|
||||
/**
|
||||
|
|
@ -8,20 +12,78 @@ import styles from './About.module.css';
|
|||
* boundary is a defence of the approaches, not a gap: there is no money on the screen (§4.8), and
|
||||
* no models or pipeline stages either.
|
||||
*/
|
||||
/** Plural forms of the chapter counter; Intl picks the category. */
|
||||
const chapters: Record<Intl.LDMLPluralRule, MessageKey> = {
|
||||
zero: 'about.chaptersZero',
|
||||
one: 'about.chaptersOne',
|
||||
two: 'about.chaptersTwo',
|
||||
few: 'about.chaptersFew',
|
||||
many: 'about.chaptersMany',
|
||||
other: 'about.chaptersOther',
|
||||
};
|
||||
|
||||
export function About({ detail }: { detail: BookDetail }) {
|
||||
export function About({ detail, onStartRun }: { detail: BookDetail; onStartRun?: () => void }) {
|
||||
const text = useText();
|
||||
const { book, run } = detail;
|
||||
const fields: [string, string][] = [
|
||||
const startable = statusOf(book.status).startable === true;
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<State book={book} run={run ?? null} />
|
||||
{/* The one action of the book card, and it stands where the card is: a book is translated
|
||||
from the place that says what the book is. It is offered only where a run can begin —
|
||||
which state that is, is decided by the vocabulary on the api seam, in one file. */}
|
||||
{startable && onStartRun !== undefined && (
|
||||
<Button look="primary" onPress={onStartRun}>
|
||||
{text('run.action')}
|
||||
</Button>
|
||||
)}
|
||||
<dl className={styles.about}>
|
||||
{fields(detail, text).map(([name, value]) => (
|
||||
<div className={styles.field} key={name}>
|
||||
<dt className={styles.fieldName}>{name}</dt>
|
||||
<dd className={styles.fieldValue} title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The states a book is IN rather than the fields it has: still arriving, being cut into sections,
|
||||
* refused, halted. Each of them is a state the list of pairs below cannot say anything about, and
|
||||
* each of them is what the person is waiting on.
|
||||
*
|
||||
* ⚠ Parsing carries NO number, and that is the contract's shape and not an omission: there is no
|
||||
* counter of it anywhere on this surface, so a percentage would have to be invented (0.2.3).
|
||||
*/
|
||||
function State({ book, run }: { book: Book; run: Run | null }) {
|
||||
const text = useText();
|
||||
|
||||
if (book.status === 'uploading')
|
||||
return <Callout tone="quiet" caption={text('about.uploading')} />;
|
||||
if (book.status === 'parsing') return <Callout tone="quiet" caption={text('about.parsing')} />;
|
||||
if (book.status === 'rejected') {
|
||||
// The machine reason is the platform's, the phrase and the advice are the product's — and the
|
||||
// advice is the point: "the file is not one we can read" and "not us, not now" end in
|
||||
// different next actions (0.2.3, PD-173).
|
||||
const reason = rejectReasonWords.describe(book.reject_reason ?? null);
|
||||
return (
|
||||
<Callout tone="note" caption={text(reason.label)}>
|
||||
{text(reason.advice)}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
// A halt is stated where the action is, not only in the status strip: the strip says WHAT, the
|
||||
// card is where the person looks for what to do about it. The phrase is the owner's (В-6), and
|
||||
// resume is deliberately not offered — after a ceiling stop it does not move the run at all.
|
||||
if (book.status === 'paused') {
|
||||
return (
|
||||
<Callout
|
||||
tone="note"
|
||||
caption={text(pausedReasonWords.describe(run?.paused_reason ?? null).label)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fields(detail: BookDetail, text: Text): [string, string][] {
|
||||
const { book, run } = detail;
|
||||
const rows: [string, string][] = [
|
||||
[text('about.title'), book.title],
|
||||
[
|
||||
text('about.languages'),
|
||||
|
|
@ -32,12 +94,17 @@ export function About({ detail }: { detail: BookDetail }) {
|
|||
[text('about.translated'), `${String(translatedPercent(book.progress))}%`],
|
||||
[text('about.chapters'), number(book.chapter_count)],
|
||||
[text('about.blocks'), number(book.progress.draft.total)],
|
||||
[text('about.characters'), number(book.character_count ?? 0)],
|
||||
// Optional on the wire, so an absent size is a dash rather than a zero — the same shape the
|
||||
// dates use for "not known".
|
||||
[
|
||||
text('about.characters'),
|
||||
book.character_count === undefined ? '—' : number(book.character_count),
|
||||
],
|
||||
[text('about.notes'), number(book.note_count)],
|
||||
[text('about.added'), date(book.added_at)],
|
||||
];
|
||||
if (run) {
|
||||
fields.push(
|
||||
rows.push(
|
||||
[text('about.runStarted'), date(run.started_at)],
|
||||
// `finished_at` comes both as `null` and as altogether absent: an unfinished run may have no
|
||||
// field at all — both forms mean one and the same thing and are drawn as a dash.
|
||||
|
|
@ -48,17 +115,5 @@ export function About({ detail }: { detail: BookDetail }) {
|
|||
[text('about.verifyBank'), run.verify_bank ? text('about.yes') : text('about.no')],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={styles.about}>
|
||||
{fields.map(([name, value]) => (
|
||||
<div className={styles.field} key={name}>
|
||||
<dt className={styles.fieldName}>{name}</dt>
|
||||
<dd className={styles.fieldValue} title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,73 @@
|
|||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.slots {
|
||||
/* The pair of languages stands in one row: it is one decision — out of what, into what — and split
|
||||
into two rows it reads as two unrelated fields. */
|
||||
.pair {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
/* The live region is a container and nothing more: it takes no space of its own until there is
|
||||
something in it, so an empty one leaves no gap in the form. */
|
||||
.live:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sending,
|
||||
.failed,
|
||||
.accepted {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
.accepted {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.acceptedTitle {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-ui);
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/* A refusal is a state of the form and not an alarm (§3.8): the phrase stands in the ordinary text
|
||||
colour, and the tone of the state is carried by the book's own glyph in the tree — there is no
|
||||
red panel here and no exclamation mark. */
|
||||
.failedTitle {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.slot {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
|
||||
.fieldName {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,76 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { FilePlus2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useText } from '../i18n/text';
|
||||
import { ApiError, UploadAborted, keys, uploadBook } from '../api';
|
||||
import type { Book, BookIntake, UploadProgress } from '../api';
|
||||
import { useText, type MessageKey, type Text } from '../i18n/text';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { ProgressBar } from '../ui/ProgressBar';
|
||||
import { Select } from '../ui/Select';
|
||||
import { TextField } from '../ui/TextField';
|
||||
import { icon } from '../ui/icon';
|
||||
import { fileSize, languageName, number, statusOf } from './format';
|
||||
import { sourceLanguages, targetLanguages } from './languages';
|
||||
import styles from './AddBook.module.css';
|
||||
|
||||
/**
|
||||
* Adding a book — with a modal window after the model of antigravity_add_folder.png (remark 12).
|
||||
* The two sources of the title are told apart: by hand or by parsing the file, and what will happen
|
||||
* is said before the press. The sending itself is the work of the upload screen (Ф-26).
|
||||
* Adding a book: the form, the sending, and what came of it.
|
||||
*
|
||||
* The four states are one screen and not four, because they are one act of the user's: the window
|
||||
* that took the file is the window that says what happened to it. A refusal is a state of this
|
||||
* form — not an alert over it (§3.8: a screen that looks alarming is a wrong screen).
|
||||
*/
|
||||
export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const text = useText();
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const client = useQueryClient();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [sourceLang, setSourceLang] = useState<string>(sourceLanguages[0]);
|
||||
const [targetLang, setTargetLang] = useState<string>(targetLanguages[0]);
|
||||
const [genre, setGenre] = useState('');
|
||||
// `null` means the browser has said nothing about the body going out — which is a state and not
|
||||
// an absence of one (see `Sending`).
|
||||
const [sent, setSent] = useState<UploadProgress | null>(null);
|
||||
// Cancelling is the user's, so the handle to it belongs to the sending and not to the request:
|
||||
// the screen has to be able to stop a body that has minutes left to go.
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
|
||||
const upload = useMutation<Book, Error, BookIntake>({
|
||||
mutationFn: (intake) => {
|
||||
abort.current = new AbortController();
|
||||
setSent(null);
|
||||
return uploadBook(intake, {
|
||||
signal: abort.current.signal,
|
||||
onProgress: setSent,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// The library is the only place the new book can be seen from, and it is read by a query that
|
||||
// considers itself fresh for fifteen seconds. Nothing else would ask it again.
|
||||
void client.invalidateQueries({ queryKey: keys.library() });
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
abort.current?.abort();
|
||||
upload.reset();
|
||||
setFile(null);
|
||||
setTitle('');
|
||||
setGenre('');
|
||||
setSent(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (file === null) return;
|
||||
upload.mutate({ file, sourceLang, targetLang, title, genre });
|
||||
};
|
||||
|
||||
const accepted = upload.data;
|
||||
return (
|
||||
<Modal
|
||||
title={text('addBook.title')}
|
||||
|
|
@ -33,60 +79,224 @@ export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => v
|
|||
footer={
|
||||
<>
|
||||
<Button look="action" onPress={close}>
|
||||
{text('addBook.cancel')}
|
||||
</Button>
|
||||
<Button look="primary" isDisabled={file === null} onPress={close}>
|
||||
{text('addBook.submit')}
|
||||
{text(accepted ? 'action.done' : 'action.cancel')}
|
||||
</Button>
|
||||
{accepted === undefined && (
|
||||
<Button look="primary" isDisabled={file === null || upload.isPending} onPress={send}>
|
||||
{text('addBook.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div>
|
||||
<p className={styles.label}>{text('addBook.fileLabel')}</p>
|
||||
{/* A real file picker, not a drawn one: the file name is needed for real — the automatic
|
||||
parse takes the title out of it, and without a real name that branch cannot be
|
||||
checked by a frame. */}
|
||||
<input
|
||||
className={styles.hidden}
|
||||
ref={input}
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(event) => setFile(event.target.files?.[0]?.name ?? null)}
|
||||
{accepted ? (
|
||||
// The same reason as the live region below: the form is gone and the answer is what
|
||||
// replaced it, so it is announced rather than merely drawn.
|
||||
<div role="status">
|
||||
<Accepted book={accepted} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.form}>
|
||||
<div>
|
||||
<p className={styles.label}>{text('addBook.fileLabel')}</p>
|
||||
{/* A real file picker, not a drawn one: the file name is needed for real — the automatic
|
||||
parse takes the title out of it, and without a real name that branch cannot be
|
||||
checked by a frame. */}
|
||||
<input
|
||||
className={styles.hidden}
|
||||
ref={input}
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(event) => {
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
upload.reset();
|
||||
// The input keeps its value, and an input whose value did not change fires nothing:
|
||||
// picking the SAME file again after a refusal would then be silent, and the refusal
|
||||
// would stay on the screen under a file the person had just re-chosen.
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
look="action"
|
||||
isDisabled={upload.isPending}
|
||||
onPress={() => input.current?.click()}
|
||||
>
|
||||
<FilePlus2 {...icon} />
|
||||
{file === null ? text('addBook.chooseFile') : `${file.name} · ${fileSize(file.size)}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label={text('addBook.titleLabel')}
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
placeholder={file === null ? text('addBook.titlePlaceholder') : parsedTitle(file.name)}
|
||||
hint={title === '' ? text('addBook.titleHintAuto') : text('addBook.titleHintManual')}
|
||||
/>
|
||||
<Button look="action" onPress={() => input.current?.click()}>
|
||||
<FilePlus2 {...icon} />
|
||||
{file ?? text('addBook.chooseFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label={text('addBook.titleLabel')}
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
placeholder={file === null ? text('addBook.titlePlaceholder') : parsedTitle(file)}
|
||||
hint={title === '' ? text('addBook.titleHintAuto') : text('addBook.titleHintManual')}
|
||||
/>
|
||||
<div className={styles.pair}>
|
||||
<Select
|
||||
label={text('addBook.sourceLangLabel')}
|
||||
options={sourceLanguages.map((code) => ({ id: code, label: languageName(code) }))}
|
||||
value={sourceLang}
|
||||
onChange={setSourceLang}
|
||||
/>
|
||||
<Select
|
||||
label={text('addBook.targetLangLabel')}
|
||||
options={targetLanguages.map((code) => ({ id: code, label: languageName(code) }))}
|
||||
value={targetLang}
|
||||
onChange={setTargetLang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The place of the future per-book settings. Named, but not drawn with switches that do
|
||||
not exist: a button without an action is forbidden (Ф-7). */}
|
||||
<div className={styles.slots}>
|
||||
<p className={styles.label}>{text('addBook.settingsLabel')}</p>
|
||||
<p className={styles.slot}>{text('addBook.settingsSlot')}</p>
|
||||
<TextField
|
||||
label={text('addBook.genreLabel')}
|
||||
value={genre}
|
||||
onChange={setGenre}
|
||||
placeholder={text('addBook.genrePlaceholder')}
|
||||
/>
|
||||
|
||||
{/* ⚠ A live region, and it is PERMANENT rather than mounted with its content: what
|
||||
happens after the button is pressed happens away from the focus — the sending, and
|
||||
then either the answer or the refusal — and a reader that is not told is a reader
|
||||
who thinks nothing happened (WCAG 4.1.3). A region inserted together with its text
|
||||
is announced by some readers and not by others; one that was already there is
|
||||
announced by all. */}
|
||||
<div role="status" className={styles.live}>
|
||||
{upload.isPending && <Sending file={file} sent={sent} />}
|
||||
{upload.isError && <Failed error={upload.error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The automatic parse of the title out of the file name — that very second source signed in the
|
||||
* form.
|
||||
* How far the file has got — in three states, and the first of them is the one that is easy to get
|
||||
* wrong.
|
||||
*
|
||||
* ⚠ **Nothing said yet.** The browser reports the progress of a request it sends itself; a request
|
||||
* answered by a service worker it does not — measured under the mock network: `loadstart` arrives
|
||||
* with the right total and not a single `progress` after it. There is then no share to draw, and a
|
||||
* bar sitting at zero for the whole upload would be a number that means nothing, so what is shown
|
||||
* is the size going out and no percentage at all.
|
||||
*
|
||||
* **Going.** A share, and it is the browser's own numbers; the size of the FILE stands in when the
|
||||
* browser states no total of its own — the multipart envelope is tens of bytes against tens of
|
||||
* megabytes.
|
||||
*
|
||||
* **All out.** The last byte leaving is not the answer: the service is still writing the book's row
|
||||
* and cutting nothing yet. Without that line a bar frozen at a hundred per cent reads as a hang.
|
||||
*/
|
||||
function Sending({ file, sent }: { file: File | null; sent: UploadProgress | null }) {
|
||||
const text = useText();
|
||||
const size = file?.size ?? 0;
|
||||
if (sent === null) {
|
||||
return (
|
||||
<div className={styles.sending}>
|
||||
<p className={styles.slot}>{text('addBook.sendingUnknown', { total: fileSize(size) })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const total = sent.total ?? size;
|
||||
const share = total === 0 ? 0 : Math.min(1, sent.sent / total);
|
||||
return (
|
||||
<div className={styles.sending}>
|
||||
<ProgressBar
|
||||
label={text('addBook.sending')}
|
||||
value={share}
|
||||
valueLabel={text('addBook.sendingSize', {
|
||||
sent: fileSize(sent.sent),
|
||||
total: fileSize(total),
|
||||
})}
|
||||
/>
|
||||
{share >= 1 && <p className={styles.slot}>{text('addBook.sent')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What came back, in the words of the contract and no others: the platform answers with the book
|
||||
* card itself, so the confirmation is the same four fields the panel of the book shows — and
|
||||
* `chapter_count` is deliberately not among them. It is zero until the parse ends, and printing a
|
||||
* zero would answer "how many sections" with a lie.
|
||||
*/
|
||||
function Accepted({ book }: { book: Book }) {
|
||||
const text = useText();
|
||||
const fields: [string, string][] = [
|
||||
[text('about.title'), book.title],
|
||||
[
|
||||
text('about.languages'),
|
||||
`${languageName(book.source_lang)} → ${languageName(book.target_lang)}`,
|
||||
],
|
||||
// A dash and not a zero when the platform sent no size: the field is optional on the wire, and
|
||||
// "0 characters" is a number the book does not have.
|
||||
[
|
||||
text('about.characters'),
|
||||
book.character_count === undefined ? '—' : number(book.character_count),
|
||||
],
|
||||
[text('about.status'), text(statusOf(book.status).label)],
|
||||
];
|
||||
return (
|
||||
<div className={styles.accepted}>
|
||||
<h3 className={styles.acceptedTitle}>{text('addBook.acceptedTitle')}</h3>
|
||||
<dl className={styles.fields}>
|
||||
{fields.map(([name, value]) => (
|
||||
<div className={styles.field} key={name}>
|
||||
<dt className={styles.fieldName}>{name}</dt>
|
||||
<dd className={styles.fieldValue}>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className={styles.slot}>{text('addBook.acceptedDescription')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal of the intake, as a state of the form.
|
||||
*
|
||||
* The PHRASE is the platform's when it sent one: `problem+json` carries product language on both
|
||||
* fields by contract, and a second copy of it here would be a second thing to keep in step. What is
|
||||
* ours is the ADVICE — the next action, which the wire does not carry and which is different for
|
||||
* every class: check the file, send it again, or wait.
|
||||
*/
|
||||
function Failed({ error }: { error: Error }) {
|
||||
const text = useText();
|
||||
const [phrase, advice] = refusal(error, text);
|
||||
return (
|
||||
<div className={styles.failed}>
|
||||
<p className={styles.failedTitle}>{phrase}</p>
|
||||
<p className={styles.slot}>{text(advice)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function refusal(error: Error, text: Text): [string, MessageKey] {
|
||||
if (error instanceof UploadAborted) return [text('upload.failedTitle'), 'upload.retryAdvice'];
|
||||
if (!(error instanceof ApiError)) return [text('loaded.unknownError'), 'upload.refusedAdvice'];
|
||||
if (error.status === 0) return [text('loaded.serverUnreachable'), 'upload.retryAdvice'];
|
||||
|
||||
const own: Partial<Record<number, [MessageKey, MessageKey]>> = {
|
||||
400: ['upload.badRequest', 'upload.badRequestAdvice'],
|
||||
404: ['upload.notAccepted', 'upload.notAcceptedAdvice'],
|
||||
408: ['upload.timeout', 'upload.retryAdvice'],
|
||||
413: ['upload.tooLarge', 'upload.tooLargeAdvice'],
|
||||
};
|
||||
const [fallback, advice] = own[error.status] ?? ['upload.refused', 'upload.refusedAdvice'];
|
||||
return [error.problem?.detail ?? error.problem?.title ?? text(fallback), advice];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the book will be called if the field is left empty — that very second source signed in the
|
||||
* form. A PREDICTION of the platform's own `titleFrom`, and it has to be an accurate one: the
|
||||
* platform strips the extension and nothing else (books.go), so a client that also turned
|
||||
* underscores into spaces showed «gu zhen ren» and got back `gu-zhen-ren` (found by the adversarial
|
||||
* review, which checked it against the platform's own test). Deciding the title is not this
|
||||
* function's business — saying what to expect is.
|
||||
*/
|
||||
function parsedTitle(file: string): string {
|
||||
return file
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim();
|
||||
return file.replace(/\.[^.]+$/, '').trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ interface Props {
|
|||
/** The palette's request to show a term. The search string is the bank's own to keep. */
|
||||
request: { term: string; seq: number };
|
||||
onOpenChapter: (chapterId: string) => void;
|
||||
/** Opens the run form. Absent while there is no book to run one over. */
|
||||
onStartRun?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,6 +38,7 @@ export function Context({
|
|||
onTabChange,
|
||||
request,
|
||||
onOpenChapter,
|
||||
onStartRun,
|
||||
}: Props) {
|
||||
const text = useText();
|
||||
// Telling the reader to pick a book from a panel that says "not a single book" is a screen
|
||||
|
|
@ -59,7 +62,7 @@ export function Context({
|
|||
label: text('about.tab'),
|
||||
content: (
|
||||
<Loaded query={book} waiting={text('about.tab')} idle={idle}>
|
||||
{(detail) => <About detail={detail} />}
|
||||
{(detail) => <About detail={detail} onStartRun={onStartRun} />}
|
||||
</Loaded>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* The state tone of a BOOK gives way on the selected row for the same reason and by the same rule:
|
||||
on the blue ground the tones fall to 1.7:1 (danger) — unreadable as a shape, let alone as a
|
||||
meaning. Nothing is lost by it, because the state is carried by the WORD in the badge and colour
|
||||
is the second channel by design; a channel that cannot be read on this ground has nothing to
|
||||
double. */
|
||||
[data-selected] .bookIcon,
|
||||
[data-selected] .bookIcon[data-tone] {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
[data-selected] .progress {
|
||||
background-color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
@ -103,3 +113,12 @@
|
|||
[data-selected] .progressFill[data-done] {
|
||||
background-color: var(--color-text);
|
||||
}
|
||||
|
||||
/* The badge of a BOOK on the same blue: 3.52:1 in the quiet grey against a text threshold of 4.5.
|
||||
⚠ This rule was written once before and removed in S3.7 as dead — the row of a book could not be
|
||||
selected then, because the selection followed the open document. It can now (a click on a book
|
||||
opens its state, the zone's §2), and the rule is alive: axe caught the badge on the very first
|
||||
scene where a book is chosen. */
|
||||
[data-selected] .badge {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { BookIcon, FileText } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Book, Chapter, ChapterList, Library as LibraryData } from '../api';
|
||||
import { useText, type MessageKey, type Text } from '../i18n/text';
|
||||
|
|
@ -105,6 +105,25 @@ function Books({
|
|||
// The chapters of the open book only: a tree that fetched every book's chapters would read the
|
||||
// whole library to draw one row.
|
||||
const rows = useMemo(() => chapters.data?.chapters ?? [], [chapters.data]);
|
||||
// `null` means "the user has not touched the branches yet" — not "everything is closed". The same
|
||||
// idiom the tab row uses for its own first look.
|
||||
const [expanded, setExpanded] = useState<Set<string> | null>(null);
|
||||
const opened = expanded ?? new Set(openBookId === undefined ? [] : [openBookId]);
|
||||
// Two branches open by themselves, and both only ON A CHANGE — added on every render they could
|
||||
// never be folded again. A chapter chosen from OUTSIDE the tree (the go-to palette, a row of the
|
||||
// notes summary) may sit inside a folded group, and a tree that answered a jump by showing
|
||||
// nothing would be a tree that lost the row; and a book just chosen shows what is in it.
|
||||
const [seen, setSeen] = useState({ selectedId, openBookId });
|
||||
if (seen.selectedId !== selectedId || seen.openBookId !== openBookId) {
|
||||
setSeen({ selectedId, openBookId });
|
||||
// ⚠ The BOOK goes with the group, always: a jump from outside the tree into a folded book
|
||||
// opened the group inside it and left the book shut, so the row it jumped to was still not on
|
||||
// the screen (found by the adversarial review, reproduced by rendering).
|
||||
const opening = [groupOf(rows, openBookId, selectedId), openBookId].filter(
|
||||
(id): id is string => id !== undefined && id !== null && !opened.has(id),
|
||||
);
|
||||
if (opening.length > 0) setExpanded(new Set([...opened, ...opening]));
|
||||
}
|
||||
// A book whose chapters did not arrive must not be drawn as a book WITHOUT chapters: no chevron
|
||||
// reads as "this book has none", while the card next to it says 2284. The state goes where the
|
||||
// chapters would have been.
|
||||
|
|
@ -123,16 +142,48 @@ function Books({
|
|||
),
|
||||
[books, rows, openBookId, state, text],
|
||||
);
|
||||
// ⚠ The ids of the folds, kept as a SET rather than recognised by their shape. A row's kind is
|
||||
// decided by what this file MINTED, never by reading a server id: ids are opaque by contract, and
|
||||
// the engine already builds keys of the very shape a pattern would have matched
|
||||
// (`<chapter>:<cut>:<index>`) — a chapter whose id ended that way would have answered a click by
|
||||
// folding a branch that does not exist (found by the adversarial review).
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
items.flatMap((node) =>
|
||||
(node.children ?? [])
|
||||
// A fold is a child that HAS children of its own. Below the threshold the tree is flat
|
||||
// and a book's children are the chapters themselves — taking every child would have
|
||||
// made every chapter a fold, and a click on one would have folded instead of opening
|
||||
// (caught by the scene battery, not by reasoning).
|
||||
.filter((child) => child.children !== undefined)
|
||||
.map((child) => child.id),
|
||||
),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tree
|
||||
label={text('library.treeLabel')}
|
||||
items={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={onOpen}
|
||||
// A row of a GROUP is not a thing to open — it is a fold, and a click on it folds. Without
|
||||
// this the row answered a click with nothing at all, which is what a row that is not a row
|
||||
// looks like (found by the adversarial review).
|
||||
onSelect={(id) => {
|
||||
if (!groups.has(id)) {
|
||||
onOpen(id);
|
||||
return;
|
||||
}
|
||||
const next = new Set(opened);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
setExpanded(next);
|
||||
}}
|
||||
onActivate={onPin}
|
||||
onPreload={onPreload}
|
||||
defaultExpandedIds={openBookId === undefined ? [] : [openBookId]}
|
||||
expandedIds={opened}
|
||||
onExpandedChange={setExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -166,6 +217,33 @@ function bookNode(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many chapters are put into one group, and from how many the tree starts grouping at all.
|
||||
*
|
||||
* Hundreds, and the number is not a taste: a chapter is addressed by its ORDINAL, and a hundred is
|
||||
* the step a person counts them in. The measurement says the same thing from the other side — a
|
||||
* flat tree of 2284 rows costs nothing to draw (the virtualizer keeps 31 of them in the DOM,
|
||||
* BACKLOG Ф-12), so grouping is not bought for the frame rate; it is bought for the scrollbar, on
|
||||
* which one row of 2284 is a third of a pixel and 59 618px of travel separates chapter 1 from
|
||||
* chapter 2284. Twenty-three rows and one fold do the same journey.
|
||||
*
|
||||
* Below the threshold the tree stays FLAT: a book of a dozen chapters gains nothing from a fold
|
||||
* that has to be opened before the book can be read at all.
|
||||
*/
|
||||
const groupSize = 100;
|
||||
const groupFrom = 200;
|
||||
|
||||
/** The id of a fold. Minted here and recognised by membership, never by its shape (see `groups`). */
|
||||
const groupId = (bookId: string, from: number) => `${bookId}:${String(from)}`;
|
||||
|
||||
/** The group a chapter sits in, or `null` when the tree is flat or the id is not a chapter. */
|
||||
function groupOf(chapters: Chapter[], bookId: string | undefined, chapterId: string | undefined) {
|
||||
if (chapters.length <= groupFrom || bookId === undefined || chapterId === undefined) return null;
|
||||
const position = chapters.findIndex((chapter) => chapter.id === chapterId);
|
||||
if (position < 0) return null;
|
||||
return groupId(bookId, position - (position % groupSize));
|
||||
}
|
||||
|
||||
// One row saying what happened, in the place the sections would occupy. Not selectable: the screen
|
||||
// only opens ids it found among the chapters.
|
||||
function childrenOf(
|
||||
|
|
@ -175,9 +253,21 @@ function childrenOf(
|
|||
text: Text,
|
||||
): TreeNode[] | undefined {
|
||||
if (chaptersState !== null) return [{ id: `${book.id}:chapters-state`, title: chaptersState }];
|
||||
return chapters.length > 0
|
||||
? chapters.map((chapter, index) => chapterNode(chapter, index, text))
|
||||
: undefined;
|
||||
if (chapters.length === 0) return undefined;
|
||||
if (chapters.length <= groupFrom) {
|
||||
return chapters.map((chapter, index) => chapterNode(chapter, index, text));
|
||||
}
|
||||
return Array.from({ length: Math.ceil(chapters.length / groupSize) }, (_, group) => {
|
||||
const from = group * groupSize;
|
||||
const inside = chapters.slice(from, from + groupSize);
|
||||
return {
|
||||
id: groupId(book.id, from),
|
||||
// The range is of POSITIONS in reading order and not of the numbers printed on the chapters:
|
||||
// a book legally has no numbering at all, and half of one is legal too (K-3).
|
||||
title: text('library.chapterRange', { from: from + 1, to: from + inside.length }),
|
||||
children: inside.map((chapter, index) => chapterNode(chapter, from + index, text)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function chapterNode(chapter: Chapter, index: number, text: Text): TreeNode {
|
||||
|
|
|
|||
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 { Library } from './Library';
|
||||
import type { Query } from './Loaded';
|
||||
import { RunStart } from './RunStart';
|
||||
import { Settings } from './Settings';
|
||||
import { Status } from './Status';
|
||||
import {
|
||||
|
|
@ -28,10 +29,11 @@ import {
|
|||
selectDocument,
|
||||
} from './documents';
|
||||
import { chapterLabel, isDraft } from './format';
|
||||
import { useIntakeEnd } from './useIntakeEnd';
|
||||
import { useRunStream } from './useRunStream';
|
||||
import { useScreenshotFlag } from './useScreenshotFlag';
|
||||
|
||||
type Overlay = 'settings' | 'add-book' | 'goto' | null;
|
||||
type Overlay = 'settings' | 'add-book' | 'goto' | 'run' | null;
|
||||
|
||||
/**
|
||||
* The showcase, now on the data layer. One screen for every scenario: what changes between routes
|
||||
|
|
@ -41,9 +43,14 @@ type Overlay = 'settings' | 'add-book' | 'goto' | null;
|
|||
export function Showcase() {
|
||||
const text = useText();
|
||||
const library = useQuery(libraryQuery());
|
||||
// The library is the entry: which book is open is a consequence of what the platform returned,
|
||||
// not a constant of the fixture.
|
||||
const bookId = library.data?.books[0]?.id;
|
||||
// Which book is open: the one chosen in the tree, and the first the platform returned until
|
||||
// something is chosen (the zone's §2 — "a click on a book opens its state"). Checked against the
|
||||
// library on every render rather than reset by an effect: a chosen book can DISAPPEAR from the
|
||||
// library, and a screen holding an id that is no longer there reads every panel as a failure.
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
const books = library.data?.books;
|
||||
const bookId =
|
||||
chosen !== null && books?.some((row) => row.id === chosen) === true ? chosen : books?.[0]?.id;
|
||||
const enabled = bookId !== undefined;
|
||||
|
||||
const book = useQuery({ ...bookQuery(bookId ?? ''), enabled });
|
||||
|
|
@ -65,6 +72,7 @@ export function Showcase() {
|
|||
};
|
||||
|
||||
const connection = useRunStream(bookId, book.data?.run?.id);
|
||||
useIntakeEnd(bookId, book.data?.book.status);
|
||||
const expandPanel = useLayout((state) => state.expand);
|
||||
|
||||
const [overlay, setOverlay] = useState<Overlay>(null);
|
||||
|
|
@ -86,8 +94,19 @@ export function Showcase() {
|
|||
// The guard stands HERE and not at every caller: the tabs are built only out of chapters that
|
||||
// exist, so a selected id that is not among the chapters leaves the controlled tab row with no
|
||||
// selected tab — an empty centre without a single word (the notes summary did exactly that).
|
||||
//
|
||||
// A row of the tree is a chapter OR a book, and the two are told apart by where the id is found.
|
||||
// Choosing a book closes the open documents: they are chapters of the book being left, and
|
||||
// keeping them would leave tabs pointing into a tree that no longer holds them.
|
||||
const open = (id: string) => {
|
||||
if (positionOf(id) >= 0) setOpened(previewDocument(documents, id));
|
||||
if (positionOf(id) >= 0) {
|
||||
setOpened(previewDocument(documents, id));
|
||||
return;
|
||||
}
|
||||
if (books?.some((row) => row.id === id) === true && id !== bookId) {
|
||||
setChosen(id);
|
||||
setOpened(null);
|
||||
}
|
||||
};
|
||||
const pin = (id: string) => {
|
||||
if (positionOf(id) >= 0) setOpened(pinDocument(documents, id));
|
||||
|
|
@ -131,7 +150,7 @@ export function Showcase() {
|
|||
<>
|
||||
<ScreenshotFlag />
|
||||
<Shell
|
||||
title={book.data?.book.title ?? 'TextMachine'}
|
||||
title={book.data?.book.title ?? text('shell.appName')}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
|
|
@ -157,7 +176,9 @@ export function Showcase() {
|
|||
library={library}
|
||||
chapters={awaiting(chapters)}
|
||||
openBookId={bookId}
|
||||
selectedId={documents.active}
|
||||
// The open CHAPTER while there is one, and the open book otherwise: a book whose
|
||||
// sections nobody has opened is still the book the panels are showing.
|
||||
selectedId={documents.active === '' ? bookId : documents.active}
|
||||
onOpen={open}
|
||||
onPin={pin}
|
||||
onPreload={preload}
|
||||
|
|
@ -188,6 +209,7 @@ export function Showcase() {
|
|||
onTabChange={setContextTab}
|
||||
request={bankRequest}
|
||||
onOpenChapter={open}
|
||||
onStartRun={enabled ? () => setOverlay('run') : undefined}
|
||||
/>
|
||||
}
|
||||
status={
|
||||
|
|
@ -201,6 +223,9 @@ export function Showcase() {
|
|||
/>
|
||||
<Settings isOpen={overlay === 'settings'} onClose={() => setOverlay(null)} />
|
||||
<AddBook isOpen={overlay === 'add-book'} onClose={() => setOverlay(null)} />
|
||||
{bookId !== undefined && (
|
||||
<RunStart bookId={bookId} isOpen={overlay === 'run'} onClose={() => setOverlay(null)} />
|
||||
)}
|
||||
<Goto
|
||||
isOpen={overlay === 'goto'}
|
||||
onClose={() => setOverlay(null)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ const build = (locale: string) => ({
|
|||
languages: new Intl.DisplayNames([locale], { type: 'language' }),
|
||||
numbers: new Intl.NumberFormat(locale),
|
||||
plurals: new Intl.PluralRules(locale),
|
||||
// The unit is formatted by Intl and not by us: "MB" is a word, and a word written in the code is
|
||||
// a word no translation file can reach. One formatter per magnitude, because the unit is part of
|
||||
// the formatter rather than of the value.
|
||||
bytes: new Map(
|
||||
(['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const).map((unit) => [
|
||||
unit,
|
||||
new Intl.NumberFormat(locale, { style: 'unit', unit, maximumFractionDigits: 1 }),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
function intl() {
|
||||
|
|
@ -30,6 +39,27 @@ export const date = (value: string) => new Date(value).toLocaleDateString(curren
|
|||
export const languageName = (code: string) => intl().languages.of(code) ?? code;
|
||||
export const number = (value: number) => intl().numbers.format(value);
|
||||
|
||||
/**
|
||||
* A size of a file, in the unit that suits it. Decimal steps of a thousand and not 1024: that is
|
||||
* what the operating system's file manager shows the same file as, and a size the interface states
|
||||
* differently from the folder the file was picked from reads as a defect.
|
||||
*/
|
||||
export function fileSize(bytes: number): string {
|
||||
const bare = { unit: 'byte', factor: 1 } as const;
|
||||
const steps = [
|
||||
{ unit: 'gigabyte', factor: 1e9 },
|
||||
{ unit: 'megabyte', factor: 1e6 },
|
||||
{ unit: 'kilobyte', factor: 1e3 },
|
||||
bare,
|
||||
] as const;
|
||||
const step = steps.find(({ factor }) => bytes >= factor) ?? bare;
|
||||
return (
|
||||
intl()
|
||||
.bytes.get(step.unit)
|
||||
?.format(bytes / step.factor) ?? String(bytes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number with the word it counts: "1 term", "2 terms". The caller names the forms — as catalogue
|
||||
* keys, so the words stay in the catalogue — and `Intl` picks the category; no table of endings is
|
||||
|
|
|
|||
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 { render, waitFor } from '@testing-library/react';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { HttpResponse, delay, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from 'vitest';
|
||||
|
||||
|
|
@ -281,6 +281,37 @@ test('a status frame re-reads the text of every open chapter', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
// S4. The same status stands in TWO answers — the book's card and the row of the library — and the
|
||||
// tree's badge is the one the eye reads first. Patching only the card left the badge saying "queued"
|
||||
// under a run that had already halted (seen on the frame of a ceiling stop).
|
||||
// Acceptance of S3.7, finding 8: the counters of every chapter move at a stage boundary too, and
|
||||
// the frame that would patch a single row may not arrive at all while `units_done` is one counter.
|
||||
test('a status frame re-reads the chapter list, whose counters move at the same boundary', async () => {
|
||||
const client = mount();
|
||||
await settled(client);
|
||||
expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(1);
|
||||
|
||||
hello();
|
||||
emit('status', { status: 'awaiting_bank', paused_reason: null }, 1850);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(askedFor(`/v0/books/${bookId}/chapters`)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('a status frame re-reads the library, where the same status is a row', async () => {
|
||||
const client = mount();
|
||||
await settled(client);
|
||||
expect(askedFor('/v0/books')).toBe(1);
|
||||
|
||||
hello();
|
||||
emit('status', { status: 'paused', paused_reason: 'credit_exhausted' }, 1850);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(askedFor('/v0/books')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Ф-50. The window is every screen start and every resync: the frame arrives while the first read
|
||||
// is still in flight, so there is nothing in the cache to patch — and the stream has already moved
|
||||
// its high-water mark, so the answer prepared BEFORE the frame is taken as fresh and the frame is
|
||||
|
|
@ -328,6 +359,78 @@ test('a burst of frames on an empty cache costs one re-read, not one per frame',
|
|||
expect(askedFor(`/v0/books/${bookId}`)).toBe(2);
|
||||
});
|
||||
|
||||
// Acceptance of S3.7, finding 2: the mark says "a re-read of this key is on its way", and it was
|
||||
// never taken off. A read that FAILS leaves the entry empty, so from then on every frame for that
|
||||
// key found the mark and returned — the key was silenced for the rest of the run, on a screen whose
|
||||
// reads had recovered long ago.
|
||||
test('a key whose re-read failed is not silenced for the rest of the run', async () => {
|
||||
let refuse = true;
|
||||
server.use(
|
||||
http.get('*/v0/books/:bookId/bank', () => {
|
||||
if (refuse) return HttpResponse.json({ title: 'no' }, { status: 503 });
|
||||
return HttpResponse.json<Schemas['Bank']>({
|
||||
revision,
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
signed: 1,
|
||||
terms: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const client = mount();
|
||||
await settled(client);
|
||||
const before = askedFor(`/v0/books/${bookId}/bank`);
|
||||
|
||||
hello();
|
||||
emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850);
|
||||
await settled(client);
|
||||
refuse = false;
|
||||
|
||||
// The second frame has to reach the network. On the pre-fix code the mark from the first one was
|
||||
// still there, and this read never happened.
|
||||
emit('bank', { total: 60, signed: 13, pending_decisions: 47 }, 1851);
|
||||
await waitFor(() => {
|
||||
expect(askedFor(`/v0/books/${bookId}/bank`)).toBeGreaterThan(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// Acceptance of S3.7, finding 3: the marks belong to an epoch. A resync throws the snapshots away,
|
||||
// and a mark left from before it eats the first frame of the new epoch — exactly in the window
|
||||
// where the client has nothing else to go on.
|
||||
test('a resync clears the marks with the snapshots', async () => {
|
||||
// The read is SLOW on purpose: the window this guards is the one where the resync's own re-read
|
||||
// has not landed yet, so the cache is empty and the mark is the only thing deciding. With a fast
|
||||
// read the data arrives first and the mark is bypassed — the test would pass on the defect.
|
||||
server.use(
|
||||
http.get('*/v0/books/:bookId/bank', async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json<Schemas['Bank']>({
|
||||
revision,
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
signed: 1,
|
||||
terms: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const client = mount();
|
||||
|
||||
hello();
|
||||
// NOT settled first, and that is the point: the first read is still in flight, so this frame
|
||||
// finds no snapshot to patch, leaves a mark and restarts the read.
|
||||
emit('bank', { total: 60, signed: 12, pending_decisions: 48 }, 1850);
|
||||
emit('resync_required', { reason: 'the book was rebuilt' }, 12);
|
||||
const before = askedFor(`/v0/books/${bookId}/bank`);
|
||||
|
||||
// Inside the window: the re-read is still in flight, the cache is empty, and this frame must
|
||||
// still reach the network. On the pre-fix code the mark of the dead epoch ate it.
|
||||
emit('bank', { total: 60, signed: 13, pending_decisions: 47 }, 13);
|
||||
await waitFor(() => {
|
||||
expect(askedFor(`/v0/books/${bookId}/bank`)).toBeGreaterThan(before);
|
||||
});
|
||||
await settled(client);
|
||||
});
|
||||
|
||||
// Ф-51. A resync follows a FULL replacement, where the revision is not promised to keep growing —
|
||||
// so the snapshot's revision has to go with the snapshot.
|
||||
test('a resync drops the book from the cache, marks and all', async () => {
|
||||
|
|
@ -350,7 +453,21 @@ test('a resync drops the book from the cache, marks and all', async () => {
|
|||
// The cost the resync must NOT pay: the run id travels inside the book detail, so emptying that
|
||||
// detail took the subscription down with it and opened a second connection. Found by the
|
||||
// adversarial review of this very fix, not by the fix's own tests.
|
||||
test('a resync does not restart the live stream', async () => {
|
||||
//
|
||||
// ⚠ The read is SLOW here, and that is the acceptance of S3.7 talking (finding 3): with an instant
|
||||
// answer the observer may never render between the reset and the new data, so the test would be
|
||||
// green on a stream that does restart. The window is opened on purpose and measured inside it.
|
||||
test('a resync does not restart the live stream, even while the re-read is in flight', async () => {
|
||||
server.use(
|
||||
http.get('*/v0/books/:bookId', async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json<Schemas['BookDetail']>({
|
||||
revision,
|
||||
book,
|
||||
run: { ...run, revision },
|
||||
});
|
||||
}),
|
||||
);
|
||||
const client = mount([], <WiredScreen />);
|
||||
await settled(client);
|
||||
expect(FakeEventSource.opened).toBe(1);
|
||||
|
|
@ -358,6 +475,12 @@ test('a resync does not restart the live stream', async () => {
|
|||
hello();
|
||||
emit('resync_required', { reason: 'the book was rebuilt' }, 12);
|
||||
|
||||
// Inside the window: the card is empty and the answer has not come back yet.
|
||||
await waitFor(() => {
|
||||
expect(client.getQueryData<BookDetail>(keys.book(bookId))).toBeUndefined();
|
||||
});
|
||||
expect(FakeEventSource.opened).toBe(1);
|
||||
|
||||
await settled(client);
|
||||
await waitFor(() => {
|
||||
expect(client.getQueryData<BookDetail>(keys.book(bookId))?.revision).toBe(12);
|
||||
|
|
|
|||
|
|
@ -15,8 +15,31 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
|
|||
const client = useQueryClient();
|
||||
const [state, setState] = useState<ConnectionState>('connecting');
|
||||
|
||||
// ⚠ The run id travels INSIDE the book card, and a resync empties that card for the length of one
|
||||
// read — so for that moment the caller has no id to give, and the subscription would be torn down
|
||||
// and opened again over a gap in the data that merely DESCRIBES it. Measured, not reasoned:
|
||||
// with an answer that takes 120 ms the stream opened twice (the acceptance of S3.7 asked for
|
||||
// exactly this measurement, and the earlier claim that it does not happen was taken against an
|
||||
// instant mock).
|
||||
//
|
||||
// Held per BOOK: another book is another stream, and there the id must not survive. Legal by the
|
||||
// contract as well — `run` is "the current or last run", so once a book has one it never goes
|
||||
// back to none.
|
||||
// State and not a ref, although a ref is what this looks like: a ref may not be read while
|
||||
// rendering (`react-hooks/refs`), and the value is needed to decide what to render.
|
||||
const [held, setHeld] = useState<{ bookId?: Id; runId?: Id }>({});
|
||||
if (held.bookId !== bookId) {
|
||||
setHeld({ bookId, runId });
|
||||
// The state belongs to the stream it was measured on. Left alone, the first frame painted for
|
||||
// another book carried the previous book's verdict — a status strip saying "live progress is
|
||||
// unavailable" about a connection that had not been opened yet.
|
||||
setState('connecting');
|
||||
} else if (runId !== undefined && runId !== held.runId) setHeld({ bookId, runId });
|
||||
const streamId = runId ?? held.runId;
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId === undefined || runId === undefined) return;
|
||||
if (bookId === undefined || streamId === undefined) return;
|
||||
const runId = streamId;
|
||||
|
||||
// Keys whose re-read is already on its way. Per subscription rather than per module: it dies
|
||||
// with the stream it belongs to, and nothing leaks into the next book.
|
||||
|
|
@ -30,6 +53,11 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
|
|||
apply(frame, revision, bookId, client, asked);
|
||||
},
|
||||
onResync: () => {
|
||||
// The marks go with the snapshots. They say "a re-read of this key is already on its way",
|
||||
// and after a full replacement that sentence is about an epoch that no longer exists — a
|
||||
// frame arriving in the window right after a resync would be eaten by a mark left from
|
||||
// before it (acceptance of S3.7).
|
||||
asked.clear();
|
||||
// Replaying history is forbidden by the contract, so the only correct answer to "I cannot
|
||||
// resume you" is to read the snapshots again.
|
||||
//
|
||||
|
|
@ -42,13 +70,13 @@ export function useRunStream(bookId: Id | undefined, runId: Id | undefined): Con
|
|||
void client.resetQueries(bookScope(bookId));
|
||||
},
|
||||
});
|
||||
}, [bookId, runId, client]);
|
||||
}, [bookId, streamId, client]);
|
||||
|
||||
// Without a run there is no channel, and the answer says so rather than keeping the last word of
|
||||
// a stream nobody holds any more — the status strip would go on claiming a live connection
|
||||
// (named by the adversarial review). Derived, not stored: state written from an effect is a
|
||||
// cascade of renders, and there is nothing here to store that the arguments do not already say.
|
||||
return bookId === undefined || runId === undefined ? 'connecting' : state;
|
||||
return bookId === undefined || streamId === undefined ? 'connecting' : state;
|
||||
}
|
||||
|
||||
type Client = ReturnType<typeof useQueryClient>;
|
||||
|
|
@ -81,6 +109,18 @@ function apply(
|
|||
// would ever ask for it again. Whole-book scope on purpose: while `units_done` stays a
|
||||
// single counter (K-10) a chapter frame may not arrive once in a whole draft wave.
|
||||
rereadAll(client, keys.bookUnits(bookId), asked);
|
||||
// The chapter LIST goes stale at the same boundary and for the same reason (the acceptance of
|
||||
// S3.7 turned Ф-49's own premise back on it): every row of it carries `units_done` and
|
||||
// `note_count`, a stage boundary moves them all, and the per-chapter frame that would patch
|
||||
// them may not arrive once in a whole wave while `units_done` stays a single counter (K-10).
|
||||
reread(client, keys.chapters(bookId), asked);
|
||||
// And the LIBRARY, which carries the same status in its own list: the tree's badge is the
|
||||
// main carrier of the state (a word, not a dot), and it went on saying "queued" while the run
|
||||
// it names was already halted — seen on the frame of the ceiling stop. Re-read and never
|
||||
// patched: the library is a scope of its own with a revision of its own, and stamping it with
|
||||
// the book's number would be comparing two counters that are not comparable (contract,
|
||||
// `Revision`).
|
||||
reread(client, keys.library(), asked);
|
||||
return;
|
||||
|
||||
case 'chapter':
|
||||
|
|
@ -134,6 +174,12 @@ function apply(
|
|||
* five frames on an empty entry cost six reads without it and two with it, and the server MAY
|
||||
* coalesce, so bursts are the normal shape of a run. What the restarted read misses of a burst the
|
||||
* next frame patches, because by then the entry is no longer empty.
|
||||
*
|
||||
* ⚠ The mark lives exactly as long as the read it stands for, and not a moment longer. Set and left
|
||||
* behind — which is what it was — it says "on its way" about a read that ENDED: a read that failed
|
||||
* leaves the entry empty, so every later frame for that key found the mark and returned, and the
|
||||
* key was silenced for the rest of the run (acceptance of S3.7). A reset that starts no read at all
|
||||
* — a key nobody is watching — resolves at once and clears it just the same.
|
||||
*/
|
||||
function reread(client: Client, key: readonly unknown[], asked: Set<string>): void {
|
||||
const mark = key.join('/');
|
||||
|
|
@ -144,7 +190,7 @@ function reread(client: Client, key: readonly unknown[], asked: Set<string>): vo
|
|||
}
|
||||
if (asked.has(mark)) return;
|
||||
asked.add(mark);
|
||||
void client.resetQueries({ queryKey: key });
|
||||
void client.resetQueries({ queryKey: key }).finally(() => asked.delete(mark));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@
|
|||
the panel and the non-text floor is 3:1 (measured, S3.7) */
|
||||
--color-ok: #3c7d4f;
|
||||
--color-warn: #b0863a;
|
||||
--color-danger: #b82e45;
|
||||
--color-danger: #bd2f47; /* fleet #b82e45, lifted: verbatim it gives 2.97:1 on the panel and the
|
||||
non-text floor is 3:1 — the same lift --color-note took (Ф-52) */
|
||||
--color-separator: #262626; /* rules, and the quiet severity step of a note */
|
||||
--color-scrollbar-thumb: #333;
|
||||
|
||||
|
|
@ -53,6 +54,13 @@
|
|||
--radius-panel: 6px; /* the corner comes out onto the straight line after 12 phys. */
|
||||
--radius-control: 6px;
|
||||
--radius-bar: 2px; /* the progress bar of a chapter */
|
||||
--radius-round: 50%; /* the thumb of the ceiling scale, the one round thing in the shell */
|
||||
|
||||
/* Form controls of the intake and the run form. Both are drawn by us rather than by the browser:
|
||||
a native checkbox and a native range are painted by the operating system, that is, outside the
|
||||
tokens. */
|
||||
--checkbox-size: 14px;
|
||||
--thumb-size: 12px;
|
||||
|
||||
/* Re-measurement S3.5: in Fleet the bars are pressed to the edges of the window, the content is
|
||||
centred within the bar itself. The former split "8 of margin + 28 of bar" kept the text at the
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ const measured: Record<string, string> = {
|
|||
'--color-selection': '#164e8d',
|
||||
'--color-tab-active': '#142f4c',
|
||||
'--color-accent': '#746deb',
|
||||
'--color-danger': '#b82e45',
|
||||
'--color-ok': '#3c7d4f',
|
||||
'--gap': '8px',
|
||||
'--radius-panel': '6px',
|
||||
|
|
@ -54,6 +53,7 @@ const toned: Record<string, string> = {
|
|||
'--color-raised': '#212121', // fleet_2 #292929 is the strip; hover sits below it
|
||||
'--color-selected': '#1c4478', // fleet_2 selected row #184176
|
||||
'--color-note': '#2a66b0', // antigravity NOTE #2964ad, lifted to the 3:1 non-text floor
|
||||
'--color-danger': '#bd2f47', // fleet #b82e45, lifted to the same floor (Ф-52)
|
||||
'--color-warn': '#b0863a', // no amber in any reference; between note and danger
|
||||
'--color-text': '#dedede', // fleet #dfe1e3, neutralised
|
||||
'--color-text-secondary': '#9b9b9b', // fleet #8a8e91, neutralised and lifted for WCAG
|
||||
|
|
@ -73,6 +73,9 @@ const chosen = [
|
|||
'--color-border',
|
||||
'--radius-control',
|
||||
'--radius-bar',
|
||||
'--radius-round',
|
||||
'--checkbox-size',
|
||||
'--thumb-size',
|
||||
'--panel-side-min',
|
||||
'--panel-side-max',
|
||||
'--row-inset',
|
||||
|
|
@ -149,6 +152,23 @@ describe('tokens.css', () => {
|
|||
expect(stale, 'a token that is no longer in tokens.css is left in the list').toEqual([]);
|
||||
});
|
||||
|
||||
// Ф-52 closed as a RULE and not as one edited number. Colour means state here (prompt §4.2), and
|
||||
// a state tone is carried by a glyph or a strip — non-text, whose WCAG floor is 3:1. Two tones
|
||||
// sat just under it (`--color-note` 2.98, `--color-danger` 2.97) and each was found by hand, one
|
||||
// session apart; axe cannot find them at all, because it does not check non-text contrast.
|
||||
it.each(['--color-note', '--color-ok', '--color-warn', '--color-danger'])(
|
||||
'%s reaches the 3:1 non-text floor on the panel canvas',
|
||||
(name) => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const tone = style.getPropertyValue(name).trim();
|
||||
const panel = style.getPropertyValue('--color-panel').trim();
|
||||
// Compared AS MEASURED: rounding to two places first would accept 2.995 as three (named by
|
||||
// the adversarial review — no token stands there today, but a gate that rounds up is a gate
|
||||
// with a hole in it).
|
||||
expect(contrast(tone, panel)).toBeGreaterThanOrEqual(3);
|
||||
},
|
||||
);
|
||||
|
||||
it('the cascade order is declared explicitly and before the first layer', () => {
|
||||
expect(reset.indexOf('@layer reset, vendor;')).toBeGreaterThanOrEqual(0);
|
||||
expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {'));
|
||||
|
|
@ -162,9 +182,11 @@ describe('tokens.css', () => {
|
|||
const declared = new Set(
|
||||
tokens.match(/^\s*(--[\w-]+)\s*:/gm)?.map((m) => m.trim().slice(0, -1)),
|
||||
);
|
||||
// Not tokens but inputs of a component: they are set on the element through style. The list is
|
||||
// explicit so that "the variable is simply not declared" does not hide behind a general rule.
|
||||
const local = new Set(['--progress']);
|
||||
// Not tokens but inputs of a component: `--progress` is set on the element through style, and
|
||||
// `--trigger-width` is written by the primitives library on its own popup (the width of the
|
||||
// control it hangs under). The list is explicit so that "the variable is simply not declared"
|
||||
// does not hide behind a general rule.
|
||||
const local = new Set(['--progress', '--trigger-width']);
|
||||
|
||||
const used = new Set<string>();
|
||||
for (const source of Object.values(styleSheets)) {
|
||||
|
|
@ -200,6 +222,23 @@ describe('tokens.css', () => {
|
|||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WCAG 2.x relative luminance and the contrast of two `#rrggbb` colours. Written out rather than
|
||||
* taken from a package: it is nine lines of arithmetic from the specification, and the same nine
|
||||
* already stand in the scene harness, where they run inside the browser over COMPUTED colours.
|
||||
*/
|
||||
function contrast(a: string, b: string): number {
|
||||
const luminance = (hex: string) => {
|
||||
const channel = (from: number) => {
|
||||
const value = Number.parseInt(hex.slice(from, from + 2), 16) / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||
};
|
||||
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
}
|
||||
|
||||
// Declared deliberately and not applied yet. The list is short and by name by construction: the
|
||||
// BACKLOG Ф-20 line holds the decision "either the monospace finds work in the S6 reader, or the
|
||||
// import and the tokens go", and until that decision the tones stand in reserve rather than
|
||||
|
|
|
|||
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.
|
||||
*/
|
||||
onPreload?: (id: string) => void;
|
||||
defaultExpandedIds?: string[];
|
||||
/**
|
||||
* Which branches are open. CONTROLLED, because the tree is not the only thing that opens them:
|
||||
* a chapter chosen in the go-to palette may sit inside a folded group, and an uncontrolled tree
|
||||
* has no way of being told about it.
|
||||
*/
|
||||
expandedIds: Set<string>;
|
||||
onExpandedChange: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -67,7 +73,8 @@ export function Tree({
|
|||
onSelect,
|
||||
onActivate,
|
||||
onPreload,
|
||||
defaultExpandedIds,
|
||||
expandedIds,
|
||||
onExpandedChange,
|
||||
}: Props) {
|
||||
// The level indent comes off the library's own data-level, so nesting is not counted by hand.
|
||||
const renderNode = (node: TreeNode) => (
|
||||
|
|
@ -133,7 +140,10 @@ export function Tree({
|
|||
onSelectionChange={(keys) => {
|
||||
if (keys !== 'all') for (const key of keys) onSelect(String(key));
|
||||
}}
|
||||
defaultExpandedKeys={defaultExpandedIds}
|
||||
expandedKeys={expandedIds}
|
||||
onExpandedChange={(keys) => {
|
||||
onExpandedChange(new Set([...keys].map(String)));
|
||||
}}
|
||||
>
|
||||
{renderNode}
|
||||
</AriaTree>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue