Land frontend S3.6 second-round fixpack: neutral palette from references, tabs returned to fleet pill, bank as measured table, vojo drag handle; acceptance record, S3.7 debt prompt, rows F-43..F-51

This commit is contained in:
heaven 2026-08-10 22:30:45 +03:00
parent 90c23cbb2e
commit 57e6bd1d63
73 changed files with 5062 additions and 597 deletions

4
frontend/.gitignore vendored
View file

@ -4,3 +4,7 @@ dist/
# Снимки скриншот-цикла и системные библиотеки Chromium — бинарники в репозиторий не едут.
.shots/
.tooling/
# Референсы владельца — чужие скриншоты (JetBrains Fleet, Antigravity), в репозиторий не едут;
# кладёт их на диск владелец, процедура — docs/FRONTEND_PLAN.md §4.
references/

View file

@ -1,22 +1,29 @@
# frontend — веб-интерфейс
Зона записи сессий «Фронт». Пройдены S0 (план), S1 (инструменты, скриншот-цикл, токены, витрина)
и S2 (оболочка трёх панелей и слой `src/ui/`). Продуктовых экранов ещё нет — они на S4S7
(`docs/BACKLOG.md`, Ф-1); вход в S3 заблокирован отсутствием контракта API v0.
Зона записи сессий «Фронт». Пройдены S0 (план), S1 (инструменты, скриншот-цикл, токены, витрина),
S2 (оболочка трёх панелей и слой `src/ui/`), S3 (слой данных на контракте API) и S3.5 (фикс-пак
оболочки по 24 замечаниям владельца). Продуктовых экранов ещё нет — они на S4S7
(`docs/BACKLOG.md`, Ф-1).
## Как запустить
```bash
npm install
npm run dev # http://localhost:5173/showcase
npm run check # prettier → eslint → stylelint → tsc → vitest
npm run check # prettier → eslint → stylelint → contract → tsc → vitest
npm run check:full # + сборка + скриншоты
npm run shot # снимки в .shots/ — открыть и посмотреть глазами
npm run scenes # сценарии интеракций: клик → кадр → проверка состояния
```
Маршрутов два, и оба снимаются: `/showcase` — оболочка на фикстуре одной главы, `/scale` — она же
на настоящем масштабе книги (2284 раздела, 1200 терминов). Новый экран заводится вместе с маршрутом
и строкой в `scripts/shot.mjs` — расхождение двух списков падает тестом.
Маршрутов семь, и все снимаются (`/showcase` `/scale` `/empty` `/loading` `/error` `/offline`
`/partial`) — маршрут выбирает мир фикстур. Новый экран заводится вместе с маршрутом и строкой
в `scripts/shot.mjs` — расхождение двух списков падает тестом.
Два ключа снимка стоит знать: `--size 1280x764` даёт вьюпорт референса для прямого наложения,
`--dpr 1` снимает то, что видит владелец на своём мониторе (при дефолтных 2x полупиксель CSS
ложится в целый пиксель устройства, и мыло на штрихах иконок в кадр не попадает вовсе).
Замер кадра — `python3 scripts/measure.py references/fleet.png .shots/showcase.png`.
Для скриншот-цикла нужен Chromium Playwright и локальные библиотеки в `.tooling/`
(ставятся без sudo, процедура — `docs/FRONTEND_PLAN.md` §4).

File diff suppressed because one or more lines are too long

View file

@ -242,6 +242,19 @@ SSE-соединение открытым ПО ПОСТРОЕНИЮ — сеть
чтения файлов, смотрю на него, кладу рядом `references/fleet.png`, называю расхождения словами,
правлю, снимаю снова.
**Два ключа и вторая команда (S3.5).**
- `--dpr 1` снимает то, что видит владелец. Дефолтные 2x нужны для сравнения с референсом, но они
ПРЯЧУТ целый класс дефектов: полупиксель CSS ложится в целый пиксель устройства, и мыло на
штрихах иконок и на мелком тексте в кадр не попадает. Замечание владельца 17 нашлось только так.
- `python3 scripts/measure.py <кадр> [<кадр>…]` — доли поверхностей, промежутки и полосы в CSS.
Норма «работать замером, а не на глаз» держится инструментом, а не обещанием.
- `npm run scenes` — сценарии ИНТЕРАКЦИЙ (`scripts/scenes.mjs`): клик → кадр → проверка состояния.
Статичный кадр не принимает вкладки, драг ручки, модалы и спойлеры — они существуют только
в движении. Падение сценария роняет команду; кадры ложатся в `.shots/scenes/`. Сегодня их
пять: `tabs` (модель VS Code), `scroll` (keep-alive, Ф-19), `drag` (залипание полоски),
`context` (сводка замечаний, спойлеры банка, честное состояние), `overlays` (модальные окна).
**Стенд без root.** `.tooling/` собирается один раз и в репозиторий не едет (вместе с `.shots/`
он в `.gitignore`). Chromium не стартует, пока не найдёт `libnss3`, `libnssutil3`, `libnspr4`,
`libasound.so.2`; в системе их нет, а sudo недоступен, поэтому пакеты выкачиваются и
@ -558,8 +571,78 @@ CSS-переменной (`{'--x': flag ? 'red' : 'blue'}`) · шаблонна
четырёхзначный hex), а поле объекта с именем `style` — инлайновым стилем. Оба случая лечатся
переименованием и стоят дешевле, чем пропущенный литерал.
### 5.5. Пере-замер S3.5 (09.08) — что нашёл замер, а не глаз
Замечания владельца 1 и 5 требовали работать замером. Инструмент замера теперь живёт в репозитории
и воспроизводим: `python3 scripts/measure.py references/fleet.png .shots/showcase.png` печатает
доли поверхностей, промежутки по горизонтали и полосы по вертикали в CSS-пикселях.
**Что сошлось при вьюпорте референса 1280×764** (прямое наложение, оба кадра при 2x):
промежутки — четыре по 8 CSS ровно на тех же координатах (0, 328, 944, 1272); ширины панелей
320 · 608 · 320; шаг строки дерева 26; высота пилюли вкладки 26. То есть по критерию §5 витрина
СТОИТ на референсе, и «масштабы великоваты» при вьюпорте референса замером не подтверждается.
**Что разошлось и было исправлено:**
| Что | Замер Fleet | Было у нас | Стало |
|---|---|---|---|
| верхняя полоса | центр иконок y=17.75 при полосе 0‥36 — полоса прижата к краю окна | центр 22: 8 поля + 28 полосы | `--topbar-height: 36`, поле оболочки только по бокам |
| статус-полоса | центр текста y=749.75 при полосе 735.5‥764 | центр 745.5, под текстом пустая полоса 8px | `--statusbar-height: 28`, текст по центру полосы |
| поле текста полос | 12.5 слева, 13.5 справа ОТ КРАЯ ОКНА | 14 (8 поля оболочки + 6) | поле оболочки перенесено на область панелей, полосы полнокровные, `--bar-inset: 12` |
| поля и промежутки вкладок | пилюля «Files» 15‥59 при тексте 25‥52, до следующей ~5px | поле 8, промежуток 2 | `--tab-inset: 10`, промежуток `--space-3` |
Полная геометрия окна при этом не изменилась: 36 + 836 + 28 = 900, промежутки панелей остались
на 0 · 328 · 944 · 1272. Это и есть ответ на «особенно заметно по тексту снизу»: полосы у Fleet
прижаты к краям окна, а не отступают от них на поле оболочки, и текст в них центрируется по всей
полосе. Поле оболочки поэтому принадлежит области ПАНЕЛЕЙ, а не окну целиком — иначе поле полосы
складывается с полем оболочки, и текст уезжает вдвое дальше от края, чем в референсе (эту ошибку
первый заход S3.5 и совершил: было 14, стало бы 20 при цели 12.5; поймало адверсариальное ревью).
После правки замер сходится с референсом по обеим осям: текст статус-полосы `x 12.5…1267.0`,
`y 744.5…756.0` против `12.5…1266.5`, `744.5…756.0` у Fleet.
**Что видно только при `--dpr 1`.** Замечание 17 («иконки шакальные») в кадрах при 2x не видно
ВООБЩЕ: полупиксель CSS там ложится в целый пиксель устройства. Поэтому у `npm run shot` появился
ключ `--dpr`. Причина мыла — сетка, а не толщина: набор нарисован на сетке 24, рисуется размером 16,
координата c уезжает в 2c/3, и у геометрических форм (c кратно трём) центр штриха шириной 1px
попадает ровно на целый пиксель, размазываясь по двум половинкам. Проверены четыре варианта кадрами
1x: «штрих 2 при 16» и «размер 18, штрих 2» дают жирнее, но по-прежнему мыльно; полупиксельный
сдвиг SVG даёт целые линии. Взят он (`reset.css`).
**Мера набора читалки.** На кадре 2560×1440 строка перевода вырастала до ~150 знаков: боковые панели
держат функциональную ширину в пикселях, и вся лишняя ширина доставалась центру. S3.5 ввела
`--reader-width: 1040px` с центрированием блоков — ~75 знаков в колонке. ⚠ **Отменено 10.08:**
владелец назвал это «жёстко приклеен к центру», токен удалён, набор снова во всю ширину панели;
на 2560×1440 это ~120 знаков в строке. Ограничивать ширину повторно нельзя — решение владельца. Функциональную ширину
боковых панелей намеренно НЕ переводили в долю кадра: 25% у Fleet это следствие окна 1280, а
не инвариант — 640px под дерево разделов были бы пустой ширины.
---
### 5.6. Замер палитры S3.6 (10.08) — тон снят с референсов, а не подобран
Владелец отверг тёплый подтон S3.5 и назвал эталоном два редактора разом: Fleet (`fleet_2.png`,
докинут 10.08) и Antigravity. Тон снят с их кадров гистограммой (`PIL`, счёт точек по всему
кадру плюс срезы именованных областей), а не выбран глазом:
| Что | fleet_2.png | antigravity_*.png | Наш токен |
|---|---|---|---|
| полотно панели | `#181818` (64% кадра) | `#161616` (18%) | `--color-panel: #181818` |
| хром (полоса вкладок, верх окна) | `#292929` (7%) | `#1c1c1c`/`#252525` | `--color-chrome: #242424` |
| фон окна | — | `#101010` (72%) | `--color-shell: #090909` (не трогали) |
| выбранная строка | `#184176` (срез строки CardPicker.tsx) | — | `--color-selected: #1c4478` |
| синий/зелёный акценты | — | `#2964ad` / `#3c7d4f` (NOTE/TIP) | `--color-note` / `--color-ok` |
Главное, что дал замер: **оба референса строго нейтральны, R=G=B**. То есть тёплый подтон,
внесённый S3.5 «по рекомендациям про тёмные фоны», противоречил тем самым кадрам, на которые
сессия ссылалась. Второй вывод — про вкладки: срез по вертикали через активную вкладку fleet_2
(x=790, y=36‥68) даёт `#181818` без единой разделительной линии до содержимого, тогда как
соседняя неактивная лежит на `#292929`. Активная вкладка у Fleet — это ВЫРЕЗ в хроме цветом
полотна; отсюда модель `Tabs.module.css` и ответ на «вкладки не вписаны в окно».
Способ повторить: `python3 -c` с `PIL.Image` + `collections.Counter` по `im.getdata()` для
общей гистограммы и по `im.crop(box)` для именованной области; насыщенные тона отбираются
фильтром по `colorsys.rgb_to_hls` (s > 0.25, 0.12 < l < 0.75).
## 6. Что уже известно про движок — учтено в форме данных
Из `FRONTEND_SESSION_PROMPT.md` §9 и `STACK_DECISIONS.md` §8. Влияет на типы в `src/api/`

View file

@ -0,0 +1,208 @@
# Промт: фронт-сессия S3.7 — долги слоя данных и мелкие хвосты (до S4)
Ты — фронтенд-сессия TextMachine, восьмая по счёту. Зона записи — **только `frontend/`**; сессия
НЕ коммитит — дерево готовит и передаёт на лендинг оркестратору. Протокол зоны — стоящий промт
`FRONTEND_SESSION_PROMPT.md` §«Зона и git», он в силе целиком; итоги, пинги и вопросы владельцу —
ТОЛЬКО зонный журнал `frontend-PROGRESS.md``docs/PROGRESS.md` фронт не пишет). В дереве живут
незакоммиченные файлы ДРУГИХ зон (полигон: `eval/*`, `docs/*`) — не трогать и не «прибирать»;
у полигона прямо сейчас идёт ЖИВОЙ платный прогон — ничего в `eval/` и `~/books/` не запускать,
не читать замки, не убивать процессы.
> Промт написан оркестратором (10.08.2026) по приёмке S3.6. Контекст холодному исполнителю:
> оболочка и витрина построены сессиями S0S3.6 и приняты владельцем (три круга замечаний);
> слой данных стоит с S3 — `src/api/` разговаривает настоящим HTTP через MSW-мок сети,
> живой поток идёт по SSE (`src/showcase/useRunStream.ts`), чтения — TanStack Query
> (`src/api/queries.ts`), контракт — зонная копия `frontend/docs/api-contract/openapi.yaml`
> (0.2.1), типы генерятся из неё. Эта сессия НЕ строит новых экранов: она закрывает названный
> техдолг слоя данных (Ф-49…Ф-51), одну ратифицированную правку контракта (Ф-47) и мелкие
> хвосты. S4 стартует после неё отдельным промтом. По всем задачам — прогон в отчёте;
> **молча пропустить задачу нельзя**: сделано / диспозиция с обоснованием / вопрос В-строкой.
## Обязательное чтение до кода (порядок)
1. `FRONTEND_SESSION_PROMPT.md` — стоящий промт: зона, git, жёсткие ограничения.
2. `frontend-PROGRESS.md`: шапка «Текущее состояние», затем **приёмочная запись оркестратора
10.08 (первая в хронике)** — в записи S3.6 есть число-дрейф против финального дерева,
и поправки перечислены именно там; затем сама запись S3.6 (⚠ её разделы 1215 стоят
в ОБРАТНОМ порядке после §11). `BACKLOG.md` строки Ф-40, Ф-47, Ф-49, Ф-50, Ф-51 —
формулировки не переизобретать, но и не принимать на веру: КАЖДУЮ проверить по коду
до правки.
3. Контракт: зонная копия `frontend/docs/api-contract/openapi.yaml` — §`Unit`,
`EventEnvelope`, `Revision`, `resync_required`, `BankTerm`; поведение ревизий и правило
`>= R` — норматив.
4. `FRONTEND_PLAN.md` §5 — гейты и замеры: что уже стоит и как расширять.
## Задачи
### 1. Ф-49 — текст открытой главы не подписан ни на один сигнал изменения
`keys.units` не инвалидируется нигде: `useRunStream.ts` по кадрам инвалидирует notes/bank/book,
кадр `chapter` патчит лишь СПИСОК глав, а открытые главы живут на keep-alive вкладках и не размонтируются —
читатель, не уводивший фокус из окна, нового текста не увидит вообще. Контракт прямо предписывает
обратное (`openapi.yaml`, `Unit`: «the live "something changed" signal arrives as an event; the
text arrives with a read after the boundary»).
Сделать: инвалидация чтений юнитов открытой главы на кадре `chapter` этой главы И на смене
`status` книги (границы стадий — за всю черновую волну кадр `chapter` может не прийти ни разу,
пока К-10 открыт). Безопасная сторона: лишняя инвалидация дешевле невидимого текста, но
НЕ инвалидировать все главы на каждый кадр — скоуп по ключу главы из кадра.
### 2. Ф-50 — кадр потока, пришедший до появления чтения в кэше, теряется молча
`useRunStream.ts`: обработчики делают `list === undefined ? list : {...}` — в пустой кэш кадр
не пишется, а `acceptFrame` уже сдвинул отметку ревизии; `freshest(undefined, next)` затем
принимает снимок, сделанный ДО кадра. Окно — старт экрана и resync. Рядом второе того же класса:
многостраничное чтение штампует «рваный» список ревизией ПОСЛЕДНЕЙ страницы (`queries.ts`),
хотя контракт предупреждает, что ревизия движется во время пагинации.
Сделать: кадр, не нашедший кэша, обязан оставить след — либо инвалидация ключа, либо отметка
ревизии не сдвигается до успешной записи; для пагинации — ревизия списка не выше МИНИМАЛЬНОЙ
из страниц либо перечитывание при сдвиге ревизии между страницами. Выбор решения обосновать
в журнале ЗАМЕРОМ/тестом, не рассуждением.
### 3. Ф-51 — `resync` сбрасывает один гард из двух
На `resync_required` зовётся `invalidateQueries()` без ключа: данные сохраняются, гард
`dropStaleReads` продолжает сравнивать новый снапшот со СТАРОЙ отметкой — а по контракту resync
выдаётся именно после полной замены, где монотонность ревизии не обещана. Вторым слоем: вызов
без ключа бьёт по чужим скоупам (библиотека, usage, run-options, другие книги).
Сделать: `resetQueries`/`removeQueries` по скоупу КНИГИ + сброс отметок ревизий этой книги
(стриминговая сторона уже сбрасывает — довести симметрию до кэша). Чужие скоупы не трогать.
### 4. Ф-47 — правка контракта: `sense` становится обязательным (РАТИФИЦИРОВАНО)
Решение оркестратора (автор контракта), исполняется этой сессией в зонной копии:
`BankTerm.sense` — в `required`; пустая строка = «различителя нет». Дифф в канон
`docs/architecture/14-api-contract/` переносит оркестратор при лендинге — канон сам не трогать.
Сделать: правка `frontend/docs/api-contract/openapi.yaml``required` + описание правила пустого),
бамп `info.version` 0.2.1 → 0.2.2, `npm run contract` зелёный, `npm run contract:types`
перегенерить `src/api/schema.ts`, убрать костыли `?? ''` по `sense` (проверить грепом ВСЕ места,
не по счёту из строки бэклога — он там завышен), фикстуры мока привести (каждый терм несёт
`sense`, хотя бы пустой строкой).
### 5. Ф-40 — общий модуль стилей поля ввода
`src/ui/TextField.module.css` и `src/ui/FilterField.module.css` держат байт-одинаковые `.input`,
`::placeholder`, `:focus`; отличие — цвет границы в покое. Роли примитивов разные, сливать
примитивы нельзя — общий CSS-модуль поля на оба, отличия остаются у ролей.
### 6. Ф-25 — свод языка комментариев (конвенция владельца 04.08)
Комментарии S1/S2 в ~40 файлах (`api/index.ts`, `showcase/*`, `ui/*`, `tokens/*`, `scripts/*`)
остались русскими; приёмка нашла русские комментарии и в **~14 НОВЫХ файлах S3.6** (например,
`src/showcase/About.tsx`) — вопреки конвенции и уточнению владельца по ходу самой S3.6.
Перевести на английский ОДНИМ механическим проходом, покрыв ОБА множества. Граница жёсткая:
переводятся комментарии, имена тестов и служебные строки; **UI-текст и фикстурная проза остаются
русскими — это продукт, а не код.** Смыслы не менять; комментарий, который при переводе оказался
враньём против кода, — чинить по коду и назвать в журнале.
### 7. Сторожа на то, что мерилось руками
Из таблицы долгов S3.6: зум 125/150/200% и узкая панель замерены руками, сторожа нет. Дёшево
и без новых механизмов: в существующие сценарии добавить прогон одной-двух ключевых проверок
на втором вьюпорте/зуме (выбор — по замеру, что реально ловит регресс раскладки). Если дёшево
не выходит — честная диспозиция в журнале, не молчание.
Отдельно: референс fleet_2.png, на котором стоит палитра S3.6 и категория `toned` в
`src/tokens/tokens.test.ts`, докинут владельцем в `frontend/references/` при приёмке 10.08
(быструю пиксельную пробу якорей оркестратор сделал — совпало; запись в журнале). Прогнать
полный `measure.py` по палитре и вкладкам, записать числа в `FRONTEND_PLAN.md` §5 — замер
становится воспроизводимым; ⚠ PIL для `measure.py` не запинен (см. 8.5) — решается тем же
пунктом.
### 8. Находки приёмки S3.6 (закрыть здесь же)
Приёмочное ревью оркестратора (12 агентов: шесть линз + скептики-верификаторы, author≠reviewer)
подтвердило 12 находок. Ниже те, что закрывает эта сессия; каждая грунтована `file:line`
проверяй по коду, не по списку (строки могли съехать).
**8.1 (HIGH) Замер ширин таблицы банка молча проваливается в скрытой keep-alive вкладке
и не повторяется при показе.** `src/ui/Table.tsx:232` — зависимости эффекта замера
`[frame, named, sample, marks, hasRows]` не знают о видимости вкладки; под
`content-visibility: hidden` (`src/ui/Tabs.module.css`) геометрия нулевая, `read()` не набирает столбцов,
10 кадров ретрая и молчаливый `return`. Сценарий: открыть книгу, уйти с вкладки «Банк» ДО прихода
`/bank`, вернуться — столбцы падают в `1fr` без потолков (ровно «баг 4» владельца, объявленный
починенным). Лечение выбрать по замеру (триггер на возврат видимости:
`contentvisibilityautostatechange` / ResizeObserver / зависимость от выбранной вкладки);
приёмка — playwright-сценарий, воспроизводящий уход-возврат, и падающий на до-фиксном коде тест.
**8.2 Вторичный текст и индикаторы теряют контраст на новом синем `--color-selected`.**
Выбранная строка дерева — ПОСТОЯННОЕ состояние (открытый раздел всегда выбран), а содержимое
не перекрашивается: на #1c4478 лежат текст 12px #9b9b9b (3.52:1 при пороге 4.5), иконка #707070
(1.98:1), дорожка прогресса #262626 (1.55:1) — `src/ui/Tree.module.css:46` красит только фон,
`src/showcase/Library.module.css` содержимое не трогает. Починить пары на selected-фоне, не уезжая от
референса; axe этого НЕ ловит (non-text не проверяет, выбранную строку со счётчиком не видел) —
добавить сторож в сцену.
**8.3 Путь из «Замечаний» не загарден: `open` по id, отсутствующему в `chapters`.**
`src/showcase/Showcase.tsx:186` + `src/showcase/Notes.tsx:53-54` — строка сводки с определённым, но отсутствующим id
рисуется кликабельной; `Tabs` контролируемый (авто-фолбэка react-stately на первую вкладку НЕТ),
итог — пустой центр без empty-состояния. Гардить как Library-путь (`positionOf`), сироту
помечать и в строке сводки (сейчас `data-orphan` только при `chapter_id === undefined`).
**8.4 Съёмка нестабильна — два источника гонки, лечить оба.**
(а) `src/showcase/useScreenshotFlag.ts:22` — на первом рендере `useIsFetching() === 0` и экран
объявляется `ready` ДО старта первого запроса (нотификация батчится в отдельную задачу); гейт
`scripts/shot.mjs` ждёт ровно `dataset.screen`. (б) Поймано исполнением на приёмке: axe стартует во время 220мс-анимации
карточки ожидания — один прогон `npm run shot` напечатал «контраст: 4 узла» на `/loading`,
два повторных — ноль. Стабилизировать цикл: убрать транзиент `ready`, дожидаться устаканивания
анимаций до прогона axe.
**8.5 Минорные — одним проходом** (правки на строки, не механизмы):
- `src/ui/Table.tsx:257` — кэш канвас-замера не инвалидируется, если шрифт так и не загрузился
(отравление ширинами fallback);
- `src/showcase/Document.tsx:33` — пин вкладки срабатывает на ЛЮБОЙ `mouseup` при непустом
глобальном выделении (правый клик, выделение в соседней панели);
- `src/showcase/Showcase.tsx:59``awaiting()` на tracked-результатах + `useIsFetching` даёт
полный ре-рендер экрана на каждом префетче; чинить ТОЛЬКО с замером до/после, иначе не трогать;
- `src/api/vocabulary.ts:112``termStatus.label` («предложен»/«черновик»/«подписан») не
рендерится ни одним экраном: снять либо применить; продуктовые слова не выдумывать;
- `src/tokens/tokens.css:27` — в комментарии `--color-text-muted` число 3.9:1, фактически 3.58:1;
`tokens.css:33``--color-note` 2.98:1 на панели при пороге 3:1 для non-text (добрать);
`tokens.css:68` — комментарий `--row-height-term` ссылается на удалённый двухстрочный список;
- `src/showcase/Status.module.css:8``.paused`/`.offline` стали no-op (цвет совпал с базой):
вернуть отличие либо снять классы;
- `src/ui/Button.module.css:36` — подпись disabled-кнопки ТЕКСТОМ `--color-text-muted` против
контракта токена «non-text only» (WCAG disabled освобождает — противоречие контракту токена,
привести одно к другому);
- `scripts/scenes.mjs:170` — сторож обрезки вкладок молча зеленеет, если заголовок вкладки
перестанет быть `span`;
- `scripts/measure.py:13` — PIL нигде не запинен: манифест окружения для `scripts/` либо честная
шапка «чем ставить».
**8.6 Доки зоны — привести к коду** (правится СВОЁ; записи журнала S3.6 не переписывать —
поправки к ним уже аннотированы приёмочной записью оркестратора в журнале):
- `FRONTEND_PLAN.md` §5.6 описывает СНЯТУЮ модель вкладок (вырез в хроме) как текущую — в коде
пилюля по fleet.png (возврат разделом 13 записи S3.6); §4 говорит «сценариев пять» — их семь;
- `frontend/README.md` — вписать S3.6 (и эту сессию) в карту сессий;
- `BACKLOG.md` Ф-47 — «`?? ''` в трёх местах» завышено: вхождение одно
(`src/showcase/Bank.tsx:166`); поправить при закрытии строки.
## Что НЕ делать
- Не строить экраны S4S7 (загрузка книги, механизм подписи, содержимое настроек, экспорт).
- Не трогать канон контракта `docs/architecture/14-api-contract/` — только зонную копию (задача 4).
- Поля банка из черновика S3.5/S3.6 (`note`, `gender`, `contexts`…) НЕ заводить ни в спеку,
ни в фикстуры — черновик ждёт ратификации (граница ПТ-33 — на владельце).
- Продуктовые слова не выдумывать: В-3 (ступень замечания), В-4 (глава без заголовка),
В-6 (фраза паузы), В-8 (слово состояния в дереве) — на владельце.
- Тесты и гейты не подгонять под зелень (D39.121): красный гейт — вопрос оркестратору
через журнал, не правка гейта.
- Никакого `git add`/`git commit` — дерево передаётся на лендинг как есть.
- Ф-48: если по ходу покажется, что пора вернуть ресайз столбцов — НЕ возвращать; строка
требует свежего замера от новой базы, это не задача пака.
## Отчёт и приёмка
- Каждая задача 13: ТЕСТ, падающий на до-фиксном коде (vitest поверх MSW; поток мокается
событиями), и зелёный после; в журнал — что именно ловит тест и как воспроизводился дефект.
- `npm run check` и `npm run check:full` зелёные на сдаче; счётчик тестов вырос, ни одна
существующая проверка не ослаблена.
- **Мандат самопроверки:** ревью ИСПОЛНЕНИЕМ своего кода, своих кадров и своих сценариев +
адверсариальное ревью диффа (author≠reviewer) в конце; находки с диспозициями — в журнал.
- Прогон по всем задачам таблицей в `frontend-PROGRESS.md`: задача → что сделано → тест/замер →
либо диспозиция. Хвосты — строками Ф-N в `BACKLOG.md` (Ф-49/50/51/40/47 закрыть или
переформулировать честно), вопросы владельцу — В-строками.
- Промт S4 не писать — его выдаёт оркестратор после приёмки этого пака.

View file

@ -69,9 +69,61 @@
> `npm run check` — 7 шагов, **108 тестов** на 08.08 (число живёт в батарее, а не здесь: сверяется прогоном), зелёный; `npm run shot` — шесть маршрутов, гейт
> доступности зелёный на всех.
> **Запись фронт-сессии S3.5, 09.08.** Фикс-пак оболочки по 24 замечаниям владельца ИСПОЛНЕН —
> прогон по всем номерам первым разделом хроники ниже. Коротко: вкладки живут по модели VS Code
> (предпросмотр/закрепление), Ф-19 закрыта keep-alive панелей, полоска ресайза больше не залипает
> (причина найдена в коде библиотеки), настройки и добавление книги открываются модальными окнами,
> лупа стала палитрой быстрого перехода, банк перепридуман со спойлерами по окну применимости,
> кнопка-заглушка «Подписать банк» снята в пользу честного состояния, палитра ушла в тёплый подтон
> (три варианта кадрами), причина «шакальных» иконок найдена в сетке набора и вылечена. Гейты
> зелёные, **контраст axe — ноль узлов на всех семи маршрутах** (Ф-11 закрыта замером).
> Адверсариальное ревью тремя линзами исполнено, находки и диспозиции — там же; две из них были
> моими дефектами по существу (правка полос увела ОТ референса; тронуто слово паузы, закреплённое
> за владельцем) и откачены.
> Ждут владельца: В-7 (масштаб интерфейса), В-8 (слово состояния в дереве), Ф-38 (защита вкладки
> «Замечания»), выбор палитры. Ждёт оркестратора: два черновика правки спеки — поля банка и
> `BookIntake.title` (в конце записи S3.5).
> **Запись фронт-сессии S3.6, 10.08.** Второй круг замечаний владельца (10 пунктов, снятых
> с прогона после S3.5) отработан. Коротко: палитра переведена на НЕЙТРАЛЬНЫЙ ряд, снятый
> замером с `fleet_2.png` и `antigravity_*.png` (тёплый подтон S3.5 был моей отсебятиной
> ПРОТИВ этих же референсов); вкладки перестроены по замеру fleet_2 — полоса вкладок это ХРОМ,
> активная вкладка вырезана из него цветом полотна; найдена и починена причина обрезки коротких
> названий; ручка ресайза перенесена из vojo дословно, включая числа упоров; линия жёлоба
> читалки — в цвет оболочки, ширина набора отпущена по окну; **банк памяти переделан в таблицу**
> и открывается вкладкой в центре, спойлер снят с ПЕРЕВОДА и поставлен на описание; иконки книги
> и главы разведены и несут состояние цветом. Первым разделом записи — АУДИТ всех 24 буллетов
> S3.5 по коду (просьба владельца), с честными вердиктами второго круга.
> Ждут не меня: две дыры платформы/контракта под банк (ниже, «Жалобы») и подписной механизм S5.
> **Сессия S3.6 ЗАКРЫТА 10.08 (разделы 1215 хроники).** Дерево готово к лендингу, фронт не
> коммитит. Гейты: `npm run check:full` зелёный — 160 тестов, 7 маршрутов, 7 сценариев, ноль
> блокирующих нарушений axe, драг панели p90 17 мс. Новые строки бэклога Ф-49…Ф-51 (свежесть
> текста главы · потерянные кадры потока · resync сбрасывает один гард из двух) заведены по
> ресёрчу отдельным агентом и проверены по коду; два моих собственных предложения тот же ресёрч
> опроверг, и это записано в разделе 15 вместе с долгами сессии.
> **Четвёртый круг замечаний, 10.08 (раздел 12 хроники).** Четыре бага владельца по таблице банка
> закрыты, и один из них был МОЙ механизм: заморозка ширин (`useSettledWidth`) оставляла таблицу
> сжатой после драга, а перезамер показал, что она ничего не покупала — драг держит p90 17 мс и
> без неё. Ширины отданы алгоритму библиотеки, содержимое меряется канвасом по всему банку и идёт
> ПОТОЛКОМ столбца. Спойлер описания переделан с фильтра на боксе на размытие глифов. Сверх списка
> владельца найдено шесть дефектов того же класса — в том числе замер по запасному шрифту (4.7%
> ширины) и живой `getComputedStyle`, обнулявшийся за время загрузки шрифтов. Приёмка: 158 тестов,
> 7 сценариев, 4 новые проверки, каждая падает на откате своего фикса.
> **Запись оркестратора, 10.08 — ПРИЁМКА S3.6: принято и залендено (D39.127).** Гейты
> пере-прогнаны исполнением, 12 находок ревью (1 high) и поправки к записи S3.6 — первой записью
> хроники ниже. Промт **S3.7** выдан (`S37_SESSION_PROMPT.md`: Ф-49…Ф-51, Ф-47 ратифицирован,
> Ф-40, Ф-25, находки §8), запуск по слову владельца; **S4 — после приёмки S3.7.** Экспорт банка
> заведён строкой 169 единого бэклога (Ф-43).
- **Пройдено:** S0 (план), S1 (инструменты, скриншот-цикл, `tokens.css`, витрина),
S2 (оболочка трёх панелей, слой `src/ui/`, масштаб списков, хвосты гейтов),
**S3 (слой данных: контракт 0.2.0, MSW, фикстуры всех состояний, живой поток, типы контракта)**.
S3 (слой данных: контракт 0.2.0, MSW, фикстуры всех состояний, живой поток, типы контракта),
S3.5 (фикс-пак оболочки: 24 замечания владельца, сценарии интеракций, пере-замер полос),
**S3.6 (второй круг: палитра по замеру референсов, модель вкладок fleet_2, банк таблицей,
ручка из vojo дословно)**.
- ⚠ **АБЗАЦ НИЖЕ ИСТОРИЧЕСКИЙ (04.08), читать как хронику:** замок с тех пор открыт (контракт ратифицирован D39.99), S3 исполнен и принят D39.115. Оставлен, потому что объясняет, ПОЧЕМУ S3 однажды остановилась, а не как диспозиция.
- **S3 НЕ сделана: замок на входе закрыт, проверено исполнением 04.08.** Контракта API v0
по-прежнему нет (`docs/architecture/14-api-contract.md` отсутствует; строка 95 единого
@ -291,12 +343,780 @@ NDJSON — это шов ДВИЖОК↔ПЛАТФОРМА (D39.85), а фрон
| В-1 | **Данные привязаны к одной машине.** Вся фактура — прогоны, экспорты, банки — лежит в `/home/ubuntu/books/gu-zhenren/` вне git; оба `backend/example/sample-*.db` пустые (только схема); пути стенда зашиты в тесты (`miner_parity_test.go:33-35`, `labelharness_test.go:43`) и в `book.yaml` абсолютными путями. Владелец сказал: «мне не нравится, что это живёт конкретно на моём диске». Лечение — платформа (`platform/README.md:20-29`: библиотека книг, хранение исходников и экспортов), но платформы ещё нет ни строки. Фронт от этого не зависит (работает на моках), но **вопрос на движок/платформу, а не на фронт** | скоуп-решение и порядок работ |
| В-3 | **Каким словом называть ступень замечания.** Сейчас `attention` и `glance` различаются только цветом полоски: синяя и серая. Зрячему разница читается ещё и светлотой, скринридеру — никак. Лечение то же, что уже применено к состоянию прогона книги: слово рядом с цветом. Но словарь замечаний продуктовый (ПТ-33), а §3.8 требует, чтобы экран не выглядел тревожным, — «важно / неважно» и «ошибка / предупреждение» не годятся. Строка Ф-21 | продуктовая формулировка, которую видит пользователь |
| В-6 | **Упёрся в свой потолок ≠ кончились деньги, а интерфейс их не различает.** Владелец 07.08 дал человеку поставить перед стартом колпак в ГЛАВАХ; платформа берёт холд на его сумму. Но машинная причина паузы в контракте ровно одна — `credit_exhausted`, и она про кредит. Значит прогон, честно доехавший до выбранной человеком границы, и прогон, у которого кончился баланс, приходят на экран одной фразой «перевод остановлен: лимиты исчерпаны». Первому она врёт: лимит не исчерпан, человек сам так решил, и следующее действие у него другое — поднять колпак, а не пополнить счёт. Нужно ли второе значение и какая у него фраза — продуктовое. Заведено К-13, строка Ф-31 | продуктовая формулировка, которую видит пользователь, и разное следующее действие |
| В-8 | **Какое слово о состоянии книги стоит в дереве.** Замечание 23 потребовало бейджу ФИКС-место, и оно сделано; но две метки словаря в него не помещаются и усекаются с тултипом — «остановлена: лимиты» (19 знаков) и ветка неизвестного «неизвестное состояние» (21). Укоротить их сессия НЕ вправе: фраза паузы зарезервирована за владельцем (В-6), и первый заход S3.5 её укоротил и был откачен собственным ревью. Варианты: (а) оставить усечение с тултипом, (б) владелец даёт короткое слово для дерева, а полная фраза остаётся в статус-полосе, (в) расширить колонку бейджа за счёт названия книги | продуктовая формулировка, которую видит пользователь |
| В-7 | **Физический масштаб интерфейса на мониторе.** Замечание 1 говорит «масштабы великоваты, особенно на 27″ 2K, на 24″ терпимее». Замер при вьюпорте референса расхождения с Fleet не нашёл (§5.5 плана): промежутки, ширины панелей, шаг строки и высота вкладки совпадают. Значит речь про физический размер на конкретном экране, а он зависит от масштабирования ОС, и «просто уменьшить токены» означало бы уехать от референса, который владелец принял. Правильное лекарство — ручка «плотность интерфейса» в настройках (3 ступени), но она требует перевода ВСЕХ размерных токенов в rem и пере-замера, то есть отдельного прохода (Ф-36). Вопрос: нужна ли ручка, и какой масштаб ОС стоит на 27″ 2K — по нему видно, во сколько раз расходится физика | продуктовое решение и цена отдельного пакета |
| В-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 единого бэклога | продуктовое: обещание срока пользователю |
## Хроника
### 10.08 — приёмка S3.6 оркестратором: ПРИНЯТО и залендено; поправки к записи S3.6
Гейты пере-прогнаны исполнением: `npm run check:full` EXIT=0, vitest **160**, `npm run shot` ×3,
сценарии зелёные. Один прогон shot напечатал «контраст: 4 узла» на `/loading`, два повторных —
ноль: гонка axe с 220мс-анимацией карточки ожидания, уходит в S3.7 как стабилизация съёмки
(контраст в shot.mjs — НЕ блокирующий гейт, «принято как есть», поэтому EXIT=0 нулей не
доказывает). Ревью 12 агентами (6 линз + скептики, author≠reviewer): десять ключевых клеймов
записи S3.6 подтверждены кодом; **12 находок подтверждено (1 high — замер ширин таблицы банка
в скрытой keep-alive вкладке, `src/ui/Table.tsx:232`), 24 минорных** — все переданы промтом
`S37_SESSION_PROMPT.md` §8, здесь не дублируются.
**Поправки к записи S3.6 ниже — сама запись не правлена** (аннотация, не переписывание):
- **§12, приёмочный буллет о сторожах спойлера, неверен обеими половинами:** «спойлер проверяется
по механизму (текст прозрачен и нарисован тенью), а не по слову filter» — финальный механизм
это ровно `filter: blur(3px)` (`src/showcase/Bank.module.css:80`, text-shadow там же отвергнут),
и сторожа сверяют именно blur (`scripts/scenes.mjs:387,454`). Сторожа валидны для настоящего
механизма; ложна формулировка буллета — она описывает промежуточную редакцию.
- **Число-дрейф разделов 714 против финального дерева:** §8а`--panel-context-width` в финале
620px, не 400px; §9а — механизма `--table-min-width` в дереве нет (минимум = полы столбцов
`--column-min`); §7 — `bankDocumentTitle` спилен в §8а вместе с банком-документом; §14 —
предзагрузка «на приходе фокуса» снята в §15, финал — только по нажатию кнопки.
- **Грунты §3/§8б в движок съехали:** правило «every proposed term is EITHER promoted OR
declined» — `backend/internal/pipeline/mining.go:81` (лог — `:243`), константа
`tm-bank-stop-v1``mining.go:295`.
- **Разделы 1215 стоят в обратном порядке** после §11 — хронику S3.6 читать §12→§15 снизу вверх.
- **Референса fleet_2.png на диске не было** (`references/` под .gitignore) — палитра и категория
`toned` в `tokens.test.ts` ссылались на невоспроизводимый замер. **Закрыто при приёмке:**
владелец докинул файл 10.08, пиксельная проба оркестратора (canvas, полный кадр 1280×801)
подтверждает якоря — доминанта #181818 (656k px, полотно = базовый токен), #292929 второй
по частоте (74k px, сурс панели), #184176 присутствует дословно (1.8k px, сурс выбранной
строки); полный `measure.py`-свип — задача S3.7.
Носители хвостов S3.6: Ф-49…Ф-51 + находки приёмки → промт **S3.7** (выдан, запуск по слову
владельца; S4 — после его приёмки); Ф-43 → строка **169** единого бэклога (экспорт банка);
**Ф-47 РАТИФИЦИРОВАН** (`BankTerm.sense` → required, пустая строка = «различителя нет»;
исполняет S3.7 в зонной копии, канон — диффом при лендинге); черновик полей `BankTerm`
(`note`/`gender`/`contexts`…) ждёт слова владельца по границе ПТ-33. Битая ссылка в
застейдженном чужом `docs/README.md` (DOVODKA↔REDO) — передана полигонному лендингу, не трогал.
### 10.08 — седьмая фронт-сессия (S3.6): второй круг замечаний владельца
Вход: 10 замечаний владельца, снятых с прогона ПОСЛЕ S3.5, плюс два уточнения по ходу сессии
(комментарии в коде — по-английски и без воды; блокеры платформы/бэкенда — жалобой, а пункт
в бэклог). Ниже: аудит прошлого пака (просьба владельца №1), прогон по 10 номерам, жалобы,
черновик правки спеки, самопроверка.
#### 1. Аудит: 24 буллета S3.5 → что реально стоит в коде
Владелец: «ты ревьювил сам себя же по буллетам из изначального промта? буллет из промта →
вот эти доработки в коде у нас есть». Сверено ПО КОДУ, не по отчёту. Столбец «вердикт второго
круга» — что владелец сказал об этом же месте на новом прогоне.
| № | Буллет S3.5 | Что стоит в коде (file:line) | Вердикт второго круга |
|---|---|---|---|
| 1 | Масштабы великоваты, кадр 2560×1440 | `tokens.css` шкала; предел меры набора `--reader-width: 1040px` | **ОТКАЧЕНО:** предел был моей добавкой сверх буллета, владелец назвал его «жёстко приклеен к центру» (нов. 6). Токен удалён |
| 2 | Спам вкладками → модель VS Code | `showcase/documents.ts:23-40` (preview/pin), `ui/Tabs.tsx:13` `preview`, `Library.tsx:110` `onActivate` | принято, повторно не оспорено |
| 3 | Вид вкладок: мелкие, не отделимы | было: пилюля на панели, `Tabs.module.css` | **ПЕРЕДЕЛАНО** (нов. 4): модель fleet_2 — хром + вырез |
| 4 | Две кнопки поиска | `showcase/Goto.tsx` (палитра), одна лупа `Showcase.tsx:126-132` | принято |
| 5 | Выравнивание полос | `Shell.module.css:12-17,111-118` `--bar-inset`, полосы прижаты к краям окна | принято |
| 6 | «О книге» — больше метаданных | `showcase/About.tsx:11-34` (14 полей до границы контракта) | принято |
| 7 | Банк перепридумать | было: двухстрочный список в 320px, `showcase/Bank.tsx` | **ПЕРЕДЕЛАНО** (нов. 7): таблица в центре |
| 8 | Вкладка «Замечания» — обосновать или снять | `showcase/Notes.tsx` — пер-книжная сводка со ссылкой в раздел | защищено, повторно не оспорено; финальное слово владельца не сказано (Ф-38) |
| 9 | Оригинал/перевод разделить мягко | `Reader.module.css:7-18` волосяная линия по жёлобу | **ЦВЕТ СМЕНЁН** (нов. 5): линия в цвет оболочки |
| 10 | Серо-синяя линия нечитаема | `ui/Callout.tsx:24-33` значок в тон полоске, ховер подсвечивает пару | половина: связь есть, СТУПЕНЬ по-прежнему только цветом — слово продуктовое, В-3 на владельце |
| 11 | Настройки модальным окном | `showcase/Settings.tsx`, `ui/Modal.tsx` | принято |
| 12 | Добавление книги модалом | `showcase/AddBook.tsx` (два источника названия) | принято; поля `title` в контракте по-прежнему нет — правка предложена |
| 13 | = 7 | — | см. 7 |
| 14 | Слишком точная копия Fleet | было: тёплый подтон в `tokens.css` | **ОТКАЧЕНО** (нов. 2): подтон владельцу неприятен |
| 15 | Светлой темы и языка нет | каркас в `Settings.tsx`, строки Ф-34/Ф-35 | диспозиция принята |
| 16 | Фон «серо-неприятный» | было: тёплый ряд | **ПЕРЕДЕЛАНО** (нов. 2, 9): нейтральный ряд по замеру референсов |
| 17 | «Шакальные» иконки | `tokens/reset.css:72-76` полупиксельный сдвиг сетки | причина закрыта; но «дизайн иконок» — отдельное замечание (нов. 8), сделано |
| 18 | Залипание и форма ручки | `Shell.module.css:64-109` | **ПЕРЕНЕСЕНО ДОСЛОВНО** (нов. 3): числа и состояния из vojo |
| 19 | Шестерёнка в правый верх | `Showcase.tsx:135-137` | принято |
| 20 | «Подписать банк» непонятна | кнопка снята, состояние честное | дополнено (нов. 7): подписывается банк ЦЕЛИКОМ — правило названо в подвале таблицы |
| 21 | Статус на иконке книги | `Library.tsx:124-128` `data-tone` на иконке | принято, усилено (нов. 8): четыре тона вместо двух |
| 22 | Где генерится заголовок главы | ответ оркестратора, сессии делать нечего | — |
| 23 | Наложение бейджа на название | `Library.module.css:34-47` фикс-место + эллипсис | принято |
| 24 | Пометка черновика | `showcase/Document.tsx:39-47` | принято |
Итог аудита честно: из 24 буллетов **шесть** владелец на втором круге вернул (1, 3/4, 7, 14/16,
18, 9) — не потому, что их не сделали, а потому что сделали НЕ ТАК. Два из шести — прямое
следствие моей самодеятельности: предел ширины набора (1) и тёплый подтон (14/16) в буллетах
не стояли, я их придумал; тёплый подтон при этом ПРОТИВОРЕЧИЛ референсам, которые владелец
называет эталоном. Это ровно тот класс ошибки, что уже записан в памяти сессий: «оспаривать
посылку, а не подменять её своей».
#### 2. Прогон по десяти номерам второго круга
| № | Замечание | Что сделано | Проверка |
|---|---|---|---|
| 1 | Аудит буллетов | таблица выше | — |
| 2 | Коричневый фон неприятен | нейтральный ряд `#181818/#242424/#212121` + `#090909` оболочки; тёплые тона удалены целиком | замер гистограмм референсов, `.shots/showcase.png` |
| 3 | Ручка не как в vojo | перенесены ЧИСЛА и состояния: покой 2×36 · ховер 0.25 · фокус акцентом 0.45 · драг 0.55 и 48px · упор min 3×28/0.85 · упор max 2×76/0.9, переходы 140ms | сценарий `drag`: проверяет и прозрачность, и высоту |
| 4 | Вкладки: вписать, баг обрезки | полоса вкладок = хром `--color-chrome`, активная вырезана цветом полотна (замер fleet_2: полоса 36‥66, активная залита `#181818`); **баг найден** — вкладки были flex-элементами и делили нехватку места ПРОПОРЦИОНАЛЬНО ширине, поэтому короткое название усекалось раньше длинного; теперь ряд прокручивается, а вкладка усекается только на своём пределе | сценарий `tabs`: «ни одна вкладка не усечена, не упёршись в предел» |
| 5 | Разделитель текста — чёрным | линия жёлоба перекрашена в `--color-shell` | `Reader.module.css:7-18` |
| 6 | Текст не по окну | `--reader-width` удалён вместе с центрированием: набор снова во всю ширину панели | кадр `.shots/showcase.png` |
| 7 | Банк — перепридумать | ниже отдельным разделом | сценарий `bank`, 12 проверок |
| 8 | Иконки книг и глав | книга — `Book` в тоне состояния (четыре тона), глава — `FileText` приглушённым; полоска выполнения главы синяя, на 100% зелёная | `.shots/showcase.png`, `.shots/scale.png` |
| 9 | Дизайн двуцветный | цвет введён там, где он ЗНАЧИТ: выбранная строка синяя (замер fleet_2 `#184176`), иконка книги в тоне состояния, точка состояния термина, полоска выполнения, акцент на открытом документе | axe: ноль узлов контраста на семи маршрутах |
| 10 | Скриншот fleet_2 | принят в работу: с него сняты и палитра, и модель вкладок, и цвет выделения | `FRONTEND_PLAN.md` §5.6 |
#### 3. Банк памяти (замечание 7) — что и почему
Владелец: «фронтовая сессия НЕ ПОНИМАЕТ чем мы заняты». Разбор пошёл в движок, а не в свою
голову. Найдено:
- **банк подписывается ЦЕЛИКОМ, а не построчно** — стоп снимается, только когда решён КАЖДЫЙ
предложенный термин, и тогда прогон идёт дальше (`backend/internal/pipeline/mining.go:201`;
в контракте это `POST /runs/{id}/resume`, отвечающий `409` при неполном наборе решений,
`openapi.yaml:418`). Правило названо словами в подвале таблицы;
- **у стопа уже есть машинная таблица подписи** `tm-bank-stop-v1` — «что читает подписной экран»
(`mining.go:388`, `bankStopRowJSON` `mining.go:317-333`): `src · dst · origin · type · freq ·
spread · conventions · conf · invented · signals · contradicts · variants · evidence · contexts`;
- **описание термина у движка ЕСТЬ** (владелец просил убедиться): `seed.Term.Note` и
`seed.Term.Gender/Aliases` (`backend/internal/seed/seed.go:63-80`), плюс `Evidence`/`Contexts`
кандидата майнера (`miner/miner_emit.go:43-55`). **В контракт API это НЕ вынесено**у
`BankTerm` есть только `sense` (`openapi.yaml:983-1023`). Поэтому спойлер поставлен на то
описание, которое контракт отдаёт, — `sense`; полноценное описание/обоснование предложено
правкой спеки (ниже) и заведено строкой бэклога.
⚠ **Абзац переписан по итогам приёмки того же дня — первая редакция описывала экран, которого
уже нет.** Было: банк открывался вкладкой-документом в центре, а справочник оставался справа;
владелец назвал это дублем и велел спилить. Стало (§8а и §9): банк живёт ТОЛЬКО в правой панели,
таблицей, и она в приложении одна. Столбцы — `Термин · Перевод · Тип · Смысл`; «Действует» стало
припиской к термину, «Состояние» снято совсем (§8б). Поиск идёт по термину и переводу, но НЕ по
описанию: поле, по которому ищут, работает оракулом к тому, что закрыто спойлером (нашло
адверсариальное ревью). Фильтр по типу — с счётчиками («имена — другая таблица, чем термины»,
слово владельца), состояние банка — в подвале.
**Спойлер переставлен:** закрыт СМЫСЛ (описание), перевод виден всегда. Прежнее поведение
(блюр перевода) владелец назвал ошибкой, и он прав по существу: строку, у которой закрыт
перевод, невозможно проверить — а таблица существует ровно ради проверки.
#### 4. Жалобы: без чего эти экраны не заработают на живой платформе
Владелец 10.08 разрешил жаловаться прямо. Три вещи, и все три — не в зоне фронта:
1. **У чтения `GET /books/{id}/bank` нет канала вообще.** Банк живёт в приватном SQLite движка,
читать его платформе запрещено (D39.85), а артефакта экспорта банка нет. Это записано
в компаньоне контракта (`14-api-contract/README.md` §3: «строки нет — заводит оркестратор»),
и строки в едином бэклоге я не нашёл до сих пор (`docs/PROGRESS.md`, номера 99101 и 104
в живом файле отсутствуют). **Пока её нет, вся витрина банка стоит на моке.**
2. **Поля, ради которых банк проверяют, движок производит, а контракт не несёт.** См. §3:
`note`, `gender`, `aliases`, `variants`, `evidence`, `contexts`, `freq`, `confidence`.
Черновик правки — ниже. Без них таблица подписи на живых данных будет беднее, чем
`.bank-stop.json`, который движок уже пишет на диск.
3. **Подписной механизм (S5) упирается в то же.** `POST /bank/decisions` и `resume` в контракте
есть, но продавать их экраном, пока нет канала чтения, нельзя. Поэтому в S3.6 построен ВИД
и честное состояние, а действие «подписать» не нарисовано вовсе — кнопка без действия
запрещена нормой зоны.
#### 5. Черновик правки спеки — «предложено», ратифицирует оркестратор
Повторяет черновик S3.5 и УСИЛЕН грунтом из движка (тогда грунт был косвенный). Предлагается
добавить в `BankTerm` необязательные поля — проекцией машинной стоп-таблицы `tm-bank-stop-v1`:
| Поле | Что это | Откуда движок берёт |
|---|---|---|
| `note` | описание/обоснование термина — то, что закрывается спойлером | `seed.Term.Note` (`seed/seed.go:79`) |
| `gender` | род: нужен постчеку склонения, а человеку — чтобы проверить строку | `seed.Term.Gender` (`seed/seed.go:70`) |
| `aliases` | прочие поверхности той же сущности | `seed.Term.Aliases`, `miner.Term.Aliases` |
| `variants` | что предлагали черновики, с числом голосов | `BankStopVariant` (`mining.go:434-447`) |
| `evidence` | улики паттернов, по которым термин выделен | `miner.Term.Evidence` (`miner_emit.go:50`) |
| `contexts` | KWIC исходника — главный материал проверки И главный спойлер | `BankStopRow.Contexts` (`mining.go:407`) |
| `freq`/`spread`/`confidence` | частота, разброс вариантов, уверенность роли | `BankStopRow` (`mining.go:403-425`) |
⚠ Два предупреждения к правке, которые важнее самой правки:
`contexts` — цитаты исходника из ЛЮБОГО места книги, то есть спойлер по построению: их отдача
обязана быть ограничена окном прочитанного либо закрываться на клиенте.
`confidence`, `signals`, `variants` раскрывают, что термин собирали НЕСКОЛЬКО источников —
это граница ПТ-33 (интерфейс не раскрывает конвейер); отдавать их можно, но называть на экране
без слов владельца нельзя. Поэтому в таблице их сегодня нет и без ратификации не появится.
#### 6. Самопроверка исполнением
- `npm run check` — зелёный, **154 теста**; `npm run scenes` — семь сценариев, **40 проверок**
(добавлен `bank`, усилен `tabs` проверкой обрезки, `drag` сверяет числа vojo, в двух сценариях
теперь стоит прогон axe по ОТКРЫТОМУ состоянию);
`npm run shot` — семь маршрутов, **ноль узлов контраста** у axe (палитра менялась целиком,
поэтому Ф-11 пересчитана прогоном, а не рассуждением).
- Свои дефекты, пойманные ИСПОЛНЕНИЕМ по ходу: фильтр-группа банка ехала без имени (axe-риск) —
добавлено `aria-label`; кнопка «Открыть таблицей» текстом ломала строку фильтра в 320px —
стала иконкой; `--panel-header-height` и `Context.module.css` остались мёртвыми после правки
вкладок — сняты (замок «токен без применения» сработал как задумано).
#### 7. Адверсариальное ревью диффа (author≠reviewer) — находки и диспозиции
| Находка | Диспозиция |
|---|---|
| **Подсказка строки банка печатала то, что закрыто спойлером.** Спойлер переехал на `sense`, а тултип строки (`card()`) по-прежнему собирал `смысл: …` — блюр закрывал глаз, тултип выдавал текстом | исправлено; закрыто проверкой в сценарии `context` («подсказка не выдаёт закрытое описание») |
| **Главное действие формы стало цветным вопреки собственному правилу.** `Button[look=primary]` красился в `--color-selected`, а тот с S3.6 — синий цвет выбранной строки. Комментарий рядом при этом утверждал «ступенью поверхности, а не цветом» | исправлено: заведён `--color-raised-strong`, правило §4.2 держится |
| **Счётчик в фишке фильтра — текст цветом `--color-text-muted`**, то есть 3.9:1 при пороге WCAG 4.5 — ровно то, что Ф-11 запрещает для текста | исправлено (`--color-text-secondary`) |
| **Новый экран не видел гейта доступности вообще.** `npm run shot` проверяет МАРШРУТЫ, а таблица банка и модалы открываются кликом — axe до них не доходил | исправлено механизмом, а не глазами: в сценарии заведён `audit(page)`, прогон axe теперь стоит на таблице банка и на модале настроек; обе — ноль нарушений, ноль узлов контраста |
| **Счётчики фишек считались по всему банку, а не по найденному** — при активном поиске фишка обещала строки, которых нет, и клик открывал пустую таблицу | исправлено: счёт по найденному |
| **Пустая полоса вкладок на маршруте без книг** читалась как сломанная шапка: хром есть, вкладок нет | исправлено: полоса без вкладок и без «+» прозрачна |
| **Название банка жило двумя литералами** (строка дерева и заголовок вкладки) | сведено в `bankDocumentTitle` |
| **Комментарии врали после правок:** `Reader.tsx` говорил «линия на скролл-контейнере» (она на содержимом с S3.5), `Tabs.tsx` — «вкладка документа синеватая, как файл во Fleet» (с S3.6 это акцент по кромке) | оба поправлены; правило прежнее — врущий комментарий хуже отсутствующего |
| **Прокрутка ряда вкладок скрытой полосой.** Ряд прокручивается, полоса скрыта (`scrollbar-width: none`) — мышью без клавиатуры до дальней вкладки добраться нечем, если бы не поведение Chromium: контейнер с ЕДИНСТВЕННЫМ горизонтальным переполнением крутится вертикальным колесом | принято как есть, поведение проверено в браузере кадра; если владелец увидит иначе — вернуть видимую полосу одной строкой |
| **Ширина строки перевода на 2K.** Мера набора отпущена по слову владельца (нов. 6), и на 2560×1440 строка идёт в ~120 знаков — типографски это много | **не чиню молча:** владелец прямо отверг ограничение шириной. Правильная ручка — плотность интерфейса (В-7/Ф-36), вопрос ему задан |
#### 8. Приёмка владельца в тот же день — три правки
**(а) Дубль банка спилен.** Первая редакция §3 открывала банк ещё и вкладкой-документом в центре,
оставив в правой панели справочник. Владелец: «зачем продублировал, спилить, перенести таблицу
вправо». Сделано: синтетический документ `bank`, строка в дереве и старый двухстрочный список
удалены целиком; **таблица переехала в правую панель** и она в приложении одна (проверяется
сценарием: `[role="grid"]` с этим именем ровно один). Правая панель стартует шире левой
(`--panel-context-width: 400px`, предел растяжки поднят до 900) — в ней теперь таблица, а не список.
Узкая панель не давит столбцы, а **роняет** их: «Смысл» приходит с 340px ширины таблицы, «Тип» —
с 520px; окно применимости стало припиской к термину («青茅 с 40-го»), потому что у 57 строк из 60
его нет вовсе и столбец стоял бы пустым. Порог пересекается только драгом панели.
**(б) Столбец подписи термина убран.** Вопрос владельца — «откуда ты взял, что каждый термин нужно
подписывать? это следует из бэкенда?» — законный, и ответ честный: **и да, и нет**. В движке
решение действительно ПОСТРОЧНОЕ: стоп снимается, лишь когда каждый предложенный термин promote
либо decline (`pipeline/mining.go:201`), и контракт это несёт (`BankDecision`, `TermStatus` на
строку). Но это МЕХАНИЗМ под стопом, а продуктовое действие одно — подпись всего банка. Показав
построчное состояние колонкой, экран начал спорить с продуктом: ровно то, на что владелец указал
ещё замечанием 20 первого круга («„подписано 10 из 60“ непонятно: что подписываем — банк или
термины?»). Снято и это, и счётчики «подписано / ждут решения»; в подвале осталось «60 терминов»
и, когда прогон реально стоит на подписи, состояние ВСЕГО банка — «банк ждёт подписи»
(из статуса книги `awaiting_bank`, а не из суммы строк). Словарь `termStatus` на шве остаётся:
он часть контракта и его сужения, просто не рисуется.
⚠ Один остаток называю прямо, решать не мне: в банке лежат и уже канонические термины (из сида),
и свежепредложенные. Сегодня таблица их не различает — а подписывают, по сути, вторые. Если это
окажется важно, различитель придётся вернуть, но уже НЕ как «состояние подписи строки».
**(в) Перформанс замерен, а не предположен.** Заведён сценарий `perf` (мир `/scale`, 1200 терминов):
| Что мерилось | Число |
|---|---|
| кадр при прокрутке таблицы, медиана | **17 мс** (~59 к/с) |
| худший кадр за прогон | 67 мс (одиночная заминка на первом кадре прокрутки) |
| строк в DOM из 1200 | **32** (417 узлов) — виртуализация жива |
| поиск: от ввода до перерисовки | **137199 мс** |
Пороги в сценарии грубые (медиана ≤ 34 мс, ≤ 80 строк в DOM, поиск ≤ 300 мс) — они ловят обвал,
а не колебания. Задержка поиска — это пересборка коллекции библиотеки на 1200 строк; на глаз она
на границе заметности, и если владелец её увидит, лечится дебаунсом ввода (строка бэклога не
заводилась: сначала замер на настоящем банке).
**Дефекты, пойманные исполнением в этой правке:**
| Находка | Диспозиция |
|---|---|
| **Таблица падала при смене состава столбцов** — «Cell count must match column count»: коллекция RAC не пересобирает строки, когда меняются СТОЛБЦЫ, а `items` те же | исправлено `dependencies` на `TableHeader`/`TableBody`/`Row`; ключ строкой, а не массивом (зависимости сравниваются по ссылке). Проверялось прогоном; попутно поймал себя на том, что первые две попытки «не помогли» из-за несобранного бандла — вывод в отчёт кладу после `build`, а не после правки файла |
| **Скролл-контейнер читалки был недостижим с клавиатуры** (`scrollable-region-focusable`) — текст раздела листался только мышью | исправлено (`tabIndex`). **Нашёл новый гейт**: axe по ОТКРЫТОМУ состоянию. Маршрутный гейт этого не видел, потому что на его кадре текст не переполнял панель — то есть дефект жил ровно там, куда старая проверка не смотрела |
| `--radius-round` осмиротел вместе с точками состояния | токен удалён (замок «объявлен, но не применён» сработал) |
#### 9. Второй заход приёмки — таблица, лаг и спойлер
Владелец посмотрел собранное и вернул четыре вещи. Все четыре — про то, что я смотрел на код,
а не на экран целиком.
**(а) «Лагает, пиздец» — и это правда.** Мой собственный замер из §8 говорил «медиана 17 мс,
всё хорошо». Медиана и была ложью: человек чувствует ДЛИННЫЕ кадры, а не середину распределения.
Пере-замер по p90 и по числу кадров дольше 50 мс, отдельно на dev-сервере (то, что открывает
владелец) и на сборке, при его вьюпорте 2560×1440:
| Прогон | p90 | худший | кадров > 50 мс |
|---|---|---|---|
| dev, ДО правок | 133200 мс | 250500 мс | 59 из ~150 |
| dev, после правок | 33 мс | 250433 мс | 45 |
| сборка, после правок | **17 мс** | 100 мс | 4 из 129 |
Найдено две причины, обе мои:
1. **Состав столбцов зависел от ширины.** Коллекция react-aria пересобирается, когда меняется
набор столбцов, — а я менял его прямо во время драга, на каждом пересечении порога: отсюда
кадры по 300500 мс. Адаптивность по ширине снята целиком; вместо неё у таблицы есть мера
(`--table-min-width`), ниже которой она уезжает вбок, а не ужимается.
2. **Строка поиска банка жила в состоянии ВСЕГО экрана.** Каждая буква перерисовывала дерево на
2284 раздела, читалку и статус-полосу разом: замер — 150 мс на символ. Состояние переехало
в сам банк; палитра перехода теперь не «пишет в строку поиска», а ПРОСИТ показать термин
(`requestedTerm`). Отклик поля: **451 мс на три буквы → 43 мс на символ**.
⚠ Честно про остаток: на dev-сервере 45 длинных кадров за драг остаются, и это НЕ банк — тот же
драг с открытой вкладкой «О книге» и на пустой библиотеке даёт ту же картину (замер). Дальше это
уже цена dev-режима React против сборки; на сборке p90 = 17 мс.
**(б) Столбец «Тип» на вкладке одного типа.** Владелец: «зачем мне при переключении на вкладку
с именем отображать её тип». Столбец теперь стоит, только пока фильтр показывает ВСЕ типы; на
любой вкладке одного типа он уходит, а его ширина достаётся переводу. Смена состава столбцов
при этом происходит по КЛИКУ, а не по перетаскиванию, — то есть в момент, когда пересборка
коллекции никого не бьёт по рукам.
**(в) Спойлер был выборочным, а описание спойлерно ЦЕЛИКОМ.** Прежняя логика закрывала строку,
если термин вступает в силу позже читаемого раздела: закрытыми оказывались два описания из
шестидесяти, а «спутница главного героя» — открытым. Владелец назвал это прямо: в этой колонке
спойлеры ВЕЗДЕ. Теперь спойлер — свойство КОЛОНКИ: закрыто всё и по умолчанию, раскрывается
наведением на саму ячейку. Позиция чтения из банка ушла совсем (и вместе с ней — плетение
`readingChapter` через три компонента).
**(г) Таблица должна читаться как таблица.** Что сделано, по одному приёму на задачу:
шапка прибита к верху и отделена линией; строки разделены волосяными линейками; между «Термином»
и «Переводом» — та же тёмная прорезь, что делит оригинал и перевод в читалке (один приём на два
экрана: слева язык книги, справа наш); исходная сторона набрана на пункт крупнее — иероглиф при
равном кегле читается мельче кириллицы; у термина и перевода есть подсказка полным текстом, у
описания её нет по построению; окно применимости стало припиской к термину и усекается первым.
Плюс к этому: описание раскрывается и с КЛАВИАТУРЫ (по фокусу строки), иначе таблица читается
только мышью; подвал при сужении говорит «показано N из M» — иначе на экране два разных числа
(счётчик фишки «все» и размер банка) без объяснения, почему они разные.
Плагин `frontend-design` при этом действительно был подключён и не сработал сам — его процесс
(план → критика плана → сборка → критика кадром) я применил руками.
Гейты после захода — числа сняты прогоном на итоговом дереве: `npm run check`**156 тестов**;
`npm run scenes` — семь сценариев, **52 проверки**, из них замерных шесть; `npm run shot` — семь
маршрутов, ноль нарушений axe и ноль узлов контраста.
#### 10. Ревью двумя агентами (author≠reviewer) — что нашли и что с этим стало
По слову владельца пущены два независимых ревьюера на дифф: один по нормам фронта, второй
адверсариальный. Оба поймали дефекты по существу, и оба — в том, что я правил ПРЯМО во время
ревью. Это само по себе находка, и она их: семь перезаписей ревьюируемых файлов за полтора часа
— не ревью-пригодное состояние, а два самых дорогих дефекта (1 и 6 ниже) въехали правками,
сделанными ПОСЛЕ последнего прогона гейтов.
| Находка | Что было | Диспозиция |
|---|---|---|
| **Кэш коллекции замораживает замыкания `render`** | `dependencies` покрывал только состав столбцов, а `render` берёт извне `sourceLang` и `senseShown`. Если банк ответил раньше книги, `lang` на исходной стороне не появился бы НИКОГДА: кандзи ушли бы в китайские начертания | исправлено: `Table` принимает `dependencies` от вызывающего, банк передаёт `[sourceLang, senseShown]` |
| **Переключатель «показать смыслы» не работал** | тот же кэш: состояние менялось, ячейки оставались закрытыми. Проп `dependencies` был заведён от ЭТОЙ болезни за три минуты до того, как я написал переключатель, им не воспользовавшись | исправлено вместе с находкой выше; закрыто проверкой сценария |
| **Спойлер закрыт только глазам** | блюр не существует для скринридера: описание зачитывалось вслух целиком | описание вынуто из дерева доступности (`aria-hidden`), пока не раскрыто; раскрывает переключатель — единственный способ, работающий и для мыши, и для клавиатуры, и для чтеца |
| **Раскрытие по фокусу вскрывало спойлеры подряд** | я добавил `[data-focus-visible] .sense`, и обход стрелками открывал по описанию на нажатие | снято; проверка сценария теперь утверждает ОБРАТНОЕ — обход строк не раскрывает |
| **Поиск по описанию — оракул** | `matchesTerm` искал по `sense`; палитра перехода находила термин по слову из ЗАКРЫТОГО описания и печатала его открытым текстом | поиск по описанию убран; палитра дополнительно называет окно применимости («с 300-го»), потому что контракт зовёт его границей спойлера |
| **Палитра ломалась со второго открытия** | перехват Escape не давал полю очиститься, следующая буква дописывалась к прошлому запросу; **сценарий делал ровно эти шаги и был слеп** | исправлено; проверка «открытая заново палитра начинает с чистой строки» |
| **Просьба палитры тонула в фильтре типа** | стоял фильтр «место», палитра вела к имени — и показывала пустоту. Тот же термин, попрошенный дважды, не срабатывал вовсе (равное значение — не изменение) | просьба сбрасывает фильтр типа и несёт номер; обе проверки в сценарии |
| **Мёртвая проверка и красный гейт** | `senses.every((entry) => entry.filter !== 'none')` на массиве СТРОК: `undefined !== 'none'` — всегда истина. Проверка прошла бы и со снятым блюром; `tsc` в этот момент был красным, а отчёт говорил «зелёный» | исправлено; вывод: числа в отчёт — только после прогона на замороженном дереве |
| **Замер перформанса был монотонен «не в ту сторону»** | ни одна проверка не утверждала, что действие произошло: залипшая прокрутка и застрявшая панель делали замер ЗЕЛЕНЕЕ | добавлены «таблица реально прокрутилась на N px» и «панель в ходе драга реально ездила на N px» |
| **Пустое состояние приезжало строкой коллекции** | `renderEmptyState` заворачивает заглушку в `role="option"`/строку таблицы: скринридер объявляет «Ничего не нашлось» выбираемым вариантом | пустое состояние рисуется ВМЕСТО коллекции |
| **Спойлера на длинном хвосте не существовало** | у всех 1200 терминов `/scale` было `sense: ''` — колонка пуста, механизм не проверялся ничем | фикстуре добавлены описания и окна применимости |
| **`aria-describedby` у подсказки поля** | подсказка в форме добавления книги была голым `<p>` | `Text slot="description"` — библиотека связывает сама |
| **Enter в дереве без гарда шеврона** | двойной клик шеврон исключал, Enter — нет | гард симметричен |
| Мелочи | `matchesTerm` приводил запрос внутри предиката (1200 раз на перерисовку) · `windowOf` считался дважды на строку · «1 терминов» | исправлено; склонение — через `Intl.PluralRules` |
**Не подтвердилось при проверке** (записываю, чтобы не переискивали): пустой банк, отсутствующий
`sense`, `kind: null` и незнакомое значение с провода обрабатываются корректно — сужение стоит на
шве; виртуализация на 1200 строках жива; пагинация банка дочитывает курсор.
Отдельно про выравнивание (требование владельца этого же захода): проверено не на глаз, а
замером положения ПЕРВОГО ГЛИФА в каждом блоке. Имена вкладок трёх панелей стоят на одной линии
(22 / 350 / 926 при полях панелей 12 / 340 / 916); в правой панели поиск, фишки, шапка столбца,
ячейка и подвал — все на 926. Двух расхождений не было видно глазами, но они были: строка дерева
начиналась на 4px левее имени вкладки, текст читалки — на 2px правее. Оба поля приведены
к полю вкладки.
#### 11. Третий заход приёмки: таблица, которую можно смотреть вблизи
Владелец разобрал таблицу по пикселям. Все его претензии подтвердились замером DOM, и у всех
оказалась ОДНА причина, которой я не знал, когда писал стили.
**Устройство виртуализованной таблицы.** Библиотека кладёт КАЖДУЮ ячейку в собственную абсолютно
позиционированную полосу высотой в шаг строки, а `role="row"` остаётся контейнером НУЛЕВОЙ высоты
(замер: полоса 32px, строка 1px, ячейка 18px). Я же красил строку и ячейку как обычные боксы.
Отсюда разом:
| Претензия владельца | Механика |
|---|---|
| «горизонтальные серые, вертикальные чёрные» | горизонталь рисовала строка, вертикаль — ячейка; разные элементы, разные токены |
| «чёрточки не доходят до конца, как отдельные элементы» | вертикаль шла по высоте ЯЧЕЙКИ (18px) внутри полосы 32px |
| «элементы прибиты под самый верх линий» | линия строки ложилась поверх ВЕРХНЕЙ кромки полосы, а текст не был центрирован в ней |
| «заголовки — не заголовки» | шапка отличалась от данных только тоном |
Сделано: линии рисует ячейка, ячейка тянется на всю высоту полосы, наведение и выбор красят через
потомка (`.row[data-hovered] .cell`) — у строки красить нечего, то есть подсветка строки до этого
вообще не работала. Все линии — один токен. Шапка набрана капителью с разрядкой.
**Обобщение, а не заплатка.** Тот же дефект нашёлся замером в дереве книг и в списках: строка была
20px в полосе 26px, то есть тоже липла к верху и оставляла неокрашенную щель. Правка перенесена
туда же.
**Спойлер.** Блюр применялся к боксу с `overflow: hidden` — обрезка резала размытие в прямоугольник
с резкими краями. Теперь закрытое описание НЕ обрезается (обрезает ячейка), а многоточие включается
только на раскрытом: размытие снова идёт по глифам.
**Ширины столбцов и перформанс — связаны, и это выяснилось замером.** Владелец: «драг банка влияет
на рисовку перевода, а драг дерева — нет». Замер подтвердил и локализовал: правый разделитель
p90 33 мс против 17 у левого. Причина не в читалке и не в скрытых вкладках (проверено по очереди):
доли `fr` заставляют библиотеку пересчитывать ширины столбцов на КАЖДЫЙ кадр изменения ширины
панели. A/B: `fr` — 33 мс, фиксированные px — 17 мс.
Решение без размена: ширины считает сам примитив (`spread()`), а ширину контейнера он берёт
УСТОЯВШЕЙСЯ (`useSettledWidth`, 120 мс) — во время драга столбцы не пересчитываются вовсе, после
отпускания встают по долям. Итог: **оба разделителя p90 17 мс**, столбцы по-прежнему текучие.
Плюс `contain: layout paint style` на ячейках — браузерная половина той же экономии.
**Перетаскивание границ столбцов** (отраслевая норма — MUI X, AG Grid) было сделано и работало
(сценарий тянул столбец 194→274px), но замер показал цену: p90 драга панели 33→**83 мс**, длинных
кадров 7→44. Снято, строка Ф-48 с числами и условием возврата.
⚠ Отдельно про «стандартный ли подход»: дерево книг — `Tree` библиотеки на семантике treegrid плюс
виртуализатор (`ui/Tree.tsx`), отступ уровня даёт сама библиотека через `data-level`, клавиатура —
тоже её. Таблица — `Table` той же библиотеки с `TableLayout`. Ни то, ни другое не самописное;
самописного в таблице ровно две вещи, и обе от замера: расчёт ширин долями и устоявшаяся ширина.
> ⚠ **Раздел 11 частично СНЯТ разделом 12 (10.08).** `spread()` и `useSettledWidth` удалены:
> владелец нашёл на них баг (панель, сжатая до упора и разжатая обратно, оставляла таблицу сжатой),
> а перезамер показал, что заморозка ширин ничего не покупала — драг держит p90 17 мс и без неё.
> Абзац про `fr` = 33 мс воспроизвести на текущем дереве не удалось; см. раздел 12.
#### 15. Закрытие сессии S3.6: ресёрч, долги, чистка
**Ресёрч отдельным агентом по четырём моим предложениям — два опровергнуты, и оба я проверил сам.**
- **«Клиент не идёт по `next_cursor`» — НЕВЕРНО, это была моя ошибка.** Курсор обходится
централизованно: `src/api/client.ts``requestAll()` с потолком в 50 страниц, и через него идут
ВСЕ пять списковых чтений (`queries.ts`), а тест `api/api.test.ts` читает 2284 главы страницами
по 500. Я утверждал обратное владельцу, не заглянув в единственный файл, где механизм и живёт.
Урок ровно тот же, что и с блюром: сначала найти, ЧЕМ вещь делается, потом судить.
- **«Suspense + переходы принципиально лучше» — НЕ КУПИТ заявленного.** Открытие главы — это
МОНТИРОВАНИЕ новой панели-вкладки, а переход React не показывает фолбэк лишь там, где содержимое
уже раскрыто внутри той же границы. Новая граница даст то же мигание, только фолбэком. Плюс три
жёстких препятствия: у `useSuspenseQuery` нет `enabled` (а он несёт смысл в шести местах),
границ ошибок в проекте нет вовсе, и флаг готовности скриншот-цикла живёт ВНУТРИ `Showcase`
приостановка снимет кадр ожидания с маршрута `/loading`. Строку не завожу.
- **«Предзагрузка соседей» — не надо:** навигация по книге прыжковая (дерево, палитра, сводка
замечаний), кнопок «следующая/предыдущая» нет вовсе, так что соседи по номеру — не то, что
откроют следующим.
- **«Свежесть текста по событию» — ПОДТВЕРЖДЕНО и заведено (Ф-49)**, с двумя поправками к моей
формулировке: это не «устаревает на 15 секунд», а «не обновится вообще, пока не уведёшь фокус из
окна»; и инвалидации на кадре `chapter` мало — нужна и на смене `status`.
Сверх списка агент нашёл три вещи, все проверены и заведены: Ф-50 (кадр, пришедший до появления
чтения в кэше, теряется молча, а отметка ревизии уже сдвинута; плюс «рваный» многостраничный
список под ревизией последней страницы) и Ф-51 (`resync` сбрасывает один гард из двух).
**Свой дефект, внесённый в этой же сессии и снятый по его наводке:** предзагрузка висела ещё и на
`onFocus` строки дерева. Замерил: стрелки двигают ФОКУС, не трогая ни выбор, ни открытую вкладку —
то есть каждое нажатие стрелки читало главу, которую никто не открывал. Ровно тот сценарий
«2284 запроса», который я сам же отверг для наведения. Осталось только нажатие кнопки.
**Чистка перед сдачей.** Комментарии в своих файлах подрезаны (`ui/Table.tsx` 106 → 73 строк
комментария при 391 → 358 строках всего; так же в `Table.module.css`, `Bank.module.css`,
`Tabs.module.css`, `scenes.mjs`), улики остались здесь, а не в коде. Маркеров `TODO`/`FIXME`/
«временно» в зоне нет, мёртвых классов CSS-модулей нет, экспортов без импортёров нет, снимки и
инструменты замера в `.gitignore`.
**Долги сессии — что НЕ сделано и почему.**
| Долг | Почему не сделано |
|---|---|
| Ф-49 · Ф-50 · Ф-51 (свежесть текста, потерянные кадры, resync) | найдены в последний час; правки трогают слой данных, а не экран — делать их в конце длинной сессии без замера рискованнее, чем занести |
| Ф-48: база замера цены ресайза столбцов устарела (p90 33 → 17 мс) | возврат ресайза требует замера заново, а не ссылки на старое число |
| Спойлер достаётся поиском по странице и печатью в PDF | цена того, что текст остаётся текстом страницы; альтернатива — не держать его в документе, это продуктовое решение |
| Канвас не знает `lang`: при двух CJK-гарнитурах под одним слагом замер может разойтись с отрисовкой | на стенде одно лицо, расхождение 0px — проверять на машине с SC+JP |
| RTL: спаривание столбцов идёт по координате, порядок в DOM перевернётся | сегодня недостижимо (RTL-локаль не включается), проверить первым делом при появлении `I18nProvider` |
| Сцены гоняются на 1440×900 и в основном на `/showcase` | зум 125/150/200% и узкая панель замерены руками в этой сессии, но сторожа на них нет |
| Возврат вкладок к fleet.png сверен ГЛАЗОМ, а не `measure.py` | владелец просил «примерно как во fleet.png»; пиксельная сверка не делалась и здесь названа |
| Механизм `stretch` в `ui/Table.tsx` в приложении недостижим (у «Смысла» нет `fit`) | это защита примитива от пустоты справа, носителя и сторожа у неё нет — осознанно |
**Открыто на владельца:** В-3 (слово ступени замечания), В-4 (подпись главы без заголовка), В-6
(фраза паузы), В-7 (масштаб интерфейса), В-8 (слово состояния в дереве), Ф-38 (защита вкладки
«Замечания»). **На оркестратора:** два черновика правки спеки (поля `BankTerm`, `BookIntake.title`)
и жалобы Ф-43…Ф-47 (канала под банк нет).
#### 14. Мигание при открытии главы (замечание владельца 10.08)
«Вижу сначала мигание — квадрат с надписью, не успеваю прочитать, — затем глава. Похоже на гонку.»
Квадрат — карточка `Blank` со словами «Раздел · Идёт загрузка»: клик открывает НОВУЮ вкладку, её
запрос главы пуст, и `Loaded` честно рисует ожидание.
**Замер до правки** (покадрово, что нарисовано в центре): холодный кэш — карточка видна
130230 мс; тёплый (та же глава второй раз) — карточки нет вовсе. То есть лечить надо ОЖИДАНИЕ,
а не показ.
Сделано двумя ходами, оба — отраслевая практика, ни одного таймера в компоненте:
1. **Чтение главы стартует по НАМЕРЕНИЮ, а не по клику.** `onPreload` у дерева срабатывает на
нажатии кнопки и на приходе фокуса (клавиатура), экран зовёт `prefetchQuery`. Рука держит
кнопку 60100 мс — этого хватает, чтобы клик пришёл на готовый ответ. Не на наведение: провести
мышью по списку из 2284 разделов — это 2284 запроса, а нажатие бывает ровно одно на намерение.
Повторную предзагрузку свежих данных слой запросов отбрасывает сам.
2. **Карточка ожидания проявляется только через 220 мс** — отложенной анимацией, а не таймером
в состоянии: гоняться не с чем, а карточка, снятая до срока, попросту не была видна ни разу.
**Проверено обратное — что я не спрятал настоящее состояние:** на маршруте `/loading` карточки
видны (opacity 1). Сторож в сцене `tabs` смотрит НЕПРОЗРАЧНОСТЬ, а не наличие в DOM, и падает,
если снять оба механизма (проверено откатом).
#### 13. Возврат вкладок и левой колонки к fleet.png (по слову владельца, 10.08)
Владелец: «вкладки, которые ты сделал, не понравились, вернём как было примерно во fleet.png, то же
самое с левым столбцом дерева». В S3.6 я перестроил ряд вкладок по fleet_2: полоса — ХРОМ, активная
вкладка вырезана из неё цветом полотна. Вернул модель fleet.png: полоса — само полотно панели без
подложки, активная вкладка — скруглённая ПИЛЮЛЯ на нём, вкладка открытого документа синяя
(`--color-tab-active`, замер #142f4c), «плюс» стоит сразу за вкладками, а не у дальнего края.
Правило одно на все три панели (`ui/Tabs.module.css`), поэтому шапка «Книги» слева поехала вместе
с документами и вкладками справки — этого владелец и просил.
**Откатывал ВИД, не круг.** В тех же файлах лежат починки этого пака, и они остались: ряд
прокручивается вместо сжатия вкладок (иначе короткое название усекалось раньше длинного соседа —
жалоба владельца), предел ширины вкладки, курсив у предпросмотра, keep-alive панелей через
`content-visibility` (это половина экономии на драге), строка дерева во всю полосу виртуализатора.
Из дерева ушла только надбавка отступа, которую я вводил ради совпадения с полем вкладки.
Токены: вернулись `--panel-header-height` (26px) и `--color-tab-active`, ушёл `--tab-row-height`
полосы больше нет. Комментарии у `--color-panel` и `--color-chrome` переписаны: они описывали
снятую модель («активная вкладка, вырезанная из хрома») и стали бы враньём рядом с кодом.
#### 12. Четвёртый заход: четыре бага владельца и цена собственной перестраховки
Владелец прислал четыре бага и потребовал луп-ревью в Playwright — «не останавливаешь правки, пока
ревью не отпустит без блокеров». Ниже — что нашлось, включая то, чего в списке не было.
**Баг 1: шапка просвечивает, строки едут сквозь заголовки.** Причина того же класса, что в
разделе 11: фон шапки стоял на группе `role="rowgroup"`, а она НУЛЕВОЙ высоты — то есть не рисовал
ничего. Библиотека при этом сама заворачивает шапку в свою sticky-коробку с правильной высотой и
`z-index`, так что моё `position: sticky` было лишним. Фон переехал на ячейки заголовка, sticky
снят как чужая работа. Токен `--layer-sticky` стал мёртвым и удалён.
**Баг 2: панель, сжатая до упора и разжатая обратно, оставляла таблицу сжатой.** Это был мой
`useSettledWidth`. Ширины считались от «устоявшейся» ширины контейнера, но результат попадал в
коллекцию через `<Column width>`, а библиотека кэширует отрисованные колонки по объекту столбца
(`useCachedChildren` — WeakMap, сбрасывается только сменой `dependencies`). Столбцы не менялись,
кэш не сбрасывался, новая ширина не доезжала. Владелец назвал это «перепеформил и сделал хаки
вместо нормального решения» — так и было.
Снято целиком. Ширины теперь считает сама библиотека своим flexbox-алгоритмом (§9.7 CSS Flexbox,
`react-stately/.../TableUtils.mjs`): столбец получает долю `Nfr`, пол и потолок. При смене ширины
панели коллекция не пересобирается вовсе — раскладка пересчитывается нативно.
**Цена перестраховки — замер, а не рассуждение.** Раздел 11 объяснял заморозку числом «`fr` даёт
p90 33 мс». На текущем дереве это НЕ воспроизводится: без заморозки p90 = **17 мс**, длинных кадров
1 из 84. A/B по двум подозреваемым — блюр боксом и `contain` на ячейках — не сдвинул число ни на
миллисекунду (17 мс во всех четырёх комбинациях). Честный вывод: чем бы ни были те 33 мс, заморозка
их не лечила, а баг создала. Записано как урок: **число, замеренное до серии правок, не оправдывает
механизм после них** — перезамерять на том дереве, которое сдаёшь.
**Баг 4: текст не помещается в столбец.** Столбцы делили место поровну, не зная, что в них лежит.
Теперь примитив меряет содержимое: канвасом, шрифтом, снятым с живой ячейки, по ВСЕМУ банку
(`sample`), а не по видимой выдаче — иначе столбцы прыгали бы под рукой печатающего.
**Правильную модель нашёл с ТРЕТЬЕГО раза, и оба неверных варианта снял не я, а ревью.** Ошибка
обоих раз одного рода: проверял модель на той фикстуре, что под рукой, и объяснял себе остаток.
1. **Замеренная ширина как ПОТОЛОК** («текст поместится, когда есть место»). На дефолтной ширине
панели «Монах Цветочного Вина» и «Весенне-осенняя цикада» продолжали усекаться: столбец
перевода брал свою долю 172 вместо своего содержимого 181, а соседний столбец смыслов —
закрытый спойлером! — держал те же 172. Потолок никогда не бывает полом, поэтому колонка с
ограниченным аппетитом всегда проигрывает колонке без него. Я видел в замере «усечено 2 из 30»
и объяснил себе как «прозе не хватает», не посмотрев, ЧТО именно усечено.
2. **Замеренная ширина как ТОЧНАЯ.** Текст стал помещаться, но статическая ширина в алгоритме
библиотеки заморожена и не уступает вовсе. Замер ревьюера: один перевод длиннее ~28 знаков — и
таблица шире панели, колонка смыслов уезжает за правый край без видимой полосы прокрутки;
на зуме 150% то же самое, на 200% в DOM оставалось три столбца из четырёх. Ни одна фикстура
в репозитории до порога не дотягивала, поэтому ни один прогон дефекта не видел.
3. **Содержимое одновременно как потолок И как ВЕС** (принято). Столбец получает `${fit}fr` с
`maxWidth: fit`: пока места хватает, потолок останавливает его ровно на содержимом, а остаток
уходит прозе; когда места мало, столбцы уступают ПРОПОРЦИОНАЛЬНО тому, что в них лежит.
Это то самое, что делает `flex-basis` в CSS, — библиотека его не поддерживает (базис всегда 0,
режим только «расти»), и вес по содержимому даёт тот же эффект её же средствами.
Замер модели 3: на всех проверенных ширинах содержимое равно окну — 608/608, 520/520, 856/856,
на зуме 1.25/1.5/2× тоже (449/449, 329/329), все четыре столбца на месте. Длинный перевод (37
знаков) больше ничего не выталкивает: 127/246/125/110 при окне 608. Усечение остаётся там, где
ему и место: в прозе и, на самой узкой панели, в паре длинных переводов — с подсказками.
**Баг 3: блюр выходил за текст и мылил фон — четыре круга на одном экране.** Ошибка была в
понимании МЕХАНИКИ, а не во вкусе, поэтому механика записана:
- `text-shadow` — СОБСТВЕННАЯ краска элемента, и его же `overflow: hidden` режет её вплотную к
глифам: отсюда ровный вертикальный срез слева.
- `filter: blur()` применяется ПОСЛЕ сборки элемента, и обрезать его может только предок.
- Маска работает по КОРОБКЕ элемента, а размытие выходит за неё со всех четырёх сторон — всё
вышедшее маска срезает по прямой.
Круг 1: заменил фильтр на `text-shadow` («фильтр мылит фон») — получил срез слева; фон при этом
никогда и не мылился, у спана он прозрачный. Круг 2: добавил маску ради мягкого хвоста — она же и
рисовала рамку, а на раскрытом тексте читалась как затемнение конца строки.
**Третий круг — и вот тут я наконец ПОСМОТРЕЛ ЗАМЕРОМ, а не глазами по общему виду.** Владелец
снова вернул кадр: «всё ещё квадратные, выходящие за текст». Я снял профиль яркости поперёк
размытой строки (скрин ×4 → канвас → максимум по столбцам и по строкам) и получил ответ, который
опроверг мою же гипотезу: **обрыва нет вообще.** По вертикали яркость идёт гладко 24 → 64 → 24,
единственный скачок в 14 единиц — это 1px линии-разделителя; по горизонтали максимальный перепад
2 единицы. Резать было нечему.
Прямоугольники делал РАДИУС. На кегле 13px размытие 5px слепляет слова в сплошные бруски с
прямыми верхом и торцами — на увеличении ×4 это видно сразу, а на общем кадре я это трижды
пропустил. На 3px форма слов сохраняется, и прочитать по-прежнему нельзя.
Принято: `filter: blur(3px)` и больше ничего. Обвязка, накопившаяся за три круга — маска, перенос
полей с ячейки на текст, `text-shadow`, `forced-color-adjust`, — снята вся; перенос полей вдобавок
не давал ничего (расстояние от глифов до обрезки в обоих случаях одинаковое, 8px). Осталось три
правила: обычный текст с многоточием, размытие без обрезки, снятие размытия при раскрытии. Фильтр
заодно держится в режиме высокой контрастности сам, без опт-аута (замерено).
**Урок, и он дороже самой правки.** Четыре круга по одному экрану ушли на то, что я правил ВИД,
не разобравшись в МЕХАНИКЕ, а потом на каждом круге навешивал ещё один приём поверх предыдущего.
Слова владельца: «не хакай решение, нужно чистое; оно у тебя уже было, потом ты всё сломал». Два
правила себе: (1) у визуального дефекта сначала выясняется, каким свойством он производится, —
и меряется, а не осматривается; (2) если правка добавляет ТРЕТИЙ приём к тем же двум строкам —
это признак, что диагноз неверен, а не что нужен четвёртый.
**Что нашёл сам, чего в списке владельца не было** (он просил ловить классы, а не пункты):
| Находка | Как нашлась |
|---|---|
| Ширина «Перевода» была на 1px меньше нужного, и `Весенне-осенняя цикада` получала многоточие при 412px пустоты рядом: замер не учитывал ЛИНИЮ столбца, а коробка считается по `border-box` | глазами на кадре, потом замером |
| Пустая выдача убирала таблицу целиком вместе с шапкой, а сообщение улетало в низ панели под подвал: пустой коллекции библиотека отводит всю высоту под собственную заглушку (замер: 702px пустоты) | обход состояний кадрами |
| Замер шёл по ЗАПАСНОМУ шрифту: канвас ничего не рисует, поэтому сабсет по `unicode-range` не грузится, а `document.fonts.ready` обещает только уже запрошенные лица. Кириллица мерилась на 4.7% шире (171.7 против 164.0) | адверсариальное ревью агентом + собственная проверка |
| `getComputedStyle` возвращает ЖИВОЙ объект: за время ожидания шрифтов строки успевали исчезнуть, и все столбцы мерились шрифтом канваса по умолчанию | замер после первой же правки — сломалось видимо |
| Библиотека кладёт под каждый столбец собственный пол в 75px, если пола не задать: столбец уже 75px (заголовок «ТИП» — 44) сидел бы в яме | ревью алгоритма библиотеки |
| Если бы измеренная ширина была у ВСЕХ столбцов, остаток не достался бы никому — таблица кончалась бы, не доходя до своего правого края (ровно та пустота справа, на которую жаловался владелец) | ревью алгоритма; закрыто структурно |
| **Регрессия моего же спойлера: в режиме высокой контрастности он исчезал целиком.** Система там снимает `text-shadow` и перекрывает `color` — все описания читались открытым текстом. Прежний фильтр бокса в forced-colors не снимался, то есть новый механизм отдал этих пользователей | адверсариальное ревью замером; проверено эмуляцией |
| Закрытое описание выделялось и копировалось: `Ctrl+A` отдавал все смыслы книги разом | то же ревью; закрыто `user-select`, поиск по странице остаётся открытым каналом — это записано, а не замолчано |
| Пол столбца брался от заголовка, но эффект замера не видел ПЕРЕИМЕНОВАНИЯ столбца: локализация заголовка при том же `id` оставила бы старый пол навсегда | ревью; ключ эффекта теперь несёт и имена |
| Нефинитная ширина (пустой computed style → NaN) уводила flex-цикл библиотеки в бесконечность: он замораживает элементы по ЗНАКУ отклонения, а у NaN знака нет | ревью; закрыто проверкой на конечность |
| **Столбец термина мерился одной гарнитурой, а рисуется двумя плюс отступ:** окно применимости («с 40-го») идёт вторым спаном на 12px с зазором 4px, которого в замере не было. Замер ревьюера: нужно 120, есть 119 — окно усекалось в каждой строке столбца, который по контракту «ровно своё содержимое», и дочитать его было нечем | ревью; теперь меряется и зазор (как пробел), у окна появилась подсказка |
| Переполнение таблицы САМОЗАПИРАЛОСЬ: за краем панели виртуализатор роняет столбцы из DOM, а замер требует их все — и ширины залипали на неверных, пока человек не прокрутит вбок. Снялось само вместе с переполнением | ревью; закрыто моделью 3 |
| Приёмка не ловила переполнение вообще: проверка «сумма ширин равна ширине таблицы» зелёная и когда таблица уехала за край панели | ревью; добавлена отдельная проверка, падает на откате модели (замер: содержимое 559 при окне 520) |
| **Возврат с фильтра одного типа на «все» дёргал столбцы.** Измеренные ширины хранились ТОЛЬКО для видимых столбцов: пока фильтр прятал «Тип», его ширина выбрасывалась, и на возврате столбец на один кадр приходил обычной долей. Замер: `Тип=146 Смысл=146` один кадр, затем `133/159` — сдвиг соседней колонки на 13px | владелец увидел глазами; замерен покадрово, ширины теперь ПОМНЯТСЯ, сторож в сцене падает на откате (2 раскладки за 90 кадров вместо 1) |
**Приёмка.** `npm run check:full` зелёный: 160 тестов, 7 маршрутов, 7 сценариев, ноль блокирующих
нарушений axe, драг панели p90 17 мс. Новых проверок в сценариях одиннадцать:
шапка не просвечивает · столбцы занимают таблицу целиком (на своей ширине, у минимума панели и
после возврата) · **таблица не уезжает за край панели** · **столбцы по содержимому показывают его
целиком** · на пустой выдаче остаётся шапка и сообщение стоит под ней · спойлер держится в режиме
высокой контрастности · выделение закрытого описания ничего не копирует · спойлер проверяется по
механизму (текст прозрачен и нарисован тенью), а не по слову `filter` · подсказка сверяется с
фактическим текстом описания, а не с литералом фикстуры.
**Каждая ключевая проверка откатана и падает нужным текстом** — этим они отличаются от украшения:
`усечено: Весенне-осенняя цикада · Монах Цветочного Вина` при возврате потолка, `содержимое 559,
окно 520` при возврате точной ширины, `раскладок за 90 кадров 2` при возврате замены ширин,
`обрезка true` при возврате обрезки размытия. Прежняя формулировка («сумма ширин равна ширине
таблицы») выполнялась арифметикой всегда, пока места хватает.
⚠ Две ловушки в самих проверках, обе из ревью:
- «что нарисовано сверху» флейкало: пока прокрутка идёт, виртуализатор снимает указатель со всего
содержимого, и ответом становится сам грид. Ожидание транзиента — не ожидание успеха. Но и
комментарий рядом врал: `elementsFromPoint` идёт по порядку наложения, а не по прозрачности, и
дефект ловит только вторая половина проверки — плотность фона. Контрфакт замерен, комментарий
исправлен: врущий комментарий тут санкционировал бы снос работающей половины.
- Проверка «подсказка не печатает описание» сравнивалась с литералом из фикстуры: сменись фикстура,
и она молча стала бы пустой. Теперь сравнивается с фактическим текстом описания той же строки.
**Известные ограничения, записаны честно:** канвас не знает про `lang`, поэтому при двух CJK-лицах
(SC и JP) под одним слагом замер может разойтись с отрисовкой — на этом стенде стоит одно лицо,
расхождение 0px; RTL перевернёт порядок колонок в DOM, а спаривание идёт по координате — сегодня
недостижимо (локаль RTL в приложении не включается), но при появлении `I18nProvider` проверить
первым делом.
### 09.08 — шестая фронт-сессия (S3.5): фикс-пак оболочки по 24 замечаниям владельца
Промт `S35_SESSION_PROMPT.md` отработан. Ниже прогон по ВСЕМ 24 номерам владельца; молча
пропущенных нет. Кадры — `.shots/` (маршруты) и `.shots/scenes/` (интеракции), они не
коммитятся, поэтому в тексте называется, что на них видно, и чем это проверено.
**Чем принято.** `npm run check` зелёный (7 шагов, 144 теста). `npm run shot` — семь маршрутов,
**ноль узлов контраста у axe на всех семи** (было 6 на маршрутах с данными и 3 на пустых) и ноль
блокирующих нарушений. `npm run scenes` — пять сценариев интеракций, 19 проверок, все зелёные.
Наложение 1280×764 на `fleet.png` и кадр 2560×1440 сняты и разобраны (`FRONTEND_PLAN.md` §5.5).
**Что нашла собственная проверка исполнением, а не рассуждение** (записано, потому что каждый
из трёх — отдельный класс ошибки, и следующая сессия наступит на них снова):
1. **Замыкание внутри строки коллекции застывает.** Двойной клик по строке дерева не закреплял
вкладку: обработчик, повешенный на `TreeItem`, живёт в КОЛЛЕКЦИИ библиотеки и пересобирается
только при смене `items`, а `items` мемоизированы книгами и разделами — замыкание осталось
с первого рендера. Лечение: обработчик на обёртке дерева, которая перерисовывается вместе
с экраном; какая строка нужна, уже сказал выбор. Нашёл сценарий `scenes tabs`.
2. **Ключ строки сводки замечаний схлопывал три замечания одного раздела в одно.** У замечания
в контракте нет собственного идентификатора, а ключом был `chapter_id`. Лечение — позиция
в списке; проверка «в сводке ровно 4 строки» добавлена сценарием `scenes context`.
3. **keep-alive оставляет соседние вкладки в DOM — и это меняет способ ПРОВЕРКИ.** Глобальный
локатор по роли находит скрытую строку соседней вкладки и ждёт её видимости до таймаута.
Все сценарии теперь ищут внутри своей области; в `scenes.mjs` это записано комментарием,
потому что грабли не разовые.
| № | Замечание | Что сделано | Чем проверено |
|---|---|---|---|
| 1 | масштабы великоваты, особенно на 27″ 2K | **Разделено замером на две разные вещи.** При вьюпорте референса 1280×764 расхождения НЕТ: промежутки, ширины панелей 320·608·320, шаг строки 26 и высота пилюли вкладки 26 совпадают с Fleet — то есть «уменьшить всё» значило бы уехать ОТ референса. На кадре 2560×1440 нашлась настоящая беда другого рода: боковые панели держат ширину в пикселях, вся лишняя ширина доставалась центру, и строка перевода шла в ~150 знаков. Введён предел меры набора `--reader-width: 1040px` с центрированием блоков (~75 знаков в колонке). Физический размер на мониторе — вопрос масштабирования ОС, ручки «плотность интерфейса» у нас нет; цена ручки посчитана, строка Ф-36, вопрос владельцу В-7 | `scripts/measure.py`, кадры 1280×764 и 2560×1440 ДО/ПОСЛЕ |
| 2 | спам вкладками | Модель VS Code целиком: одиночный клик — вкладка ПРЕДПРОСМОТРА, одна на панель, заголовок курсивом; следующий одиночный клик замещает её; закрепление — двойной клик по строке дерева, двойной клик по самой вкладке, Enter на выбранной строке либо содержательное взаимодействие с содержимым; закрытие как было. Модель вынесена в чистый модуль `showcase/documents.ts` и проверяется юнит-тестами, а не кадром. **Что считаем содержательным взаимодействием:** ВЫДЕЛЕНИЕ текста в паре. Правки у нас нет, а выделение — единственный акт, который читатель совершает намеренно и который означает «эта глава нужна дальше»; прокрутка намеренно не пинит | `npm run scenes tabs` (5 кликов → одна превью; двойной клик → пин; следующий клик → новая превью рядом) + `documents.test.ts` |
| 3 | вкладки мелкие, слиты с панелью | Причина найдена замером, а не на глаз: высота пилюли у нас и так 26 (как в референсе), а разошлись ПОЛЯ и ПРОМЕЖУТКИ — у Fleet поле вкладки 10 и промежуток ~5, у нас было 8 и 2. Введены `--tab-inset: 10` и промежуток `--space-3`. Активная вкладка получила ступень заливки крупнее (`--color-selected` вместо `--color-raised`: разница с панелью была 4% светлоты) и полную яркость текста | наложение 1280×764, срезы через ряд вкладок |
| 4 | две кнопки поиска делают одно и то же | Роли разведены: лупа справа вверху — настоящая палитра быстрого перехода к разделу или термину (модальное окно, ввод + список, выбор раздела открывает превью-вкладку, выбор термина открывает банк с фильтром на этом термине). Вкладка-заглушка «Поиск» из левой панели СНЯТА: построить полнотекстовый поиск нечем — у контракта нет ни одной поисковой ручки. Строка Ф-33 | `npm run scenes overlays` |
| 5 | выравнивание полос, «заметно по тексту снизу» | **Пере-замер нашёл точную причину.** У Fleet верхняя и статус-полоса прижаты к краям окна, а их содержимое центрируется в самой полосе: центр иконок y=17.75 при полосе 0‥36, центр текста статус-полосы y=749.75 при полосе 735.5‥764. У нас стояла разбивка «8 поля + 28 полосы», дававшая центры 22 и 745.5 — текст снизу висел на 4px выше и оставлял под собой пустую полосу. Токены стали 36 и 28, поле оболочки осталось только по бокам, поле текста полос — 12 (замер Fleet: 12.5 слева, 13.5 справа). Общая геометрия не изменилась: 36 + 836 + 28 = 900 | замер вертикальных срезов обоих кадров, зум статус-полосы ДО/ПОСЛЕ при `--dpr 1` |
| 6 | «О книге» — метаданных больше, подходы охранять | Список расширен ровно до границы контракта: языки, жанр, состояние, переведено %, разделов, блоков, знаков, замечаний, добавлена, а при наличии прогона — начат, завершён, потолок прогона в разделах, остановка на подписи. Ни моделей, ни роутинга, ни стадий конвейера, ни денег: разрешающий список контракта их не отдаёт, и восстанавливать их по косвенным полям — то же самое, что показать | кадр `/showcase`, вкладка «О книге» |
| 7 · 13 | представление банка «совсем не годится» | Перепридумано. Строка стала двухстрочной карточкой: исходная сторона, различитель смысла, ТИП термина, перевод и СОСТОЯНИЕ подписи словами контракта (`предложен` · `черновик` · `подписан`); полная карточка с окном применимости — в подсказке строки. Тип показан потому, что контракт прямо говорит: `name` и `place` маршрутизируют термин в транслитерацию, значит строка без типа подписывается вслепую; строка без типа честно говорит «тип не определён» (требование контракта, а не наша выдумка). **Спойлеры:** термин, чьё окно начинается позже читаемого раздела, раскрывает то, до чего читатель не дошёл — перевод закрыт размытием и открывается наведением, вместо него стоит «с N-го». Чего в контракте НЕТ (род/пол, обоснование, варианты) — не заведено ни в фикстуру, ни в типы; правка спеки предложена черновиком ниже. Строка Ф-39 | кадр `/showcase`, правая панель; спойлер виден на 青茅 (растение) и 不死凤凰 |
| 8 | вкладка «Замечания» — лишняя | **Защищена, но не тем, чем была.** По пер-ГЛАВНОМУ списку владелец прав: он дублировал читалку. Вкладка переделана в пер-КНИЖНУЮ сводку — фраза замечания плюс раздел, строка открывает раздел вкладкой. Сценарий, который без неё не живёт: «пройтись по всем местам книги, требующим внимания», не открывая разделы по одному. Лишних запросов это не стоит: контракт отдаёт замечания пер-книжно и по-другому не умеет (Ф-32). Финальное слово владельцу, строка Ф-38 | кадр `/showcase`, вкладка «Замечания» |
| 9 | оригинал и перевод разделены слабо | Приём РОВНО ОДИН: волосяная линия по жёлобу между колонками. Нарисована на скролл-контейнере, а не на каждом блоке — иначе она рвётся на промежутках между блоками и читается как таблица; суммарный отступ между текстами оставлен прежним, ширина колонок не поехала. Приглушение колонки оригинала не трогали — это хвост Ф-7 на S6 | замер: линия стоит ровно на 720.0 CSS при центре содержимого 720 |
| 10 | серо-синюю линию не удалось прочитать | Сделана СВЯЗЬ полоски со своей подписью: значок того же тона стоит у начала подписи, наведение на блок подсвечивает обе разом, подпись поднята с приглушённого тона до вторичного. **Ступень по-прежнему различается только цветом, и этот остаток честно не закрыт:** значок у обеих ступеней один, а СЛОВО ступени продуктовое и стоит на владельце (Ф-21, В-3) — выдумать его здесь значило бы сделать ровно то, что промт запрещает. То есть номер закрыт наполовину по решению, а не по недосмотру | кадр `/showcase`, две выноски в читалке |
| 11 · 19 | настройки: в правый верх и модальным окном | Шестерёнка — в правом верхнем углу, из низа левой панели вход убран (он там был единственным, теперь единственный здесь). Открывается модальным окном поверх оболочки. Внутри — честный каркас разделов: профиль и доступ · язык интерфейса · светлая тема · перевод по умолчанию · использование, у каждого одна строка о том, чего в нём пока нет. Переключателей, которые ничего не переключают, не нарисовано: это были бы кнопки без действия | `npm run scenes overlays`, кадр `modal-settings.png` |
| 12 | добавление книги — модальным окном | Сделано по `antigravity_add_folder.png`: выбор файла (настоящий, а не нарисованный — имя нужно живьём), поле названия и слоты будущих пер-книжных настроек. Два источника названия разведены и названы ДО нажатия: пустое поле — «название определит разбор файла», заполненное — «название задано вручную: разбор его не перепишет». Сам аплоад не строится (Ф-26) — это экран S4 | `npm run scenes overlays`, кадр `modal-add-book.png` |
| 14 | слишком точная копия Fleet | Доводка, не редизайн: поверхности ушли в тёплый подтон той же светлоты, вкладка открытого документа уведена из синего Fleet в тон акценту — «открытый документ» и «фокус» теперь читаются одной системой, а не заимствованной парой. **Чёрный фон между окнами не тронут** (`--color-shell` тот же `#090909`), полоска ресайза его тоже не закрашивает | кадры четырёх палитр рядом |
| 15 | нет светлой темы и выбора языка | Цена посчитана честно, и дёшево не выходит ни то, ни другое — диспозиции в строках Ф-34 и Ф-35. Сделана та часть, что дешевле сегодня, чем завтра: локаль интерфейса сведена в ОДНО место (`ui/Locale.tsx`), прежде её порознь зашивали провайдер примитивов, имена языков, формат чисел и формат дат. Оба пункта названы в каркасе настроек честными строками | код + каркас настроек на кадре |
| 16 | фон главных окон серо-неприятный | Изучены рекомендации по тёмным фонам (не чистый нейтральный серый; слегка тонированные тёмные; текст не на максимуме контраста). Сняты кадрами ТРИ варианта — тёплый уголь, холодный графит, индиго. Выбран **тёплый**: он дальше всего от Fleet и подходит продукту, где основная работа — чтение. Холодный и индиго остаются альтернативами владельцу (пере-снимаются одной правкой `tokens.css`, значения в записи ниже). Контраст пересчитан: axe печатает НОЛЬ узлов на всех семи маршрутах | `palette-compare` (четыре кадра одним листом), прогон axe |
| 17 | иконки шакальные, текст рендерится странновато | **Причина найдена, а не замазана.** Она не в толщине, а в сетке: набор нарисован на сетке 24, рисуется размером 16, координата c уезжает в 2c/3, у геометрических форм (c кратно трём — рамка панели и её перегородка) центр штриха шириной 1px попадает ровно на ЦЕЛЫЙ пиксель и размазывается по двум половинкам. Проверены четыре варианта кадрами при `--dpr 1`: «штрих 2 при размере 16» и «размер 18, штрих 2» дают жирнее, но по-прежнему мыльно; полупиксельный сдвиг SVG даёт целые линии — взят он. **Отдельно важное про инструмент:** при дефолтных 2x этот дефект в кадр НЕ ПОПАДАЕТ вовсе, поэтому у `npm run shot` появился ключ `--dpr`. Текст статус-полосы: настоящий дефект был геометрическим (номер 5) плюс приглушённый тон; и то и другое исправлено. Цветная бахрома субпиксельного сглаживания остаётся — это норма платформы, расхождение уже принято в `FRONTEND_PLAN.md` §5.2 п.1 | зумы 1x ДО/ПОСЛЕ: тогглы панелей, дерево, статус-полоса |
| 18 | фиолетовая полоска залипает; форма полоски | **(а) Причина найдена в коде библиотеки, а не угадана:** `data-separator` становится `focus` по обычному `onFocus` (`dist/react-resizable-panels.js`: `z ? G = "focus"`), а мышиный pointerdown фокус ОСТАВЛЯЕТ — наш стиль красил это состояние акцентом, и после отпускания драга полоска светилась. Лечится `:focus-visible`, который мышиный фокус не показывает; клавиатурная доступность при этом сохраняется. **(б) Форма** скопирована подходом с vojo: зона захвата — весь геп, индикатор — пилюля 2×36 по центру, в покое невидима, под курсором 0.35, в драге акцент и рост до 48, на упоре min сплющивается, на упоре max вытягивается. Чёрный геп не закрашивается ничем: разделитель прозрачен всегда | `npm run scenes drag`: в драге opacity 1 → после отпускания opacity 0, при этом библиотека ДЕРЖИТ `data-separator="focus"` — сценарий проверяет именно это |
| 20 | «подписать банк» и «подписано 10 из 60» непонятны | Кнопка снята: она вела во вкладку-заглушку, то есть была кнопкой без действия, а счётчик не отвечал ни на один вопрос человека. На её месте — честное состояние банка словами контракта, без выдуманных глаголов: «60 терминов · подписано 10 · ждут решения 50». Механизм подписи — S5 | кадр `/showcase`, подвал правой панели |
| 21 | цветной статус книги — на иконке | Точка состояния переехала на угол иконки книги. Слово рядом осталось: цвет ДУБЛИРУЕТ его, а не заменяет — тексту цвета замечания и отказа не хватает контраста, точке порог для текста не предъявляется | кадр `/showcase`, левая панель |
| 22 | «где генерится оригинал названия главы?» | Вопрос владельца, ответ вписан оркестратором в промт; сессии тут делать нечего. Записано для полноты: сейчас метка приходит из фикстур мока, в бою — из ДАННЫХ книги (`heading`/`number`, К-3), а чем подписывать главу без заголовка — открытый В-4 на владельце (Ф-30) | — |
| 23 | системные надписи наезжают на название | Бейджу дано ФИКС-место шириной `--badge-width`, текст в нём выравнен вправо, название слева забирает всю свободную ширину и усекается первым — пересечься теперь нечем. Ширина взята по самой длинной метке словаря; заодно метка `paused` укорочена с «остановлена: лимиты» до «на паузе» — причина паузы и так печатается один раз, в статус-полосе, и дублировать её в самый длинный бейдж дерева незачем | кадр `/showcase`: «нужна подпись», «не разобрана», «на паузе» помещаются целиком |
| 24 | пометка черновика | Над текстом главы — мягкая строка со значком и тултипом: «черновой вариант, может быть перегенерирован», подробность в подсказке. Форма взята из данных контракта: пока прогон не довёл книгу до `ready`, и текст, и нарезка законно пересобираются. Неизвестное состояние тоже считается черновым (безопасная сторона), отсутствие книги пометки не даёт. Точнее было бы по ГЛАВЕ, но `units_done` контракта не разведён по фазам — строка Ф-37 | кадр `/showcase`, шапка читалки |
**Что закрыто в бэклоге:** Ф-19 (keep-alive панелей, сценарий `scroll`), Ф-11 (ноль узлов
контраста замером). **Заведено:** Ф-33..Ф-39.
**Адверсариальное ревью диффа (author≠reviewer, три независимые линзы — корректность, соответствие
заданию, энтропия).** Считаю нужным перечислить, потому что половина находок — мои дефекты, а не
придирки, и часть из них ревью поймало ровно там, где мой отчёт заявлял «сделано».
| Находка | Диспозиция |
|---|---|
| **Замечание 5 стало ХУЖЕ, а записано как исправленное.** `--bar-inset: 12` клался внутрь оболочки, у которой своё поле 8 → текст полос уехал на 20 от края окна при цели 12.5 (было 14). То есть правка увела ОТ референса | **Исправлено:** поле оболочки перенесено на область панелей, полосы стали полнокровными. Замер после правки: наш текст `x 12.5…1267.0`, `y 744.5…756.0` против `12.5…1266.5`, `744.5…756.0` у Fleet — сходится по обеим осям. Промежутки панелей не сдвинулись |
| **Тронуто слово, зарезервированное за владельцем.** Метка `paused` укорочена с «остановлена: лимиты» до «на паузе» ради ширины бейджа, а промт прямо запрещает трогать фразу паузы (В-6) | **Откачено.** Слово возвращено, бейдж усекается с тултипом, а сам вопрос вынесен владельцу строкой **В-8**. Урок записан в код: ширина колонки — не основание менять продуктовое слово |
| **Ключ строки палитры перехода собран из `term.src`** — ровно тот приём, против которого в соседнем файле стоит пятистрочный комментарий; в фикстуре уже лежит контрпример (два «青茅») | **Исправлено:** ключ — непрозрачный `term.id`. Заодно фильтр терминов стал ОДНИМ предикатом на банк и палитру (`showcase/terms.ts`): раньше палитра искала по склейке «src — dst» и находила то, чего банк, куда она ведёт, не показывает |
| **Действие ведёт в свёрнутую панель.** Выбор термина в палитре переключал вкладку правой панели, которая может быть свёрнута, — и не делал ничего видимого. `expand` из `shell/layout.ts` при этом остался без вызывающих | **Исправлено:** панель разворачивается вместе с действием |
| **Спойлер банка блюрил ВЕСЬ банк у книги без нумерации** (`number` = `null` → позиция чтения 0) | **Исправлено:** у книги без номеров берётся позиция в порядке чтения — она есть всегда |
| **Выноска смещена на 2px относительно блока без замечания**, поэтому её жёлоб уходил с волосяной линии читалки | **Исправлено:** прозрачная граница с обеих сторон, сетка общая |
| **Ф-19 закрыта механизмом, но исходный кейс приёмки (дерево на `/scale`) остался непокрытым:** после снятия вкладки «Поиск» дерево защищено не keep-alive, а тем, что рядом нет второй вкладки | **Исправлено частично:** `keepAlive` передан и левой панели, то есть механизм стоит и там; сценарий по-прежнему проверяет банк (1200 строк того же порядка), и это названо в Ф-19 |
| **Замечание 12 не имеет опоры в контракте:** у `BookIntake` нет поля `title`, а разведение двух источников названия — центральное требование номера | **Исправлено в бумаге:** вторая правка спеки предложена черновиком выше. Дисциплина, применённая к банку, к модалу сначала применена не была — это мой пропуск, а не находка о контракте |
| Три токена мертвы (`--color-current-line`, `--color-hint`, `--font-mono`), и замок полноты их узаконивал | **Исправлено:** у теста токенов появилось обратное направление — «объявленный токен кем-то используется», с именным списком зарезервированных под Ф-20. Гейт проверен живым нарушением |
| Комментарии-простыни, местами по три копии одного рассказа (код + тест + план) | **Сокращено:** тела остались в `FRONTEND_PLAN.md` §5.5, в коде — по одной строке со ссылкой. Это постоянное замечание владельца, и оно справедливо |
| `measure.py` содержал мёртвую функцию и обёртку-пустышку; `references/` не был ни в git, ни в `.gitignore` | **Исправлено:** мёртвый код снят, `references/` внесён в зонный `.gitignore` (чужие скриншоты в репозиторий не едут, кладёт их владелец) |
| `scenes` не гонялся ничем | **Исправлено:** `check:full` = check + build + shot + **scenes** |
| Дублируются стили поля ввода (`TextField` и `FilterField`), `Showcase.tsx` за нормой 150 строк | **Частично:** из `Showcase.tsx` вынесены модель дефолтных вкладок и сигнал скриншот-цикла (230 строк — по-прежнему за нормой, экран режется на `features/` при S4). Дубль CSS поля признан и оставлен: правка требует общего модуля стилей на два примитива, и делать её в фикс-паке оболочки — лишний риск. Строка Ф-40 |
| Спойлер раскрывается только наведением (клавиатура и тач не раскроют); у спойлерной строки метка подписи замещается на «с N-го» | **Принято как есть, названо:** раскрытие кнопкой внутри строки списка — вложенный интерактив, его роняет гейт доступности (Ф-17). Полная карточка термина живёт в подсказке. Строка Ф-41 |
| **Двойной клик МИМО строки раздела закреплял чужую вкладку.** Обработчик брал «текущий выбор», а строка книги и пустое место выбор не двигают — измерено прогоном: двойной клик по книге закреплял главу | **Исправлено:** строка берётся из САМОГО события, по `data-key`, который библиотека кладёт на строку. Покрыто сценарием |
| **Ветка Enter была мёртвым кодом, а доккоммент обещал, что она работает.** `usePress` библиотеки останавливает всплытие синтетического keydown, и обработчик на обёртке до него не доходил | **Исправлено:** перехват (`onKeyDownCapture`) — фаза перехвата идёт ДО обработчиков строки. Проверено прогоном: Enter по разделу закрепляет, по книге не трогает ничего |
| **Проверка палитры в сценарии была самоподтверждающейся:** на витрине одна вкладка предпросмотра открыта всегда, и «после выбора их ровно одна» прошло бы, даже если выбор не делает ничего | **Исправлено:** сценарий спрашивает ИМЯ открытой вкладки и имя найденной строки. Это ровно тот класс, ради которого мандат самопроверки и написан |
| **Escape не закрывал палитру с первого нажатия:** примитив поиска первым нажатием чистит свою строку и наружу событие не пускает | **Исправлено** перехватом; проверка добавлена в сценарий |
| **Линия жёлоба читалки уезжала бы на классических полосах прокрутки Windows:** фон стоял на скролл-контейнере, а он позиционируется по padding-box, куда входит полоса. На стенде полосы оверлейные, поэтому кадр этого показать не мог | **Исправлено:** линия переехала на СОДЕРЖИМОЕ. Отдельно ценно, что дефект нашёлся рассуждением о механизме там, где инструмент слеп |
| **Сигнал скриншот-цикла стал считать ЛЮБОЙ запрос**, включая фоновый рефетч по возврату фокуса и обновления от живого прогона, — кадр снимался бы в гонке с ними | **Исправлено защёлкой** «однажды сошёлся — больше не гаснет». Первая версия защёлки сработала слишком рано и объявила готовым маршрут, снятый ради ожидания, — поймал сам скриншот-цикл, защёлка теперь закрывается только после того, как что-то реально полетело |
| **Палитра: при 50 совпавших разделах ни один термин до списка не доходил** — обрезка резала общий список, а разделы стояли первыми | **Исправлено:** у разделов и терминов свои слоты; строка «показаны первые N» теперь показывается по факту обрезки, а не по совпадению длины |
| **Строка замечания без раздела никуда не ведёт, но курсор обещал переход** (раздел у замечания по контракту необязателен) | **Исправлено:** такой строке курсор возвращён в обычный |
| Шаг строки банка 46px против нарисованной коробки 40px: 6px между строками подсветка наведения не закрашивает | **Принято:** это шаг виртуализатора, а не высота коробки; комментарий токена исправлен, чтобы следующая сессия не искала эту высоту в CSS |
| Выбор термина в палитре ставит фильтр банка по исходной стороне, и различение двух легальных строк одного иероглифа на этом теряется | **Принято, названо:** фильтр — текстовый по построению; точное наведение на строку появится вместе с экраном подписи S5 |
| keep-alive не ограничен: семь закреплённых вкладок — семь смонтированных читалок и семь запросов | **Названо строкой Ф-42:** на фикстурных главах цена нулевая, на настоящих — нет |
**Черновик правки спеки — ПРЕДЛОЖЕНО, не ратифицировано (замечания 7 и 13).** Ратифицирует
оркестратор; в зонную копию `api-contract/openapi.yaml` эта сессия НИЧЕГО не вносила, потому что
правка спеки тянет за собой генерённые типы, а фикстура на выдуманной форме — известный класс
ошибки (Ф-14). Владелец просит на термин больше, чем контракт отдаёт:
| Предложено в `BankTerm` | Зачем | Откуда движок это возьмёт |
|---|---|---|
| `gender` (`m`/`f`/`n`/`plural`/`null`) | русский требует согласования: «Бай Нинбин сказал» ↔ «сказала». Сегодня род термина существует только в голове переводящей модели, и проверить его на подписи нечем | открытый вопрос к движку: в `glossary` такой колонки нет |
| `rationale` (строка, необязательная) | «почему предложен именно такой перевод» — то, ради чего подпись и существует; без обоснования человек подписывает вслепую | добытчик терминов знает контекст находки; вопрос, что из него легально показать (§4.1) |
| `variants` (массив строк, необязательный) | у термина бывает несколько кандидатов; сегодня приезжает один `dst`, и выбор человека сводится к «принять или переписать руками» | вопрос к автору контракта |
| — | **спойлерная граница НЕ нужна отдельным полем** | она уже есть: `since_chapter`/`until_chapter` — окно применимости, и S3.5 использует его именно как спойлерную границу |
Вторая правка спеки, из замечания 12 (нашло адверсариальное ревью — та же дисциплина, что
применена к банку, к модалу добавления сначала применена не была): у `BookIntake` четыре поля —
`file`, `source_lang`, `target_lang`, `genre`, и **`title` среди них НЕТ**. Между тем центральное
требование замечания 12 — «автор вводит название руками ЛИБО скипает, тогда название даёт
авто-парсер, и эти два источника надо различать». Предложено: `title` (строка, необязательная) —
пустое значение означает «взять из разбора файла». Различение источников на клиенте достаточно
для формы, но не для экрана загрузки S4: без поля в спеке введённое руками название отправить
некуда. Ратифицирует оркестратор.
**Изменённые файлы.** `src/tokens/` (tokens.css, reset.css, measures.ts, tokens.test.ts) ·
`src/shell/` (Shell.tsx, Shell.module.css) · `src/ui/` (Tabs, Tree, List, Button, Callout,
FilterField, Locale + новые Modal, TextField) · `src/showcase/` (Showcase, Library, Context,
Reader, format + новые About, Bank, Notes, Document, Settings, AddBook, Goto, documents,
documents.test; удалён rooms.ts) · `src/api/vocabulary.ts`, `src/api/index.ts` ·
`scripts/shot.mjs`, новые `scripts/scenes.mjs` и `scripts/measure.py` · `package.json` ·
`README.md`, `docs/FRONTEND_PLAN.md`, `docs/BACKLOG.md`, этот файл.
**Альтернативные палитры на случай, если тёплая не подойдёт** (меняются одной правкой блока
поверхностей `tokens.css`): холодный графит — панель `#14181d`, приподнятая `#1f242b`, выбранная
`#2b323b`, текст `#dde1e6`/`#8b939c`/`#6d747d`, вкладка документа `#16304e`; индиго — панель
`#16171f`, приподнятая `#21222d`, выбранная `#2e2f3d`, текст `#e0e0e8`/`#8d8d9b`/`#71717f`,
вкладка документа `#232455`.
### 08.08 — пятая фронт-сессия (S3): слой данных построен, контракт правлен до 0.2.0
**Замок открыт, и это проверено, а не принято на слово.** Контракт лежит в

View file

@ -11,8 +11,9 @@
"build": "vite build",
"preview": "vite preview",
"check": "prettier --check . && eslint . --max-warnings 0 && stylelint \"src/**/*.css\" && npm run contract && tsc --noEmit && vitest run",
"check:full": "npm run check && npm run build && npm run shot",
"check:full": "npm run check && npm run build && npm run shot && npm run scenes",
"shot": "node scripts/shot.mjs",
"scenes": "node scripts/scenes.mjs",
"prepare": "node scripts/githooks/install.mjs",
"contract": "spectral lint docs/api-contract/openapi.yaml --fail-severity=warn",
"contract:types": "openapi-typescript docs/api-contract/openapi.yaml -o src/api/schema.ts"

View file

@ -0,0 +1,81 @@
# Замер кадра: доли поверхностей, границы панелей, высоты полос, базовые линии текста.
# Инструмент существует ради нормы «работать замером, а не на глаз» (FRONTEND_PLAN.md §5):
# каждая сессия, трогающая масштаб и зазоры, обязана привести числа, а не впечатление.
#
# python3 scripts/measure.py references/fleet.png .shots/showcase.png
#
# Оба кадра приводятся к CSS-пикселям по своему масштабу (аргумент --scale, по умолчанию 2 —
# и референс, и наш снимок сняты при deviceScaleFactor 2).
import sys
from collections import Counter
from PIL import Image
def surfaces(image, top=8):
counts = Counter(image.getdata())
total = sum(counts.values())
return [
('#%02x%02x%02x' % color, round(100 * n / total, 2)) for color, n in counts.most_common(top)
]
def runs(values, minimum=2):
"""Отрезки одинакового значения подряд: (значение, начало, длина)."""
out = []
start = 0
for index in range(1, len(values) + 1):
if index == len(values) or values[index] != values[start]:
if index - start >= minimum:
out.append((values[start], start, index - start))
start = index
return out
def column_profile(image, y):
"""Цвета вдоль горизонтального среза — так видны границы панелей и промежутки."""
return [image.getpixel((x, y)) for x in range(image.width)]
def row_profile(image, x):
return [image.getpixel((x, y)) for y in range(image.height)]
def report(path, scale):
image = Image.open(path).convert('RGB')
print(f'\n=== {path}{image.width}x{image.height} физ, {image.width // scale}x{image.height // scale} CSS ===')
print('поверхности (доля кадра):')
for color, share in surfaces(image):
print(f' {color} {share:5.2f}%')
middle = image.height // 2
# Проба берётся по центру верхней полосы, а не в углу: у референса углы окна macOS скруглены,
# и в углу лежит чёрный фон рабочего стола, а не фон оболочки.
shell = image.getpixel((image.width // 2, 2 * scale))
print('\nфон оболочки (верхняя полоса): #%02x%02x%02x' % shell)
# Горизонтальный срез по середине: отрезки фона оболочки = промежутки между панелями.
strip = column_profile(image, middle)
gaps = [(start, length) for color, start, length in runs(strip) if color == shell]
print('промежутки фона по горизонтали (CSS: начало, ширина):')
for start, length in gaps:
print(f' x={start / scale:7.1f} w={length / scale:5.1f}')
# Вертикальный срез по левому краю панели: верхняя полоса и статус-полоса.
x = gaps[0][0] + gaps[0][1] + 4 if gaps else 20
column = row_profile(image, x)
bands = [(start, length) for color, start, length in runs(column) if color == shell]
print(f'полосы фона по вертикали при x={x / scale:.1f} (CSS: начало, высота):')
for start, length in bands:
print(f' y={start / scale:7.1f} h={length / scale:5.1f}')
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if not a.startswith('--')]
scale = 2
for a in sys.argv[1:]:
if a.startswith('--scale='):
scale = int(a.split('=')[1])
for path in args:
report(path, scale)

894
frontend/scripts/scenes.mjs Normal file
View file

@ -0,0 +1,894 @@
// Сценарии ИНТЕРАКЦИЙ: клик → кадр → проверка состояния. Статичный снимок половину пака S3.5
// не принимает — вкладки, драг ручки, модалы и спойлеры существуют только в движении.
//
// node scripts/scenes.mjs все сценарии, кадры в .shots/scenes/
// node scripts/scenes.mjs tabs drag только названные
//
// Падение сценария роняет команду: это приёмка, а не демонстрация.
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = resolve(root, '.shots/scenes');
const toolingRoot = resolve(root, '.tooling/root');
if (existsSync(toolingRoot)) {
const libs = resolve(toolingRoot, 'usr/lib/x86_64-linux-gnu');
process.env.LD_LIBRARY_PATH = [libs, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':');
process.env.XDG_DATA_HOME = resolve(toolingRoot, 'usr/share');
}
const { chromium } = await import('playwright');
const { default: AxeBuilder } = await import('@axe-core/playwright');
const { build, preview } = await import('vite');
/** @typedef {import('playwright').Page} Page */
/** @typedef {import('playwright').Locator} Locator */
/** @typedef {(name: string) => Promise<unknown>} Shot */
/** @param {boolean} condition @param {string} what */
function check(condition, what) {
if (!condition) throw new Error(`сценарий провален: ${what}`);
console.log(`${what}`);
}
/**
* Прогон axe по ОТКРЫТОМУ состоянию. `npm run shot` проверяет только маршруты, то есть экран,
* который открывается кликом (таблица банка, модалы), гейта доступности не видел вовсе.
* Политика та же, что в shot.mjs: контраст в отчёт, остальное роняет сценарий.
* @param {Page} page @param {string} what
*/
async function audit(page, what) {
const axe = await new AxeBuilder({ page }).analyze();
const blocking = axe.violations.filter((v) => v.id !== 'color-contrast');
for (const v of axe.violations.filter((v) => v.id === 'color-contrast')) {
console.log(` контраст (принято как есть): ${v.nodes.length} узл(ов)`);
}
if (blocking.length > 0) {
for (const v of blocking) {
console.error(`${v.id}: ${v.help}${v.nodes.length} узл(ов)`);
// Узел печатается сразу: без него «одно нарушение» приходится искать вручную.
for (const node of v.nodes)
console.error(` ${node.target.join(' ')}${node.html.slice(0, 160)}`);
}
throw new Error(`Доступность: ${blocking.length} нарушени(й) — ${what}`);
}
console.log(` ✓ гейт доступности: ${what}`);
}
/** @type {Record<string, (page: Page, shot: Shot) => Promise<void>>} */
const scenes = {
// Замечание 2 + приёмка промта: пять одиночных кликов — одна вкладка предпросмотра.
/** @param {Page} page @param {Shot} shot */
async tabs(page, shot) {
await open(page, '/showcase');
const tabs = page.locator('[role="tab"]');
// Строки дерева ищутся ВНУТРИ левой панели: те же названия разделов стоят и в сводке
// замечаний справа, и без области поиска локатор указывает на две вещи разом.
/** @param {string} name */
const chapter = (name) => page.locator('nav').getByText(name, { exact: true });
for (const name of [
'Нет раскаяния',
'Церемония открытия',
'Деревня Гуюэ',
'Аптека',
'Первый гу',
]) {
await chapter(name).click();
}
await shot('tabs-preview');
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'после пяти одиночных кликов ровно одна вкладка предпросмотра',
);
const before = await tabs.count();
await chapter('Аптека').dblclick();
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
'двойной клик по главе закрепляет вкладку — предпросмотра не осталось',
);
await chapter('Кровь на снегу').click();
await shot('tabs-pinned');
check(
(await tabs.count()) === before + 1 &&
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'следующий одиночный клик открывает НОВУЮ вкладку предпросмотра рядом с закреплённой',
);
// Двойной клик по самой вкладке — второй способ закрепления из модели VS Code.
await page.locator('[role="tab"][data-preview="true"]').dblclick();
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
'двойной клик по вкладке предпросмотра закрепляет её',
);
// ⚠ Двойной клик МИМО строки раздела не должен закреплять ничего. Прежняя реализация
// брала «текущий выбор», а книга и пустое место выбор не двигают — закреплялась чужая
// вкладка (нашло адверсариальное ревью замером, не рассуждением).
await chapter('Горная тропа').click();
const pinnedBefore = await tabs.count();
await page.locator('nav').getByText('Гу Чжэньжэнь', { exact: true }).dblclick();
check(
(await tabs.count()) === pinnedBefore &&
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'двойной клик по строке КНИГИ вкладок не трогает',
);
// Клавиатурный близнец двойного клика: Enter по выбранной строке закрепляет её.
await page.keyboard.press('Enter');
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'Enter по строке книги вкладок тоже не трогает',
);
await chapter('Горная тропа').click();
await page.keyboard.press('Enter');
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
'Enter по выбранной строке раздела закрепляет вкладку',
);
// ⚠ Opening a chapter must not flash a card. Watch OPACITY, not the DOM: the card is held back
// until the wait is worth explaining. The press is held 80ms, as a hand holds it — that is the
// window the chapter is read in, before the click completes.
await page.evaluate(() => {
/** @type {number[]} */
const seen = [];
Object.assign(window, { __cards: seen });
const tick = () => {
const card = document.querySelector('main h2')?.closest('div');
if (card && Number(getComputedStyle(card).opacity) > 0.02) seen.push(1);
if (seen.length < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
const row = await chapter('Утраченный свиток').boundingBox();
if (!row) throw new Error('строка раздела не найдена');
await page.mouse.move(row.x + row.width / 2, row.y + row.height / 2);
await page.mouse.down();
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(900);
check(
(await page.evaluate(() => Reflect.get(window, '__cards'))).length === 0,
'открытие раздела не мигает карточкой ожидания перед текстом',
);
// Замечание второго круга 4: короткое название усекалось раньше длинного соседа, потому что
// вкладки делили нехватку места пропорционально ширине. Теперь ряд прокручивается, и
// усечение бывает ТОЛЬКО у вкладки, упёршейся в свой предел.
const clipped = await page.locator('[role="tab"]').evaluateAll((nodes) =>
nodes
.map((node) => {
const title = node.querySelector('span');
const max = parseFloat(getComputedStyle(node).maxWidth);
return {
text: title?.textContent ?? '',
cut: (title?.scrollWidth ?? 0) > (title?.clientWidth ?? 0) + 1,
atMax: node.getBoundingClientRect().width >= max - 1,
};
})
.filter((tab) => tab.cut && !tab.atMax),
);
check(
clipped.length === 0,
`ни одна вкладка не усечена, не упёршись в предел ширины (усечено: ${clipped.length})`,
);
},
// Ф-19: переключение вкладки больше не размонтирует панель, прокрутка живёт.
/** @param {Page} page @param {Shot} shot */
async scroll(page, shot) {
await open(page, '/scale');
const bank = page.locator('[role="grid"][aria-label="Термины банка"]');
await bank.evaluate((node) => node.scrollTo(0, 9000));
const before = await bank.evaluate((node) => node.scrollTop);
check(before > 8000, `прокрутка банка встала на ${String(before)}px`);
await page.getByRole('tab', { name: 'О книге' }).click();
await page.getByRole('tab', { name: 'Банк' }).click();
await shot('scroll-kept');
const after = await bank.evaluate((node) => node.scrollTop);
check(
after === before,
`после ухода на соседнюю вкладку и возврата офсет тот же (${String(after)})`,
);
},
// Замечание 18: полоска ресайза не залипает после отпускания драга.
/** @param {Page} page @param {Shot} shot */
async drag(page, shot) {
await open(page, '/showcase');
const separator = page.locator('[role="separator"]').first();
const box = await separator.boundingBox();
if (!box) throw new Error('разделитель не найден');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
await page.mouse.move(box.x + 90, y, { steps: 12 });
await shot('drag-active');
const dragging = await handleStyle(separator);
// Числа — из vojo (style.css.ts): драг 0.55 и рост пилюли до 48px.
check(
dragging.opacity === '0.55',
`в драге пилюля проступает на ${dragging.opacity}, как в vojo`,
);
check(dragging.height === '48px', `в драге пилюля вырастает до ${dragging.height}, как в vojo`);
await page.mouse.up();
await page.mouse.move(box.x + 400, y + 200);
await shot('drag-released');
const released = await handleStyle(separator);
check(
released.opacity === '0',
`после отпускания пилюля погасла (opacity ${released.opacity}), полоска не залипла`,
);
check(
(await page.locator('[role="separator"]').first().getAttribute('data-separator')) === 'focus',
'библиотека при этом ДЕРЖИТ data-separator="focus" — залипание лечится стилем, а не удачей',
);
},
// Замечания 7, 8, 13, 20: панель контекста — сводка замечаний, банк со спойлерами, честное
// состояние вместо кнопки-заглушки.
/** @param {Page} page @param {Shot} shot */
async context(page, shot) {
await open(page, '/showcase');
const panel = page.locator('aside');
await panel.getByRole('tab', { name: 'Замечания' }).click();
await shot('context-notes');
// Ищем в СВОЁМ списке: соседняя вкладка при keep-alive тоже в DOM, и `[role="option"]`
// по всей панели считает заодно строки банка.
const summary = panel.locator('[role="listbox"][aria-label="Замечания книги"] [role="option"]');
// Ровно столько, сколько замечаний в фикстуре книги. Проверка не косметическая: ключ строки,
// собранный из раздела, схлопывал три замечания одного раздела в одну строку.
check(
(await summary.count()) === 4,
'сводка показывает ВСЕ замечания книги, включая несколько в одном разделе',
);
await summary.first().click();
check(
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
'строка сводки открывает свой раздел вкладкой предпросмотра',
);
},
// Замечание второго круга 7: банк — ТАБЛИЦА в правой панели (не вкладка в центре),
// со столбцами, поиском, фильтром по типу и спойлером на описании, а не на переводе.
/** @param {Page} page @param {Shot} shot */
async bank(page, shot) {
await open(page, '/showcase');
const panel = page.locator('aside');
await panel.getByRole('tab', { name: 'Банк' }).click();
await shot('bank-table');
const table = panel.getByRole('grid', { name: 'Термины банка' });
check(await table.isVisible(), 'банк живёт в правой панели и показан таблицей');
check(
(await page.getByRole('grid', { name: 'Термины банка' }).count()) === 1,
'таблица банка в приложении ровно одна — дубля в центре нет',
);
// Утверждаем ВЕСЬ набор целиком: проверка «нет столбца „Состояние“» прошла бы и на столбце
// «Подпись», то есть стерегла бы слово, а не устройство экрана.
// Сравниваем в нижнем регистре: заголовки набраны капителью средствами CSS, и `innerText`
// отдаёт их уже прописными — проверять надо СОСТАВ столбцов, а не приём набора.
const columns = (await table.getByRole('columnheader').allInnerTexts()).map((name) =>
name.toLowerCase(),
);
check(
JSON.stringify(columns) === JSON.stringify(['термин', 'перевод', 'тип', 'смысл']),
`столбцы таблицы ровно те: ${columns.join(' · ')} — состояния подписи среди них нет`,
);
// Owner's report, 10.08: the header's ground sat on a rowgroup of ZERO height and so painted
// nothing — scrolled rows read straight through the column titles. Ask what is drawn there.
await table.evaluate((node) => {
node.scrollTop = 400;
});
// What catches the defect is the GROUND'S OPACITY: `elementsFromPoint` follows paint order, not
// transparency (checked against the counterfactual). The hit test is only a gate — while a
// scroll runs the virtualizer takes the pointer off the content and the grid itself answers.
const header = await page
.waitForFunction(
() => {
const node = document.querySelector('[role="grid"]');
const th = node?.querySelector('[role="columnheader"]');
if (!th) return null;
const box = th.getBoundingClientRect();
const [top] = document.elementsFromPoint(
box.left + box.width / 2,
box.top + box.height / 2,
);
if (top === node) return null; // указатель ещё снят — прокрутка не улеглась
const parts = getComputedStyle(th).backgroundColor.match(/[\d.]+/g) ?? [];
return { role: top?.getAttribute('role'), opacity: Number(parts[3] ?? 1) };
},
null,
{ timeout: 5000 },
)
.then((handle) => handle.jsonValue());
check(
header?.role === 'columnheader' && header.opacity === 1,
`прокрученная строка не просвечивает сквозь шапку (сверху ${String(header?.role)}, плотность фона ${String(header?.opacity)})`,
);
await table.evaluate((node) => {
node.scrollTop = 0;
});
// Owner's report, 10.08: a panel squeezed to its stop and let back out left the table
// squeezed, with a gap down its right side. Columns must fill the table at ANY width.
const filled = async (/** @type {string} */ what) => {
const spread = await table.evaluate((node) => {
const heads = [...node.querySelectorAll('[role="columnheader"]')].sort(
(a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left,
);
const sum = heads.reduce((total, th) => total + th.getBoundingClientRect().width, 0);
// The prose column — the last one — stretches and clips legitimately: no width fits a
// description. The REST are sized off their own content, so clipping there is a defect.
const prose = Math.round(heads.at(-1)?.getBoundingClientRect().left ?? 0);
const cut = [];
for (const cell of node.querySelectorAll('[role="gridcell"],[role="rowheader"]')) {
const inner = cell.firstElementChild;
if (!inner || Math.round(cell.getBoundingClientRect().left) === prose) continue;
if (inner.scrollWidth > inner.clientWidth) cut.push(inner.textContent ?? '');
}
return {
sum: Math.round(sum),
grid: Math.round(node.getBoundingClientRect().width),
content: node.scrollWidth,
cut,
};
});
// Against the CONTENT width, not the window: the latter holds arithmetically whenever there
// is room to spare, so on its own it guards almost nothing.
check(
Math.abs(spread.sum - spread.content) <= 1,
`${what}: столбцы занимают таблицу целиком (${spread.sum} из ${spread.content}, окно ${spread.grid})`,
);
// ⚠ And it does not run past the panel: columns MUST give way when space runs short. A narrow
// panel catches it (before the fix: content 555, window 520).
check(
spread.content <= spread.grid + 1,
`${what}: таблица не уезжает за край панели (содержимое ${spread.content}, окно ${spread.grid})`,
);
return spread.cut;
};
/** @param {string} what @param {string[]} cut */
const fits = (what, cut) =>
check(
cut.length === 0,
`${what}: столбцы по содержимому показывают его целиком (усечено: ${cut.join(' · ') || 'ничего'})`,
);
fits('на своей ширине', await filled('на своей ширине'));
// At its stop the columns are cramped, and it shows whether they give way. Clipping is legitimate
// here, running past the panel is not.
// ⚠ Each drag's offsets count from ITS OWN start: letting the panel back out needs a second drag.
await pull(page, [400]);
await filled('панель сжата до упора');
await pull(page, [-200]);
fits('после сжатия до упора и возврата', await filled('после сжатия до упора и возврата'));
// Спойлер закрывает ОПИСАНИЕ, перевод остаётся читаемым.
const row = table.getByRole('row').filter({ hasText: 'Бессмертный феникс' });
// Разбираем цвет на числа, а не сравниваем со строкой: литеральный цвет в коде запрещён
// гейтом, и сравнивать всё равно надо со свойством, а не с тем, как его печатает браузер.
const covered = await row.locator('[data-sense]').evaluate((node) => {
const style = getComputedStyle(node);
return { blur: style.filter, clips: style.overflow !== 'visible' };
});
// ⚠ The second half guards the square edge: a blur mixes in what lies BEYOND the ink, so
// anything clipping at the glyphs turns it into a rectangle.
check(
covered.blur.startsWith('blur(') && !covered.clips,
`описание закрыто настоящим размытием и ничем не обрезано (${covered.blur}, обрезка ${String(covered.clips)})`,
);
check(
await row.getByText('Бессмертный феникс', { exact: true }).isVisible(),
'сам перевод при этом виден',
);
await row.locator('[data-sense]').hover();
await page.waitForTimeout(250); // переход цвета 140ms — иначе читаем середину анимации
check(
!(await isCovered(row.locator('[data-sense]'))),
'наведение на ячейку раскрывает описание',
);
// Скринридеру блюр не закрывает ничего, поэтому описание вынуто из дерева доступности,
// а раскрывает его переключатель — единственный способ, работающий и для мыши, и для
// клавиатуры, и для чтеца.
check(
(await row.locator('[data-sense]').getAttribute('aria-hidden')) === 'true',
'пока не раскрыто, описание вынуто из дерева доступности',
);
await panel.getByRole('button', { name: 'Показать смыслы терминов' }).click();
await page.waitForTimeout(300);
const revealed = await row.locator('[data-sense]').evaluate((node) => {
const parts = getComputedStyle(node).color.match(/[\d.]+/g) ?? [];
return {
hidden: node.getAttribute('aria-hidden'),
filter: getComputedStyle(node).filter,
// A revealed line does NOT fade out: a tail mask read as the end of the line going dark
// (owner's report, 10.08), and over a blur it clipped the whole thing into a rectangle.
mask: getComputedStyle(node).maskImage,
opacity: Number(parts[3] ?? 1),
pressed: document.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'),
};
});
check(
revealed.hidden === null &&
revealed.opacity === 1 &&
revealed.filter === 'none' &&
revealed.mask === 'none',
`переключатель раскрывает описания и для глаз, и для чтеца (${JSON.stringify(revealed)})`,
);
await panel.getByRole('button', { name: 'Скрыть смыслы терминов' }).click();
await page.waitForTimeout(250);
// Обход стрелками НЕ раскрывает описания: иначе спойлер вскрывался бы по одному на нажатие,
// а клавиатуре служит переключатель выше.
await page.mouse.move(0, 0);
// Кликаем по ЯЧЕЙКЕ: сам `role="row"` — контейнер нулевой высоты, кликать по нему нечем,
// и человек тоже попадает в ячейку.
await table.getByRole('rowheader').first().click();
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(250);
const walked = await page.evaluate(() => {
const cell = document.activeElement?.closest('[role="row"]')?.querySelector('[data-sense]');
return cell ? getComputedStyle(cell).filter !== 'none' : null;
});
check(walked === true, `обход строк с клавиатуры описания не вскрывает (закрыто: ${walked})`);
// ⚠ The spoiler must hold in FORCED COLOURS too: the glyph-shadow version vanished there
// outright — the system drops `text-shadow` — and printed every description in plain text.
await page.emulateMedia({ forcedColors: 'active' });
await page.waitForTimeout(200);
const forced = await row
.locator('[data-sense]')
.evaluate((node) => ({ filter: getComputedStyle(node).filter }));
check(
forced.filter.startsWith('blur('),
`в режиме высокой контрастности описание остаётся закрытым (${JSON.stringify(forced)})`,
);
await page.emulateMedia({ forcedColors: 'none' });
await page.waitForTimeout(200);
// Спойлер не должен утекать мимо блюра. Подсказки у термина и перевода легальны — проверяем
// ровно то, что закрыто: текст описания не встречается ни в одном `title` строки, а выделение
// мышью его не копирует — текст закрытого описания остаётся текстом страницы.
const titles = await row
.locator('[title]')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('title') ?? ''));
// Against the row's ACTUAL description, not a literal from the fixture: a nailed-down word
// turns the check silently vacuous the moment the fixture changes.
const hidden = (await row.locator('[data-sense]').innerText()).trim();
check(
titles.length > 0 && hidden.length > 0 && titles.every((title) => !title.includes(hidden)),
`подсказки строки (${String(titles.length)}) не печатают закрытое описание «${hidden}»`,
);
const copied = await row.locator('[data-sense]').evaluate((node) => {
const range = document.createRange();
range.selectNodeContents(node);
const selection = getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const text = selection?.toString() ?? '';
selection?.removeAllRanges();
return text;
});
check(copied === '', `выделение закрытого описания ничего не копирует (взято: «${copied}»)`);
// Узкая панель роняет столбцы, а не сжимает их: «Тип» приходит только на широкой.
// Описание закрыто у ВСЕХ строк, а не у выбранных: спойлер — свойство колонки.
// Увести и курсор, и ФОКУС: иначе раскрытой осталась бы строка, на которой они стоят.
await page.mouse.move(0, 0);
await page.getByRole('searchbox', { name: 'Поиск по банку' }).focus();
await page.waitForTimeout(250);
const senses = await table
.locator('[role="row"] [data-sense]')
.evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).filter !== 'none'));
check(
senses.length > 3 && senses.every(Boolean),
`описание закрыто во всех ${String(senses.length)} строках, а не в отдельных`,
);
const rows = () => table.getByRole('row').count();
const all = await rows();
await page.getByRole('radio', { name: /^имя/ }).click();
const names = await rows();
check(names < all, `фильтр по типу сужает таблицу (${String(all)}${String(names)})`);
check(
(await table.getByRole('columnheader', { name: 'Тип' }).count()) === 0,
'на вкладке одного типа столбец «Тип» уходит: он повторял бы её название в каждой строке',
);
await shot('bank-kind');
// ⚠ Every frame of the return, not just where it settles: a hidden column whose width was
// dropped comes back as a plain share for one frame — the neighbour jumped 13px.
await page.evaluate(() => {
/** @type {string[]} */
const seen = [];
Object.assign(window, { __layouts: seen });
const tick = () => {
seen.push(
[...document.querySelectorAll('aside [role="columnheader"]')]
.map((head) => ({ x: head.getBoundingClientRect().left, head }))
.sort((a, b) => a.x - b.x)
.map(
({ head }) => `${head.textContent}=${Math.round(head.getBoundingClientRect().width)}`,
)
.join(' '),
);
if (seen.length < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
await page.getByRole('radio', { name: /^все/ }).click();
await page.waitForTimeout(1600);
check(
await table.getByRole('columnheader', { name: 'Тип' }).isVisible(),
'на «все» столбец «Тип» возвращается — там он единственный способ увидеть тип строки',
);
const layouts = await page.evaluate(() => {
const seen = /** @type {string[]} */ (Reflect.get(window, '__layouts'));
return [...new Set(seen.filter((row) => row.split(' ').length === 4))];
});
check(
layouts.length === 1,
`возврат на «все» не дёргает столбцы: раскладок за 90 кадров ${layouts.length} (${layouts.join(' | ')})`,
);
await page.getByRole('searchbox', { name: 'Поиск по банку' }).fill('цикада');
// Ждём УСТОЙЧИВОСТИ (перерисовка таблицы отложена), а утверждает число сама проверка.
await page.waitForTimeout(700);
await shot('bank-search');
check((await rows()) === 2, `поиск оставил заголовок и одну строку (всего ${await rows()})`);
check(
await panel.getByText('показано 1 из 60', { exact: false }).isVisible(),
'подвал говорит, сколько строк осталось от банка после сужения',
);
// Nothing found: the table KEEPS its head, and the message stands right under it rather than at
// the foot of the panel (an empty collection makes the library reserve the whole height).
await page.getByRole('searchbox', { name: 'Поиск по банку' }).fill('щщщ');
await page.waitForTimeout(700);
await shot('bank-empty');
const nothing = await panel.evaluate((node) => {
const head = node.querySelector('[role="columnheader"]');
const message = node.querySelector('[role="grid"]')?.parentElement?.querySelector('p');
return {
heads: node.querySelectorAll('[role="columnheader"]').length,
rows: node.querySelectorAll('[role="grid"] [role="row"]').length,
gap: Math.round(
(message?.getBoundingClientRect().top ?? 0) - (head?.getBoundingClientRect().bottom ?? 0),
),
};
});
check(
nothing.heads === 4 && nothing.rows === 1,
`на пустой выдаче остаются шапка и её ${String(nothing.heads)} столбца, строк данных нет`,
);
check(
nothing.gap >= 0 && nothing.gap < 40,
`сообщение стоит сразу под шапкой, а не в низу панели (отступ ${String(nothing.gap)}px)`,
);
await page.getByRole('searchbox', { name: 'Поиск по банку' }).fill('');
await page.waitForTimeout(700);
await audit(page, 'таблица банка');
},
// Перформанс таблицы на длинном хвосте: 1200 терминов, прокрутка и поиск. Числа печатаются
// в отчёт; пороги грубые и ловят обвал, а не колебания (замер, а не вера).
/** @param {Page} page @param {Shot} shot */
async perf(page, shot) {
await open(page, '/scale');
const panel = page.locator('aside');
await panel.getByRole('tab', { name: 'Банк' }).click();
const table = panel.getByRole('grid', { name: 'Термины банка' });
const scroll = await table.evaluate(async (node) => {
const startedAt = node.scrollTop;
/** @type {number[]} */
const frames = [];
let last = performance.now();
let running = true;
const tick = (/** @type {number} */ now) => {
frames.push(now - last);
last = now;
if (running) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
for (let step = 0; step < 40; step += 1) {
node.scrollTop += 400;
await new Promise((done) => requestAnimationFrame(done));
}
running = false;
frames.sort((a, b) => a - b);
return {
median: Math.round(frames[Math.floor(frames.length / 2)] ?? 0),
worst: Math.round(frames.at(-1) ?? 0),
rows: node.querySelectorAll('[role="row"]').length,
nodes: node.querySelectorAll('*').length,
// Пройденный путь: без него «дешёвые кадры» и «прокрутка не сдвинулась» неразличимы,
// и любая поломка делала бы замер ЗЕЛЕНЕЕ.
travelled: node.scrollTop - startedAt,
};
});
await shot('perf-scrolled');
console.log(
` прокрутка 1200 терминов: кадр медиана ${scroll.median}мс, худший ${scroll.worst}мс; ` +
`в DOM ${scroll.rows} строк / ${scroll.nodes} узлов`,
);
check(scroll.travelled > 5000, `таблица реально прокрутилась на ${scroll.travelled}px`);
check(scroll.rows > 10, `при этом строки отрисованы (${scroll.rows}) — мерили не пустоту`);
check(scroll.median <= 34, `медиана кадра при прокрутке ${scroll.median}мс (порог 34)`);
check(scroll.rows <= 80, `в DOM держится ${scroll.rows} строк из 1200 — виртуализация жива`);
// Две РАЗНЫЕ величины, и путать их нельзя: отклик поля — то, что человек чувствует пальцами,
// а оседание таблицы отстаёт намеренно (useDeferredValue), чтобы ввод не ждал коллекцию.
const search = page.getByRole('searchbox', { name: 'Поиск по банку' });
await search.click();
const before = await table.getByRole('row').count();
const rowsSelector = '[role="grid"] [role="row"]';
const typed = Date.now();
// Запрос обязан РЕЗКО сузить выдачу: «фан» есть почти в каждом термине этой фикстуры,
// и число строк в DOM не менялось бы — замер мерил бы ожидание, а не перерисовку.
await page.keyboard.type('юэф');
await page.waitForFunction(
() =>
/** @type {HTMLInputElement | null} */ (document.querySelector('input[type="search"]'))
?.value === 'юэф',
null,
{ timeout: 5000 },
);
// На символ, а не на всю фразу: порог должен быть про ощущение от КЛАВИШИ.
const echo = Math.round((Date.now() - typed) / 3);
await page.waitForFunction(
({ was, selector }) => document.querySelectorAll(selector).length < was,
{ was: before, selector: rowsSelector },
{ timeout: 5000 },
);
const settled = Date.now() - typed;
console.log(
` поиск по 1200 терминам: поле отвечает за ${echo}мс, таблица оседает за ${settled}мс`,
);
// Порог из замера, а не из головы: до починки было ~150 мс на символ (перерисовывалась вся
// оболочка), после — 4045. 60 отделяет одно от другого и терпит шум окружения.
check(echo <= 60, `поле ввода отвечает за ${echo}мс на символ (порог 60)`);
check(settled <= 700, `таблица оседает за ${settled}мс (порог 700)`);
// Драг панели с открытым банком — то, на что владелец пожаловался словами «лагает пиздец».
await search.fill('');
const drag = await dragFrames(page);
console.log(
` драг панели с банком: медиана ${drag.median}мс, p90 ${drag.p90}мс, худший ${drag.worst}мс, ` +
`кадров дольше 50мс${drag.janky} из ${drag.frames}`,
);
check(drag.moved > 100, `панель в ходе драга реально ездила (на ${drag.moved}px в пике)`);
check(drag.p90 <= 34, `p90 кадра при драге ${drag.p90}мс (порог 34)`);
check(drag.janky <= 3, `длинных кадров за драг ${drag.janky} (порог 3)`);
},
// Замечания 11, 12, 4: три модальных окна вместо вкладок-заглушек и второй кнопки поиска.
/** @param {Page} page @param {Shot} shot */
async overlays(page, shot) {
await open(page, '/showcase');
// ⚠ Всё ищется ВНУТРИ окна. Из-за keep-alive невыбранные панели остаются в DOM (в этом
// и смысл Ф-19), поэтому глобальный локатор по роли находит скрытую строку соседней
// вкладки и ждёт её видимости до таймаута.
const modal = page.getByRole('dialog');
await page.getByRole('button', { name: 'Настройки' }).click();
await shot('modal-settings');
check(await modal.isVisible(), 'настройки открылись модальным окном');
await audit(page, 'модальное окно настроек');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Добавить книгу' }).click();
await modal.locator('input[type="file"]').setInputFiles({
name: 'gu-zhen-ren.txt',
mimeType: 'text/plain',
buffer: Buffer.from('пример'),
});
await shot('modal-add-book');
check(
await modal.getByText('название определит разбор файла', { exact: false }).isVisible(),
'пустое поле названия честно говорит, что название даст разбор файла',
);
await modal.getByRole('textbox').last().fill('Гу Чжэньжэнь');
check(
await modal.getByText('Название задано вручную', { exact: false }).isVisible(),
'введённое руками название отличается от авто-разбора',
);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click();
await page.keyboard.type('Аптека');
await shot('modal-goto');
const results = modal.locator('[role="option"]');
check(
(await results.count()) === 1 && (await results.first().innerText()).includes('Аптека'),
'палитра перехода находит ровно тот раздел, что назван',
);
await results.first().click();
// ⚠ Считать вкладки предпросмотра тут бессмысленно: одна такая на витрине открыта всегда,
// и проверка проходила бы, даже если выбор в палитре не делал НИЧЕГО. Спрашиваем ИМЯ.
check(
(await page.locator('[role="tab"][data-preview="true"]').innerText()).includes('Аптека'),
'выбранный в палитре раздел и открылся — вкладкой предпросмотра',
);
// Escape закрывает палитру с ПЕРВОГО раза: примитив поиска первым нажатием чистит строку.
await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click();
await page.keyboard.type('Аптека');
await page.keyboard.press('Escape');
check(!(await modal.isVisible()), 'Escape закрывает палитру с первого нажатия');
// ⚠ Цена того фикса: перехват Escape не даёт полю очиститься самому, и палитра открывалась
// со СТАРЫМ запросом — следующая буква дописывалась к нему (нашло адверсариальное ревью).
await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click();
check(
(await modal.getByRole('searchbox').inputValue()) === '',
'открытая заново палитра начинает с чистой строки',
);
await page.keyboard.press('Escape');
// Просьба палитры сильнее фильтра типа: иначе она приводит в банк и показывает пустоту.
const panel = page.locator('aside');
await panel.getByRole('tab', { name: 'Банк' }).click();
await panel.getByRole('radio', { name: /^место/ }).click();
await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click();
await page.keyboard.type('Фан Юань');
await modal.locator('[role="option"]').first().click();
await page.waitForTimeout(700);
const table = panel.getByRole('grid', { name: 'Термины банка' });
check(
(await table.getByRole('row').filter({ hasText: 'Фан Юань' }).count()) === 1,
'выбранный в палитре термин виден в банке, хотя стоял фильтр другого типа',
);
// Тот же термин, попрошенный ВТОРОЙ раз, — тоже просьба.
await panel.getByRole('searchbox', { name: 'Поиск по банку' }).fill('щщщ');
await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click();
await page.keyboard.type('Фан Юань');
await modal.locator('[role="option"]').first().click();
await page.waitForTimeout(700);
check(
(await table.getByRole('row').filter({ hasText: 'Фан Юань' }).count()) === 1,
'повторная просьба о том же термине срабатывает так же',
);
},
};
/**
* Drags the right separator through a list of offsets from where it started, then lets go.
* @param {Page} page @param {number[]} offsets
*/
async function pull(page, offsets) {
const separator = page.locator('[role="separator"]').nth(1);
const box = await separator.boundingBox();
if (!box) throw new Error('правый разделитель не найден');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
for (const x of offsets) await page.mouse.move(box.x + x, y, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
}
/**
* Драг правого разделителя туда-обратно с замером кадров. Меряется ИМЕННО то, на что жалуется
* человек: не абстрактный рендер, а перетаскивание окна с открытой таблицей.
* @param {Page} page
*/
async function dragFrames(page) {
const panel = page.locator('aside');
const widthBefore = (await panel.boundingBox())?.width ?? 0;
await page.evaluate(() => {
/** @type {{frames: number[], last: number, running: boolean}} */
const state = { frames: [], last: performance.now(), running: true };
Object.assign(window, { __frames: state });
const tick = (/** @type {number} */ now) => {
state.frames.push(now - state.last);
state.last = now;
if (state.running) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
const separator = page.locator('[role="separator"]').nth(1);
const box = await separator.boundingBox();
if (!box) throw new Error('правый разделитель не найден');
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
let widthPeak = widthBefore;
for (const x of [-60, -140, -220, -300, -220, -140, -60, 0]) {
await page.mouse.move(box.x + x, y, { steps: 8 });
// Ширину снимаем В ХОДЕ драга: к концу мышь возвращается на место, и сравнение «до/после»
// показало бы ноль — то есть замер прошёл бы и на намертво застрявшей панели.
const width = (await panel.boundingBox())?.width ?? 0;
if (width > widthPeak) widthPeak = width;
}
await page.mouse.up();
const stats = await page.evaluate(() => {
const state = /** @type {{frames: number[], running: boolean}} */ (
Reflect.get(window, '__frames')
);
state.running = false;
const frames = [...state.frames].sort((a, b) => a - b);
return {
median: Math.round(frames[Math.floor(frames.length / 2)] ?? 0),
p90: Math.round(frames[Math.floor(frames.length * 0.9)] ?? 0),
worst: Math.round(frames.at(-1) ?? 0),
// Человек чувствует ДЛИННЫЕ кадры, а не медиану: она держалась 17 мс и там, где драг
// рвался в клочья. Считаем их штуками.
janky: frames.filter((frame) => frame > 50).length,
frames: frames.length,
};
});
return { ...stats, moved: Math.round(widthPeak - widthBefore) };
}
/**
* Whether the description is covered. The spoiler is `filter: blur()`: a filter is applied AFTER
* the element is drawn and only an ancestor can cut it, whereas `text-shadow` is the element's own
* ink and its own `overflow` cuts it at the glyphs (owner, 10.08: "square on the left").
* @param {Locator} sense
*/
const isCovered = (sense) => sense.evaluate((node) => getComputedStyle(node).filter !== 'none');
/** @param {Locator} separator */
const handleStyle = (separator) =>
separator
.locator('span')
.first()
.evaluate((node) => {
const style = getComputedStyle(node);
return { opacity: style.opacity, background: style.backgroundColor, height: style.height };
});
/** @param {Page} page @param {string} route */
async function open(page, route) {
await page.goto(`${origin}${route}`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, {
timeout: 15_000,
});
await page.evaluate(() => document.fonts.ready);
}
const requested = process.argv.slice(2);
const names = requested.length > 0 ? requested : Object.keys(scenes);
const unknown = names.filter((name) => !(name in scenes));
if (unknown.length > 0) {
throw new Error(
`Нет такого сценария: ${unknown.join(', ')}. Известные: ${Object.keys(scenes).join(', ')}`,
);
}
await mkdir(shotsDir, { recursive: true });
await build({ logLevel: 'warn' });
const server = await preview({ preview: { open: false } });
const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '');
if (!origin) throw new Error('vite preview не отдал локальный адрес');
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
for (const name of names) {
console.log(`\n${name}`);
/** @param {string} file */
const shot = (file) => page.screenshot({ path: resolve(shotsDir, `${file}.png`) });
await scenes[name]?.(page, shot);
}
await browser.close();
await server.close();
console.log('\nвсе сценарии прошли');

View file

@ -4,6 +4,12 @@
// node scripts/shot.mjs все маршруты из KNOWN_ROUTES, 1440x900
// node scripts/shot.mjs /showcase /reader только названные
// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения
// node scripts/shot.mjs --dpr 1 то, что видит владелец на своём мониторе
//
// ⚠ --dpr заведён в S3.5 не для удобства: при deviceScaleFactor 2 полупиксель CSS ложится
// в целый пиксель устройства, и КЛАСС дефектов «мыло на штрихе иконки и на мелком тексте»
// (замечание 17) в кадре не виден вовсе. Дефолт остаётся 2 — референс снят при 2x и сравним
// только с ним; 1 снимает то же самое глазами владельца.
//
// KNOWN_ROUTES дублирует src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный
// выбор в пользу простоты, пополнять оба (расхождение падает тестом src/routes.test.ts).
@ -46,23 +52,29 @@ const { build, preview } = await import('vite');
// позиционный маршрут (sizeIndex = -1, и условие index !== 0 съедало его молча).
const requested = [];
let size = '1440x900';
let awaitingSize = false;
let dpr = '2';
let awaiting = null;
for (const arg of process.argv.slice(2)) {
if (awaitingSize) {
size = arg;
awaitingSize = false;
} else if (arg === '--size') {
awaitingSize = true;
if (awaiting) {
if (awaiting === '--size') size = arg;
else dpr = arg;
awaiting = null;
} else if (arg === '--size' || arg === '--dpr') {
awaiting = arg;
} else if (arg.startsWith('/')) {
requested.push(arg);
} else {
throw new Error(`Непонятный аргумент «${arg}». Ожидается маршрут /showcase или --size ШхВ`);
throw new Error(
`Непонятный аргумент «${arg}». Ожидается маршрут /showcase, --size ШхВ или --dpr N`,
);
}
}
if (awaitingSize) throw new Error('У --size нет значения');
if (awaiting) throw new Error(`У ${awaiting} нет значения`);
const [width, height] = size.split('x').map(Number);
if (!width || !height) throw new Error('Ожидается --size ШИРИНАxВЫСОТА, например --size 1440x900');
const deviceScaleFactor = Number(dpr);
if (!deviceScaleFactor) throw new Error('Ожидается --dpr ЧИСЛО, например --dpr 1');
const routes = requested.length > 0 ? requested : KNOWN_ROUTES;
const unknown = routes.filter((route) => !KNOWN_ROUTES.includes(route));
@ -80,10 +92,11 @@ const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '');
if (!origin) throw new Error('vite preview не отдал локальный адрес');
const browser = await chromium.launch();
// deviceScaleFactor 2 — референс снят на macOS при 2x, иначе снимки несравнимы по детализации.
// deviceScaleFactor 2 по умолчанию — референс снят на macOS при 2x, иначе снимки несравнимы
// по детализации; --dpr 1 снимает то же самое так, как это видит владелец.
// Контекст создаётся явно: @axe-core/playwright отказывается работать со страницей из
// browser.newPage() («Please use browser.newContext()»).
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor });
const page = await context.newPage();
for (const route of routes) {
@ -99,7 +112,7 @@ for (const route of routes) {
await page.evaluate(() => document.fonts.ready);
const file = resolve(shotsDir, `${route.replace(/^\//, '').replace(/\//g, '-') || 'index'}.png`);
await page.screenshot({ path: file });
console.log(`${route}${file} (${width}x${height} @2x)`);
console.log(`${route}${file} (${width}x${height} @${deviceScaleFactor}x)`);
// Доступность — из того же класса, что цвет и имена токенов: владелец её глазами не проверит,
// значит проверяет машина. Контраст вынесен в отчёт, а не в падение: палитра снята с Fleet

View file

@ -12,7 +12,7 @@ export * from './scenarios';
export { ApiError } from './client';
export { subscribeToRun, supportedMajor, majorOf, readFrame } from './stream';
export type { ConnectionState, Frame } from './stream';
export { bookStatus, noteSeverity, pausedReason, termStatus } from './vocabulary';
export { bookStatus, noteSeverity, pausedReason, termKind, termStatus } from './vocabulary';
export type { Tone } from './vocabulary';
/**

View file

@ -15,8 +15,11 @@ import type { components } from './schema';
type Schemas = components['schemas'];
/** Colour is the second channel and means attention or refusal only — never decoration. */
export type Tone = 'note' | 'danger';
/**
* Colour is the second channel: four steps, and every one of them is a STATE work in progress,
* finished, waiting on a person, refused. Never decoration.
*/
export type Tone = 'note' | 'ok' | 'warn' | 'danger';
export interface Vocabulary<Value extends string, Meaning> {
/** The values of version 0.x. Derived from the entries, so the two cannot drift. */
@ -48,15 +51,18 @@ function vocabulary<Value extends string, Meaning>(
*/
export const bookStatus = vocabulary<Schemas['BookStatus'], { label: string; tone?: Tone }>(
{
uploading: { label: 'загрузка' },
parsing: { label: 'разбор' },
uploading: { label: 'загрузка', tone: 'note' },
parsing: { label: 'разбор', tone: 'note' },
not_started: { label: 'в очереди' },
translating: { label: 'перевод' },
awaiting_bank: { label: 'нужна подпись', tone: 'note' },
finalizing: { label: 'финал' },
ready: { label: 'готова' },
translating: { label: 'перевод', tone: 'note' },
awaiting_bank: { label: 'нужна подпись', tone: 'warn' },
finalizing: { label: 'финал', tone: 'note' },
ready: { label: 'готова', tone: 'ok' },
// Resumable stop, so no danger tone: the screen must not read as a failure (D39.100 K-8).
paused: { label: 'остановлена: лимиты', tone: 'note' },
// ⚠ 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: 'остановлена: лимиты', tone: 'warn' },
stopped: { label: 'остановлена' },
rejected: { label: 'не разобрана', tone: 'danger' },
failed: { label: 'ошибка', tone: 'danger' },
@ -91,20 +97,44 @@ export const noteSeverity = vocabulary<Schemas['NoteSeverity'], { tone: 'note' |
);
/**
* Signing status of a bank row, three-valued. `canon` is the only distinction the reference view
* makes: a term is injected as canon or it is not. No new words the signing SCREEN is S5, and
* inventing its vocabulary here would be inventing product language.
* Signing status of a bank row, three-valued. `canon` says whether the term is injected as canon;
* the label names the state for a reader who has to VERIFY the bank (owner's remarks 7/13/20).
*
* The words are the contract's own description of each value, not new product verbs
* (`TermStatus`: "only `approved` is injected as canon", `auto` is "proposed by the engine,
* nobody looked", `draft` is "a human started and did not finish"). The signing SCREEN with its
* actions is S5, and no verb of that screen is invented here.
*/
export const termStatus = vocabulary<Schemas['TermStatus'], { canon: boolean }>(
{ auto: { canon: false }, draft: { canon: false }, approved: { canon: true } },
export const termStatus = vocabulary<
Schemas['TermStatus'],
{ canon: boolean; label: string; tone?: Tone }
>(
{
auto: { canon: false, label: 'предложен' },
draft: { canon: false, label: 'черновик', tone: 'warn' },
approved: { canon: true, label: 'подписан', tone: 'ok' },
},
// Safe direction: an unknown status does not get to claim the row is signed.
{ canon: false },
{ canon: false, label: 'состояние неизвестно' },
);
/** Kind of term. Narrowing only — the reference view does not show the kind, the S5 screen will. */
export const termKind = vocabulary<Schemas['TermKind'], Record<string, never>>(
{ name: {}, place: {}, title: {}, term: {}, nickname: {} },
{},
/**
* Kind of term. Not cosmetic: the contract says `name` and `place` ROUTE a term into
* transliteration, so a bank row shown without its kind is a row signed blind which is why the
* reference view now carries it.
*
* The unknown branch is the contract's own requirement, not our invention: a row whose kind the
* engine could not decide "MUST be shown as kind not decided and MUST NOT be dropped".
*/
export const termKind = vocabulary<Schemas['TermKind'], { label: string }>(
{
name: { label: 'имя' },
place: { label: 'место' },
title: { label: 'титул' },
term: { label: 'термин' },
nickname: { label: 'прозвище' },
},
{ label: 'тип не определён' },
);
/** Provenance of a bank row. Narrowing only, same reason as `termKind`. */

View file

@ -34,16 +34,28 @@ const term = (
});
export const terms: Schemas['BankTerm'][] = [
term('t_1', '方源', 'Фан Юань', 'name', 'approved', 'seed'),
term('t_2', '白凝冰', 'Бай Нинбин', 'name', 'approved', 'seed'),
term('t_3', '花酒行者', 'Монах Цветочного Вина', 'nickname', 'approved', 'mined'),
term('t_4', '沈嬷嬷', 'матушка Шэнь', 'nickname', 'draft', 'mined'),
term('t_5', '古月山寨', 'деревня Гуюэ', 'place', 'approved', 'seed'),
term('t_6', '蛊', 'гу', 'term', 'approved', 'seed'),
term('t_7', '蛊师', 'гу-мастер', 'title', 'approved', 'seed'),
term('t_8', '开窍', 'открытие апертуры', 'term', 'approved', 'seed'),
term('t_9', '春秋蝉', 'Весенне-осенняя цикада', 'term', 'approved', 'seed'),
term('t_10', '学堂', 'школа', 'place', 'approved', 'mined'),
term('t_1', '方源', 'Фан Юань', 'name', 'approved', 'seed', { sense: 'главный герой' }),
term('t_2', '白凝冰', 'Бай Нинбин', 'name', 'approved', 'seed', {
sense: 'спутница главного героя',
}),
term('t_3', '花酒行者', 'Монах Цветочного Вина', 'nickname', 'approved', 'mined', {
sense: 'странствующий мастер',
}),
term('t_4', '沈嬷嬷', 'матушка Шэнь', 'nickname', 'draft', 'mined', {
sense: 'служанка в поместье',
}),
term('t_5', '古月山寨', 'деревня Гуюэ', 'place', 'approved', 'seed', {
sense: 'родная деревня героя',
}),
term('t_6', '蛊', 'гу', 'term', 'approved', 'seed', { sense: 'существо-артефакт, основа силы' }),
term('t_7', '蛊师', 'гу-мастер', 'title', 'approved', 'seed', { sense: 'тот, кто владеет гу' }),
term('t_8', '开窍', 'открытие апертуры', 'term', 'approved', 'seed', {
sense: 'обряд посвящения',
}),
term('t_9', '春秋蝉', 'Весенне-осенняя цикада', 'term', 'approved', 'seed', {
sense: 'легендарный гу времени',
}),
term('t_10', '学堂', 'школа', 'place', 'approved', 'mined', { sense: 'деревенская школа' }),
// The same surface, two senses and two windows. Both rows are legal and both need signing.
term('t_11', '青茅', 'Цинмао', 'place', 'approved', 'mined', { sense: 'гора' }),
term('t_12', '青茅', 'зелёный тростник', 'term', 'draft', 'mined', {
@ -51,7 +63,10 @@ export const terms: Schemas['BankTerm'][] = [
since_chapter: 40,
}),
// Spoiler window: the term must not be applied before chapter 300.
term('t_13', '不死凤凰', 'Бессмертный феникс', 'title', 'draft', 'mined', { since_chapter: 300 }),
term('t_13', '不死凤凰', 'Бессмертный феникс', 'title', 'draft', 'mined', {
sense: 'гу воскрешения',
since_chapter: 300,
}),
// The engine could not decide the kind. Shown as undecided, never dropped, never guessed.
term('t_14', '一气金光虫', 'Золотосветный червь', null, 'auto', 'ruby'),
// A candidate with no proposed translation yet — legal only while the status is `auto`.

View file

@ -84,6 +84,15 @@ const kinds: Schemas['TermKind'][] = ['name', 'nickname', 'title', 'place', 'ter
const statuses: Schemas['TermStatus'][] = ['approved', 'approved', 'draft', 'auto'];
const origins: Schemas['TermOrigin'][] = ['seed', 'ruby', 'mined'];
const senses = [
'спутник главного героя',
'наставник из первой части',
'место последней битвы',
'титул старейшины',
'гу времени',
'клан северных земель',
];
export const scaleTerms: Schemas['BankTerm'][] = Array.from({ length: 1200 }, (_, index) => {
const first = syllables[index % syllables.length] ?? syllables[0];
const second = syllables[Math.floor(index / syllables.length) % syllables.length] ?? syllables[0];
@ -101,8 +110,12 @@ export const scaleTerms: Schemas['BankTerm'][] = Array.from({ length: 1200 }, (_
kind: index % 9 === 0 ? null : (kinds[index % kinds.length] ?? 'term'),
status,
origin: origins[index % origins.length] ?? 'mined',
sense: '',
since_chapter: 0,
// Описание есть у большинства строк, и это не косметика фикстуры: спойлерная колонка на
// длинном хвосте иначе пуста от начала до конца, то есть механизм спойлера на тысяче строк
// не проверяется ничем (нашло адверсариальное ревью).
sense: index % 5 === 0 ? '' : senses[index % senses.length],
// Окно применимости у каждой седьмой: у банка настоящей книги оно редкое, но не отсутствующее.
since_chapter: index % 7 === 0 ? (index % 400) + 40 : 0,
until_chapter: 0,
};
});

View file

@ -1,11 +1,11 @@
/* Модель раскладки из замера и сходится точно: поле оболочки 8 · промежуток 8 ·
верхняя полоса 28 · статус-полоса 20 (FRONTEND_PLAN.md §5.1). */
/* Модель раскладки из замера: поле оболочки 8 по бокам · промежуток 8 · верхняя полоса 36 ·
статус-полоса 28. Полосы прижаты к краям окна и НЕ отступают от них на поле их содержимое
центрируется в самой полосе (пере-замер S3.5, FRONTEND_PLAN.md §5.1). */
.shell {
display: grid;
grid-template-rows: var(--topbar-height) 1fr var(--statusbar-height);
height: 100%;
padding: var(--gap);
background-color: var(--color-shell);
}
@ -13,6 +13,7 @@
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding-inline: var(--bar-inset);
}
.side {
@ -27,13 +28,21 @@
/* h1 ради структуры документа, а не ради вида: кегль и насыщенность — интерфейсные. */
.title {
overflow: hidden;
max-width: 100%;
color: var(--color-text);
font-size: var(--font-size-ui);
font-weight: inherit;
white-space: nowrap;
text-overflow: ellipsis;
}
.body {
min-height: 0;
/* Поле оболочки принадлежит области ПАНЕЛЕЙ, а не окну целиком: полосы у Fleet прижаты
к краям окна и отсчитывают своё поле от них (замер: 12.5 слева, 13.5 справа). */
padding-inline: var(--gap);
}
.slot {
@ -42,27 +51,76 @@
}
/* Разделитель И ЕСТЬ промежуток между панелями: замеренные 8px и зона захвата одно и то же
место, поэтому лишнего отступа рядом с ним не появляется. Виден он только под курсором. */
место, поэтому лишнего отступа рядом с ним не появляется. Сам он ПРОЗРАЧЕН всегда чёрный
геп между окнами не закрашивается ничем (слово владельца, замечание 14). */
.separator {
position: relative;
width: var(--gap);
border-radius: var(--radius-control);
background-color: transparent;
}
.separator[data-separator='hover'] {
background-color: var(--color-separator);
/* A pill centred in the gap. Rules copied verbatim from vojo (`components/page/style.css.ts`):
rest invisible · hover and keyboard focus 0.25 · focus tinted at 0.45 · drag 0.55 and grows ·
min stop squished and thickened · max stop stretched. 140ms throughout, as there. */
.handle {
position: absolute;
top: 50%;
left: 50%;
width: var(--handle-width);
height: var(--handle-length);
transform: translate(-50%, -50%);
border-radius: var(--radius-bar);
background-color: var(--color-text-secondary);
opacity: 0;
transition:
opacity 140ms ease,
height 140ms ease,
width 140ms ease,
background-color 140ms ease;
}
.separator[data-separator='active'],
.separator[data-separator='focus'] {
.separator[data-separator='hover'] .handle,
.separator:focus-visible .handle {
opacity: 0.25;
}
/* Акцент на драге и на КЛАВИАТУРНОМ фокусе, но не на любом `data-separator="focus"`.
Библиотека ставит это состояние по обычному onFocus (dist: `z ? G = "focus"`), а мышиный
pointerdown фокус оставляет поэтому после отпускания драга полоска ЗАЛИПАЛА подсвеченной
(замечание 18а). :focus-visible этого не делает: мышь фокус-кольца не показывает. */
.separator:focus-visible .handle {
background-color: var(--color-accent);
opacity: 0.45;
}
.separator[data-separator='active'] .handle {
height: var(--handle-length-active);
background-color: var(--color-accent);
opacity: 0.55;
}
.separator:focus-visible {
outline: none;
}
/* The stop is tactile: crushed at min, stretched at max. The state comes from the shell
the library does not report a clamp. */
.separator[data-separator='active'] .handle[data-limit='min'] {
width: var(--handle-width-limit);
height: var(--handle-length-min);
opacity: 0.85;
}
.separator[data-separator='active'] .handle[data-limit='max'] {
height: var(--handle-length-max);
opacity: 0.9;
}
.statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: var(--space-3);
padding-inline: var(--bar-inset);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -1,8 +1,8 @@
import { PanelLeft, PanelRight } from 'lucide-react';
import { type ReactNode, useMemo } from 'react';
import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels';
import { type ReactNode, useMemo, useState } from 'react';
import { Group, Panel, type PanelSize, Separator, useDefaultLayout } from 'react-resizable-panels';
import { px } from '../tokens/measures';
import { measures, px } from '../tokens/measures';
import { Button } from '../ui/Button';
import { icon } from '../ui/icon';
import { type Side, useLayout } from './layout';
@ -19,6 +19,8 @@ interface Props {
status: ReactNode;
}
type Limit = 'min' | 'max' | null;
// Свёрнутая панель не сжимается до нуля, а уходит из раскладки вместе со своим разделителем:
// иначе на её месте остаётся лишний промежуток, а замеренный отступ оболочки ровно 8px.
// useDefaultLayout хранит раскладку ОТДЕЛЬНО для каждого состава видимых панелей, поэтому
@ -33,6 +35,19 @@ export function Shell({ title, actions, left, center, right, status }: Props) {
);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: 'shell', panelIds });
// Упор — состояние ОБОЛОЧКИ, а не библиотеки: она про упор не рассказывает. Перерисовка тут
// дешёвая, потому что содержимое панелей приходит готовыми элементами в пропсах — React видит
// ту же ссылку и в поддеревья не заходит.
const [limit, setLimit] = useState<Record<Side, Limit>>({ left: null, right: null });
// Минимум у панелей РАЗНЫЙ: справа живёт таблица банка, и уже своей меры она не сжимается.
// Общая функция сравнения врала бы про упор левой панели на каждом кадре (поймано сценарием).
const watch = (side: Side, min: number) => (size: PanelSize) => {
setLimit((current) => {
const next = limitOf(size, min);
return current[side] === next ? current : { ...current, [side]: next };
});
};
return (
<div className={styles.shell}>
<header className={styles.topbar}>
@ -66,10 +81,17 @@ export function Shell({ title, actions, left, center, right, status }: Props) {
>
{!collapsed.left && (
<>
<Panel {...side} className={styles.slot} id="left">
<Panel
{...side}
className={styles.slot}
id="left"
onResize={watch('left', measures['--panel-side-min'])}
>
{left}
</Panel>
<Separator className={styles.separator} />
<Separator className={styles.separator}>
<span className={styles.handle} data-limit={limit.left ?? undefined} />
</Separator>
</>
)}
{/* Центр тянется долей, боковые пикселями: на 1920 лишнюю ширину получает читалка,
@ -80,8 +102,17 @@ export function Shell({ title, actions, left, center, right, status }: Props) {
</Panel>
{!collapsed.right && (
<>
<Separator className={styles.separator} />
<Panel {...side} className={styles.slot} id="right">
<Separator className={styles.separator}>
<span className={styles.handle} data-limit={limit.right ?? undefined} />
</Separator>
<Panel
{...side}
defaultSize={px('--panel-context-width')}
minSize={px('--panel-context-min')}
className={styles.slot}
id="right"
onResize={watch('right', measures['--panel-context-min'])}
>
{right}
</Panel>
</>
@ -93,6 +124,14 @@ export function Shell({ title, actions, left, center, right, status }: Props) {
);
}
// Полпикселя допуска: библиотека доводит размер до целого не всегда, а равенство «в лоб»
// пропускало бы упор через раз.
function limitOf({ inPixels }: PanelSize, min: number): Limit {
if (inPixels <= min + 0.5) return 'min';
if (inPixels >= measures['--panel-side-max'] - 0.5) return 'max';
return null;
}
const side = {
defaultSize: px('--panel-side-width'),
minSize: px('--panel-side-min'),

View file

@ -1,6 +1,5 @@
/* Спокойный список пар «поле — значение», не карточки со статистикой (промт §3.9). */
.about,
.notes {
.about {
flex: 1;
min-height: 0;
overflow: auto;
@ -25,20 +24,3 @@
white-space: nowrap;
text-overflow: ellipsis;
}
.term,
.draft {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.draft {
color: var(--color-text-secondary);
}
/* Счётчик уезжает к правому краю подвала: действие слева, состояние справа. */
.counter {
margin-left: auto;
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,48 @@
import type { BookDetail } from '../api';
import { date, languageName, number, statusOf, translatedPercent } from './format';
import styles from './About.module.css';
/**
* Метаданные книги (замечание 6), расширенные ровно до границы контракта. Граница защита
* подходов, а не пробел: денег на экране нет (§4.8), моделей и стадий конвейера тоже.
*/
export function About({ detail }: { detail: BookDetail }) {
const { book, run } = detail;
const fields: [string, string][] = [
['Название', book.title],
['Языки', `${languageName(book.source_lang)}${languageName(book.target_lang)}`],
['Жанр', book.genre ?? '—'],
['Состояние', statusOf(book.status).label],
['Переведено', `${String(translatedPercent(book.progress))}%`],
['Разделов', number(book.chapter_count)],
['Блоков', number(book.progress.draft.total)],
['Знаков', number(book.character_count ?? 0)],
['Замечаний', number(book.note_count)],
['Добавлена', date(book.added_at)],
];
if (run) {
fields.push(
['Прогон начат', date(run.started_at)],
// `finished_at` бывает и `null`, и вовсе отсутствующим: у незавершённого прогона поля
// может не быть — обе формы значат одно и то же и рисуются прочерком.
['Прогон завершён', run.finished_at ? date(run.finished_at) : '—'],
// Потолок и остановка на подписи — выбор самого человека перед стартом, а не устройство
// движка: показывать их честно и полезно, он их и задавал.
['Потолок прогона', `${number(run.ceiling_chapters)} разделов`],
['Остановка на подписи', run.verify_bank ? 'да' : 'нет'],
);
}
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>
);
}

View file

@ -0,0 +1,30 @@
.form {
display: grid;
gap: var(--space-6);
}
.label {
margin-bottom: var(--space-2);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
/* Родное поле выбора файла нужно живым, но не показанным: вид у него системный и в оболочку
не встраивается, поэтому нажимает его наша кнопка. */
.hidden {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
clip-path: inset(50%);
}
.slots {
border-top: 1px solid var(--color-border);
padding-top: var(--space-5);
}
.slot {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -0,0 +1,90 @@
import { FilePlus2 } from 'lucide-react';
import { useRef, useState } from 'react';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { TextField } from '../ui/TextField';
import { icon } from '../ui/icon';
import styles from './AddBook.module.css';
/**
* Добавление книги модальным окном по образцу antigravity_add_folder.png (замечание 12).
* Два источника названия разведены: руками или разбором файла, и что произойдёт сказано
* до нажатия. Сама отправка работа экрана загрузки (Ф-26).
*/
export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [file, setFile] = useState<string | null>(null);
const [title, setTitle] = useState('');
const input = useRef<HTMLInputElement>(null);
const close = () => {
setFile(null);
setTitle('');
onClose();
};
return (
<Modal
title="Добавить книгу"
isOpen={isOpen}
onClose={close}
footer={
<>
<Button look="action" onPress={close}>
Отмена
</Button>
<Button look="primary" isDisabled={file === null} onPress={close}>
Добавить
</Button>
</>
}
>
<div className={styles.form}>
<div>
<p className={styles.label}>Файл книги</p>
{/* Настоящий выбор файла, а не нарисованный: имя файла нужно живьём из него берёт
название авто-разбор, и без реального имени эту ветку не проверить кадром. */}
<input
className={styles.hidden}
ref={input}
type="file"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => setFile(event.target.files?.[0]?.name ?? null)}
/>
<Button look="action" onPress={() => input.current?.click()}>
<FilePlus2 {...icon} />
{file ?? 'Выбрать файл'}
</Button>
</div>
<TextField
label="Название"
value={title}
onChange={setTitle}
placeholder={file === null ? 'по умолчанию — из файла' : parsedTitle(file)}
hint={
title === ''
? 'Поле пустое: название определит разбор файла — его можно будет поправить позже.'
: 'Название задано вручную: разбор его не перепишет.'
}
/>
{/* Место будущих пер-книжных настроек. Названо, но не нарисовано переключателями,
которых нет: кнопка без действия запрещена (Ф-7). */}
<div className={styles.slots}>
<p className={styles.label}>Настройки книги</p>
<p className={styles.slot}>Пара языков, жанр и параметры запуска появятся здесь.</p>
</div>
</div>
</Modal>
);
}
/** Авто-разбор названия из имени файла — тот самый второй источник, что и подписан в форме. */
function parsedTitle(file: string): string {
return file
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.trim();
}

View file

@ -0,0 +1,98 @@
.bank {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
gap: var(--space-3);
}
/* The field takes the row, the toggle is pinned to the panel edge otherwise it floats in the
emptiness between them. */
.search {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
}
.toolbar {
display: flex;
gap: var(--space-3);
flex-direction: column;
align-items: stretch;
}
/* The source side is one step larger: a hanzi at the same size reads smaller than Cyrillic, and
in the pair "term → translation" it lost weight to what is derived from it. */
.src {
overflow: hidden;
flex: 0 1 auto;
min-width: 0;
color: var(--color-text);
font-size: var(--font-size-source);
text-overflow: ellipsis;
}
.dst {
overflow: hidden;
text-overflow: ellipsis;
}
/* The window is a suffix, not a column almost no row has one. It shrinks FIRST: the term
matters more than the note attached to it. */
.window {
overflow: hidden;
margin-left: var(--space-2);
flex: 0 100 auto;
min-width: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-small);
white-space: nowrap;
text-overflow: ellipsis;
}
.quiet,
.absent {
color: var(--color-text-secondary);
}
/* Revealed, it is ordinary text that ellipsizes. Nothing fades: a darkening tail reads as damage. */
.sense {
display: block;
overflow: hidden;
min-width: 0;
color: var(--color-text-secondary);
text-overflow: ellipsis;
transition: filter 140ms ease;
}
/* A plain blur and nothing else two rules were learned the hard way here:
- NOTHING may clip it. A blur mixes in what lies outside the ink, so a box ending at the glyphs
ends the blur with a straight edge. That rules out `text-shadow` (own ink, cut by own
`overflow`) and a mask (works over the box, and a blur reaches past it on all four sides).
- The RADIUS decides whether this reads as blurred text or as grey bricks: at 5px words of this
size fuse into slabs, at 3px their shapes survive and nothing is legible.
`user-select` closes the plainest way round a spoiler selecting the column copied every
description at once. Find-in-page still reaches it; nothing that keeps text in the document can
stop that. */
.senseCell .sense {
overflow: visible;
filter: blur(3px);
user-select: none;
}
/* Two deliberate ways to reveal: hovering the cell (mouse) and the toggle, which opens the whole
column (keyboard and screen reader only it drops `aria-hidden`). */
.senseCell:hover .sense {
filter: none;
user-select: auto;
}
.state {
display: flex;
gap: var(--space-5);
flex-wrap: wrap;
padding-inline: var(--space-4);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -0,0 +1,243 @@
import { Eye, EyeOff } from 'lucide-react';
import { useDeferredValue, useMemo, useState } from 'react';
import { termKind } from '../api';
import type { Bank as BankData, BankTerm } from '../api';
import { Chips, type Chip } from '../ui/Chips';
import { FilterField } from '../ui/FilterField';
import { Toggle } from '../ui/Toggle';
import { icon } from '../ui/icon';
import { measures } from '../tokens/measures';
import { Table, type TableColumn } from '../ui/Table';
import tableStyles from '../ui/Table.module.css';
import { counted, number } from './format';
import { matchesTerm } from './terms';
import styles from './Bank.module.css';
interface Props {
bank: BankData;
/** Source-side language of the book: without it the hanzi render with Japanese glyphs. */
sourceLang?: string;
/** The run stopped and waits for the bank to be signed — a state of the WHOLE bank. */
awaitingSignature?: boolean;
/**
* A request from the palette to show one term. NOT the value of the search box: the box owns its
* own text, and lifting it made every keystroke re-render the whole screen (measured: 150 ms per
* character on a book with 2284 chapters, because the tree re-rendered with it). The number is
* what makes asking for the SAME term twice a second request.
*/
request: { term: string; seq: number };
}
type KindFilter = 'all' | NonNullable<BankTerm['kind']> | 'undecided';
/** Plural forms for the bank counter; Intl picks the category. */
const terms: Record<Intl.LDMLPluralRule, string> = {
zero: 'терминов',
one: 'термин',
two: 'термина',
few: 'термина',
many: 'терминов',
other: 'терминов',
};
/** How far a column may grow to fit what it holds. */
const fitMax = measures['--column-fit-max'];
const kindFilters: { id: KindFilter; label: string }[] = [
{ id: 'all', label: 'все' },
...termKind.values.map((value) => ({ id: value, label: termKind.describe(value).label })),
{ id: 'undecided', label: termKind.describe(null).label },
];
/**
* The bank as a table: one row per term, columns that can be compared down the page, and the two
* ways through hundreds of rows search and the kind filter. A book's names are a different
* table from its terms, and that is what the filter is for.
*
* No per-term signing state on screen, deliberately. The engine keeps one per row and the
* contract carries it (`TermStatus`), but that is the MECHANISM under the stop: the product act is
* ONE signature over the whole bank, so a "signed" column made the screen argue with the product.
*/
export function Bank({ bank, sourceLang, awaitingSignature, request }: Props) {
const [kind, setKind] = useState<KindFilter>('all');
// Descriptions are covered whole — they are the spoiler. Hover reveals one cell to the EYES;
// this toggle is the only way for the keyboard and for a screen reader, which a blur hides
// nothing from (while covered, the text is out of the accessibility tree).
const [senseShown, setSenseShown] = useState(false);
const [filter, setFilter] = useState(request.term);
// A request from the palette overrides both the typed text and the kind filter: the person just
// picked a term and expects to see it, not an empty table because another kind tab was on.
const [seen, setSeen] = useState(request.seq);
if (request.seq !== seen) {
setSeen(request.seq);
setFilter(request.term);
setKind('all');
}
// Typing stays responsive while the table lags a frame behind: rebuilding the collection over a
// thousand rows costs ~150 ms, and every keystroke would pay it.
const query = useDeferredValue(filter);
// Counted over what the SEARCH found rather than over the whole bank: otherwise a chip promises
// rows the current search does not have, and clicking it opens an empty table.
const found = useMemo(() => bank.terms.filter(matchesTerm(query)), [bank.terms, query]);
const chips: Chip[] = useMemo(() => {
const counts = countByKind(found);
return kindFilters.map((item) => ({ ...item, count: counts[item.id] ?? 0 }));
}, [found]);
const rows = useMemo(() => found.filter(matchesKind(kind)), [found, kind]);
// The kind column stands only while the filter shows every kind: on the "имя" tab a column that
// says "имя" in every row is noise.
const columns: TableColumn<BankTerm>[] = [
{
id: 'src',
name: 'Термин',
isRowHeader: true,
// The window is drawn as a SECOND span with a gap before it. Measured as one run of text it
// came up exactly that gap short, and the window clipped in every row that had one — so the
// gap is measured too, as the space it is.
fit: {
text: (term) => {
const window = windowOf(term);
return window === null ? term.src : `${term.src} ${window}`;
},
max: fitMax,
},
render: (term) => {
// The window travels with the term instead of taking a column: it is empty for almost
// every row, and an almost-empty column is width spent on nothing.
const window = windowOf(term);
return (
<>
<span className={styles.src} lang={sourceLang} title={term.src}>
{term.src}
</span>
{window !== null && (
<span className={styles.window} title={window}>
{window}
</span>
)}
</>
);
},
},
{
id: 'dst',
name: 'Перевод',
fit: { text: (term) => (term.dst === '' ? 'не предложен' : term.dst), max: fitMax },
// The table's only vertical rule is the language boundary: the book's side on the left.
className: tableStyles.divided,
render: (term) =>
term.dst === '' ? (
<span className={styles.absent}>не предложен</span>
) : (
// Full text as a tooltip: a clipped cell is a dead end, and the translation is what is
// being checked here.
<span className={styles.dst} title={term.dst}>
{term.dst}
</span>
),
},
...(kind === 'all'
? [
{
id: 'kind',
name: 'Тип',
// The widest label the bank actually uses, not the widest the vocabulary has: a book
// with no nicknames should not pay for the word.
fit: { text: (term: BankTerm) => termKind.describe(term.kind).label, max: fitMax },
render: (term: BankTerm) => (
<span className={styles.quiet}>{termKind.describe(term.kind).label}</span>
),
},
]
: []),
{
id: 'sense',
// The spoiler is a property of the COLUMN, not of single rows: a term's sense gives away how
// things end, so the whole column is covered by default.
name: 'Смысл',
// No fit: a description is a sentence, and no width fits one — so this is the column with
// room to grow, and everything the others do not need lands here.
minWidth: measures['--column-min'],
className: senseShown ? undefined : styles.senseCell,
render: (term) =>
(term.sense ?? '') === '' ? (
<span className={styles.absent}></span>
) : (
<span className={styles.sense} data-sense="" aria-hidden={senseShown ? undefined : true}>
{term.sense}
</span>
),
},
];
return (
<div className={styles.bank}>
<div className={styles.toolbar}>
<div className={styles.search}>
<FilterField label="Поиск по банку" value={filter} onChange={setFilter} />
<Toggle
label={senseShown ? 'Скрыть смыслы терминов' : 'Показать смыслы терминов'}
isSelected={senseShown}
onChange={setSenseShown}
>
{senseShown ? <Eye {...icon} /> : <EyeOff {...icon} />}
</Toggle>
</div>
<Chips
label="Тип термина"
items={chips}
selectedId={kind}
onSelect={(id) => setKind(id as KindFilter)}
/>
</div>
{/* Everything the render closures take from outside the row belongs in `dependencies`, or
the collection cache serves cells drawn with the old value. */}
<Table
label="Термины банка"
columns={columns}
items={rows}
sample={bank.terms}
dependencies={[sourceLang, senseShown]}
empty="Ничего не нашлось"
/>
{/* Untouched the size of the bank; narrowed how much of it is left. Otherwise the screen
shows two numbers about the same thing and explains neither. */}
<p className={styles.state}>
<span>
{rows.length === bank.total
? counted(bank.total, terms)
: `показано ${number(rows.length)} из ${number(bank.total)}`}
</span>
{awaitingSignature === true && <span>банк ждёт подписи</span>}
</p>
</div>
);
}
/** `0` means no boundary on that side, so a term without a window applies to the whole book. */
function windowOf({ since_chapter: since, until_chapter: until }: BankTerm): string | null {
if (since === 0 && until === 0) return null;
if (until === 0) return `с ${number(since)}-го`;
if (since === 0) return `по ${number(until)}`;
return `${number(since)}${number(until)}`;
}
const matchesKind = (kind: KindFilter) => (term: BankTerm) => {
if (kind === 'all') return true;
if (kind === 'undecided') return term.kind === null;
return term.kind === kind;
};
function countByKind(terms: BankTerm[]): Record<string, number> {
const counts: Record<string, number> = { all: terms.length };
for (const term of terms) {
const key = term.kind ?? 'undecided';
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}

View file

@ -24,3 +24,31 @@
.description {
color: var(--color-text-secondary);
}
/* A card that explains a WAIT holds itself back: it fades in only once the wait has lasted long
enough to be worth a word, and a read that answers sooner never paints it at all. Done with a
delayed animation rather than a timer in the component there is no state to race with, and a
card removed before the delay elapses was simply never visible.
The threshold is the shorter end of the usual advice for progress indicators: under ~0.1s a
response reads as instant, and an indicator that appears and leaves inside that window reads as
a glitch, which is exactly what the owner saw. */
.waiting {
animation: emerge 120ms ease 220ms both;
}
@keyframes emerge {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Movement is the part that is decorative here; the delay is not, so it stays. */
@media (prefers-reduced-motion: reduce) {
.waiting {
animation-duration: 1ms;
}
}

View file

@ -3,6 +3,11 @@ import styles from './Blank.module.css';
interface Props {
title: string;
description: string;
/**
* A wait, not a state of the data. Such a card only becomes visible once the wait is long enough
* to be worth explaining see the rule in the stylesheet.
*/
waiting?: boolean;
}
/**
@ -10,10 +15,10 @@ interface Props {
* по центру пустоты, без иллюстраций. Витрина открывает такие вкладки там, где действие
* оболочки уже настоящее, а экран за ним строит следующая сессия.
*/
export function Blank({ title, description }: Props) {
export function Blank({ title, description, waiting = false }: Props) {
return (
<div className={styles.blank}>
<div className={styles.card}>
<div className={`${styles.card} ${waiting ? styles.waiting : ''}`}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.description}>{description}</p>
</div>

View file

@ -1,61 +1,63 @@
import { useMemo, useState } from 'react';
import { termStatus } from '../api';
import type { Bank, BankTerm, BookDetail, NoteList } from '../api';
import type { Bank as BankData, BookDetail, Chapter, NoteList } from '../api';
import { Panel } from '../shell/Panel';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { FilterField } from '../ui/FilterField';
import { List, type ListRow } from '../ui/List';
import { About } from './About';
import { Bank } from './Bank';
import { Loaded } from './Loaded';
import { languageName, noteTone, number } from './format';
import styles from './Context.module.css';
import { Notes } from './Notes';
interface Props {
book: Parameters<typeof Loaded<BookDetail>>[0]['query'];
/** True when the library came back with nothing: "выберите книгу" would point at an empty list. */
libraryEmpty?: boolean;
/** The chapter being read. The panel is about IT, so its notes are the ones that belong here. */
chapterId?: string;
chapters: Chapter[];
notes: Parameters<typeof Loaded<NoteList>>[0]['query'];
bank: Parameters<typeof Loaded<Bank>>[0]['query'];
onSignBank: () => void;
bank: Parameters<typeof Loaded<BankData>>[0]['query'];
/** Вкладка поднята на экран: палитра перехода ведёт СЮДА, к найденному термину. */
tab: string;
onTabChange: (id: string) => void;
/** Просьба палитры показать термин. Строку поиска банк держит сам. */
request: { term: string; seq: number };
onOpenChapter: (chapterId: string) => void;
}
/**
* The panel of context for the chapter being read. The bank lives here rather than on the left
* The panel of context for the book being read. The bank lives here rather than on the left
* (owner, 02.08): the reader's cycle is "read the translation trip over a term check it in the
* bank", and all three have to be visible at once that is, in columns, not in rows.
*/
export function Context({ book, chapterId, libraryEmpty, notes, bank, onSignBank }: Props) {
export function Context({
book,
libraryEmpty,
chapters,
notes,
bank,
tab,
onTabChange,
request,
onOpenChapter,
}: Props) {
// Telling the reader to pick a book from a panel that says "Ни одной книги" is a screen arguing
// with itself; it was on the session's own first-run shot.
const idle = libraryEmpty
? { title: 'Книг пока нет', description: 'Добавьте книгу — сведения о ней появятся здесь.' }
: undefined;
const [tab, setTab] = useState('bank');
const [query, setQuery] = useState('');
const terms = useMemo(() => bank.data?.terms ?? [], [bank.data]);
const sourceLang = book.data?.book.source_lang;
const rows = useMemo(
() => terms.filter(matches(query)).map((term) => termRow(term, sourceLang)),
[terms, query, sourceLang],
);
return (
<Panel
label="О читаемой главе"
label="О читаемой книге"
landmark="complementary"
selectedId={tab}
onSelect={setTab}
onSelect={onTabChange}
// Панели не размонтируются: прокрутка банка на сотнях терминов переживает уход
// на «О книге» и обратно (Ф-19).
keepAlive
tabs={[
{
id: 'about',
label: 'О книге',
content: (
<Loaded query={book} waiting="О книге" idle={idle}>
{(detail) => <About book={detail.book} />}
{(detail) => <About detail={detail} />}
</Loaded>
),
},
@ -67,24 +69,14 @@ export function Context({ book, chapterId, libraryEmpty, notes, bank, onSignBank
query={notes}
waiting="Замечания"
idle={idle}
isEmpty={(data) => ofChapter(data.notes, chapterId).length === 0}
isEmpty={(data) => data.notes.length === 0}
empty={{
title: 'Замечаний нет',
description: chapterId
? 'В этом разделе нет мест, требующих внимания.'
: 'Откройте раздел — замечания к нему появятся здесь.',
description: 'В этой книге нет мест, требующих внимания.',
}}
>
{(data) => (
<div className={styles.notes}>
{ofChapter(data.notes, chapterId).map((note, index) => (
<Callout
key={`${note.unit_id ?? ''}:${String(index)}`}
tone={noteTone(note.severity)}
caption={note.message}
/>
))}
</div>
<Notes notes={data.notes} chapters={chapters} onOpenChapter={onOpenChapter} />
)}
</Loaded>
),
@ -103,92 +95,18 @@ export function Context({ book, chapterId, libraryEmpty, notes, bank, onSignBank
description: 'Термины книги появятся после первого прохода перевода.',
}}
>
{() => (
<>
<FilterField label="Фильтр терминов" value={query} onChange={setQuery} />
<List label="Термины банка" items={rows} empty="Ничего не нашлось" />
</>
{(data) => (
<Bank
bank={data}
sourceLang={book.data?.book.source_lang}
awaitingSignature={book.data?.book.status === 'awaiting_bank'}
request={request}
/>
)}
</Loaded>
),
},
]}
// The reference view leads into the working one: the signing screen opens as a tab in the
// centre, where there is width for columns, the keyboard and bulk actions.
// The counter appears only when the bank has actually answered. `0 из 0` next to a panel that
// says "идёт загрузка" is a screen contradicting itself — it was on the session's own shots.
footer={
<Button onPress={onSignBank}>
Подписать банк
{bank.data && (
<span className={styles.counter}>
подписано {number(bank.data.signed)} из {number(bank.data.total)}
</span>
)}
</Button>
}
/>
);
}
// The read is per BOOK — the contract has no per-chapter notes endpoint — but the panel is about the
// chapter being read, and an unattributed list of the whole book's notes says nothing about where
// each one belongs. Filtering here rather than asking for another endpoint is the cheap half.
const ofChapter = (notes: NoteList['notes'], chapterId: string | undefined) =>
chapterId === undefined ? [] : notes.filter((note) => note.chapter_id === chapterId);
// Case is folded on both sides: hanzi have none, but the source side of a pair is sometimes Latin,
// and an asymmetric filter would then behave differently in the two columns.
const matches = (query: string) => (term: BankTerm) => {
const needle = query.toLowerCase();
return (
needle === '' ||
term.src.toLowerCase().includes(needle) ||
term.dst.toLowerCase().includes(needle)
);
};
function termRow(term: BankTerm, sourceLanguage: string | undefined): ListRow {
return {
// The row key is the contract's opaque id. It has to be: a term is unique by
// (src, sense, since, until), so one hanzi legally arrives as several rows, and a key folded
// out of the visible fields dropped the second one — React discards it and the list loses its
// selection. The engine's own autoincrement would not do either; it is regenerated whenever the
// bank is rebuilt, which is every run.
id: term.id,
text: `${term.src} ${term.dst}`,
content: (
<>
{/* The language of the source side comes from the book's data: without it Japanese kanji
render with Chinese glyph shapes and a screen reader picks a Chinese voice. */}
<span lang={sourceLanguage}>{term.src}</span>
{/* An unsigned term is muted: the state reads as saturation, not as colour. An unknown
status is muted too it does not get to claim the row is signed. */}
<span className={termStatus.describe(term.status).canon ? styles.term : styles.draft}>
{term.dst}
</span>
</>
),
};
}
function About({ book }: { book: BookDetail['book'] }) {
const fields: [string, string][] = [
['Название', book.title],
['Языки', `${languageName(book.source_lang)}${languageName(book.target_lang)}`],
['Жанр', book.genre ?? '—'],
['Разделов', number(book.chapter_count)],
['Знаков', number(book.character_count ?? 0)],
['Добавлена', new Date(book.added_at).toLocaleDateString('ru-RU')],
];
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}>{value}</dd>
</div>
))}
</dl>
);
}

View file

@ -0,0 +1,24 @@
.document {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
/* Пометка черновика стоит НАД скролл-контейнером, а не внутри: она про всю главу, а не про
первый блок, и не должна уезжать вверх при прокрутке. Мягкая по промту §3.8 сообщение,
а не авария: приглушённый тон, значок вместо восклицания, подробность в тултипе. */
.draft {
display: flex;
gap: var(--space-2);
align-items: center;
flex: none;
padding: var(--space-2) var(--space-4) var(--space-3);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.draftMark {
flex: none;
color: var(--color-text-muted);
}

View file

@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { FileClock } from 'lucide-react';
import { unitsQuery } from '../api';
import { icon } from '../ui/icon';
import { Reader } from './Reader';
import styles from './Document.module.css';
interface Props {
bookId: string;
chapterId: string;
sourceLang?: string;
targetLang?: string;
/** Прогон над книгой ещё не довёл её до готовности — текст может быть перегенерирован. */
draft: boolean;
/** Содержательное взаимодействие с содержимым закрепляет вкладку (модель VS Code). */
onEngage: () => void;
}
/**
* Открытая глава как вкладка-документ. Запрос живёт ЗДЕСЬ, а не на экране: вкладки не
* размонтируются при переключении (keep-alive, Ф-19), и один общий запрос отдал бы всем
* открытым вкладкам текст активной главы.
*/
export function Document({ bookId, chapterId, sourceLang, targetLang, draft, onEngage }: Props) {
// Без книги вкладок не бывает, но запрос выключается явно: собранный из пустого id адрес
// ушёл бы в сеть и вернулся отказом, который экран показал бы как поломку.
const units = useQuery({ ...unitsQuery(bookId, chapterId), enabled: bookId !== '' });
return (
<div
className={styles.document}
onMouseUp={() => {
// Выделение текста — то самое «содержательное взаимодействие»: читатель сравнил или
// скопировал кусок, значит глава нужна ему дальше. Простая прокрутка не пинит.
if ((document.getSelection()?.toString().length ?? 0) > 0) onEngage();
}}
>
{draft && (
<p
className={styles.draft}
title="Прогон над книгой ещё идёт: текст, названия разделов и их число могут измениться при следующем проходе"
>
<FileClock {...icon} className={styles.draftMark} />
черновой вариант, может быть перегенерирован
</p>
)}
<Reader sourceLang={sourceLang} targetLang={targetLang} units={units} />
</div>
);
}

View file

@ -0,0 +1,31 @@
.palette {
display: grid;
gap: var(--space-3);
padding-bottom: var(--space-5);
}
/* Список получает свою высоту: виртуализатору нужен контейнер с размером, а не «сколько выйдет». */
.results {
display: flex;
height: var(--modal-height-list);
min-height: 0;
}
.label {
overflow: hidden;
flex: 1;
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
.what {
flex: none;
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.more {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -0,0 +1,122 @@
import { useMemo, useState } from 'react';
import type { BankTerm, Chapter } from '../api';
import { FilterField } from '../ui/FilterField';
import { List, type ListRow } from '../ui/List';
import { Modal } from '../ui/Modal';
import { chapterLabel } from './format';
import { matchesTerm } from './terms';
import styles from './Goto.module.css';
interface Props {
isOpen: boolean;
onClose: () => void;
chapters: Chapter[];
terms: BankTerm[];
onOpenChapter: (chapterId: string) => void;
/** Термин ведёт в банк: панель переключается на него и фильтр встаёт на этот термин. */
onOpenTerm: (src: string) => void;
}
/** Дальше первых пятидесяти совпадений список не читается — уточнять запрос быстрее, чем листать. */
const LIMIT = 50;
/**
* Быстрый переход к разделу или термину (замечание 4). Это НЕ поиск по тексту книги: ручки
* поиска у контракта нет вовсе, поэтому вкладка-заглушка «Поиск» снята, а не оставлена
* обещанием (Ф-33).
*/
export function Goto({ isOpen, onClose, chapters, terms, onOpenChapter, onOpenTerm }: Props) {
const [query, setQuery] = useState('');
// Закрыли — забыли запрос: следующий вызов палитры начинается с чистого листа, как и модал
// добавления книги. Иначе она открывается с прошлым запросом, но без выделения — и первая
// же буква дописывается к нему.
const close = () => {
setQuery('');
onClose();
};
const { rows, truncated } = useMemo(() => {
const needle = query.trim().toLowerCase();
const chapterHits: ListRow[] = [];
chapters.forEach((chapter, index) => {
const label = chapterLabel(chapter, index + 1);
if (needle === '' || label.toLowerCase().includes(needle)) {
chapterHits.push(row(`chapter:${chapter.id}`, label, 'раздел'));
}
});
// Ключ — непрозрачный id термина: один иероглиф законно приходит несколькими строками
// (`sense` и окно применимости входят в ключ), и ключ из видимой стороны их схлопывал.
const termHits =
needle === ''
? []
: terms.filter(matchesTerm(needle)).map((term) =>
row(
`term:${term.id}`,
// Окно применимости названо и здесь: контракт зовёт `since_chapter` границей
// СПОЙЛЕРА, и термин из трёхсотого раздела не должен выглядеть как обычный.
term.since_chapter > 0
? `${term.src}${term.dst} · с ${String(term.since_chapter)}-го`
: `${term.src}${term.dst}`,
'термин',
),
);
// Разделам и терминам отведены СВОИ слоты: у книги на 2284 раздела запрос «1» давал полсотни
// разделов, и ни один термин до списка не доходил — сколько запрос ни уточняй.
const half = Math.floor(LIMIT / 2);
const shownChapters = chapterHits.slice(0, Math.max(half, LIMIT - termHits.length));
const shownTerms = termHits.slice(0, LIMIT - shownChapters.length);
return {
rows: [...shownChapters, ...shownTerms],
truncated: chapterHits.length + termHits.length > LIMIT,
};
}, [chapters, terms, query]);
return (
<Modal title="Перейти" isOpen={isOpen} onClose={close} size="wide">
{/* Escape перехватывается ДО поля: примитив поиска первым нажатием очищает свою строку
и наружу его не пускает, поэтому окно закрывалось только со второго раза. */}
<div
className={styles.palette}
onKeyDownCapture={(event) => {
if (event.key === 'Escape') close();
}}
>
<FilterField
label="Название раздела или термин"
value={query}
onChange={setQuery}
autoFocus
/>
<div className={styles.results}>
<List
label="Результаты перехода"
items={rows}
empty="Ничего не нашлось"
onSelect={(id) => {
const [what, ...rest] = id.split(':');
const value = rest.join(':');
if (what === 'chapter') onOpenChapter(value);
else onOpenTerm(terms.find((term) => term.id === value)?.src ?? '');
close();
}}
/>
</div>
{/* Обрезка списка названа вслух: молчаливое «показаны не все» читается как «это всё». */}
{truncated && <p className={styles.more}>Показаны первые {LIMIT} совпадений.</p>}
</div>
</Modal>
);
}
const row = (id: string, label: string, what: string): ListRow => ({
id,
text: label,
content: (
<>
<span className={styles.label}>{label}</span>
<span className={styles.what}>{what}</span>
</>
),
});

View file

@ -1,39 +1,49 @@
/* A chapter's glyph is quiet grey; a book's carries the state tone. Two grey glyphs of the same
weight were what made book and chapter indistinguishable at 16px. */
.icon {
color: var(--color-text-muted);
}
/* Состояние прогона словом; тусклость набирается кеглем, а не темнотой: приглушённый тон
не проходит порог контраста, а состояние это информация, а не украшение (Ф-11). */
.runState {
.bookIcon {
display: flex;
gap: var(--space-2);
align-items: center;
padding-left: var(--space-2);
font-size: var(--font-size-small);
white-space: nowrap;
}
/* Цвет ДУБЛИРУЕТ слово, а не заменяет его, и живёт на точке: тексту цвета замечания и отказа
не хватает контраста, а точке порог для текста не предъявляется. */
.stateDot {
width: 6px;
height: 6px;
flex: none;
border-radius: var(--radius-round);
background-color: currentcolor;
color: var(--color-text-secondary);
}
.stateDot[data-tone='note'] {
.bookIcon[data-tone='note'] {
color: var(--color-note);
}
.stateDot[data-tone='danger'] {
.bookIcon[data-tone='ok'] {
color: var(--color-ok);
}
.bookIcon[data-tone='warn'] {
color: var(--color-warn);
}
.bookIcon[data-tone='danger'] {
color: var(--color-danger);
}
/* Фикс-место бейджа: ширина задана, текст выравнен вправо и усекается сам, а название слева
усекается первым, потому что забирает всю свободную ширину строки. Наложению взяться неоткуда
(замечание 23). */
.badge {
overflow: hidden;
width: var(--badge-width);
flex: none;
padding-left: var(--space-2);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
}
.noteCount {
margin-right: var(--space-2);
color: var(--color-text-muted);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
@ -51,12 +61,11 @@
width: calc(var(--progress) * 100%);
height: 100%;
border-radius: inherit;
background-color: var(--color-text-secondary);
background-color: var(--color-note);
}
.hint {
padding-inline: var(--space-2);
color: var(--color-text-muted);
.progressFill[data-done] {
background-color: var(--color-ok);
}
.hidden {

View file

@ -1,10 +1,8 @@
import { BookText, FileText, Settings } from 'lucide-react';
import { useMemo, useState } from 'react';
import { BookIcon, FileText } from 'lucide-react';
import { useMemo } from 'react';
import type { Book, Chapter, ChapterList, Library as LibraryData } from '../api';
import { Panel } from '../shell/Panel';
import { Button } from '../ui/Button';
import { FilterField } from '../ui/FilterField';
import { icon } from '../ui/icon';
import { Tree, type TreeNode } from '../ui/Tree';
import { Loaded } from './Loaded';
@ -15,34 +13,34 @@ interface Props {
library: Parameters<typeof Loaded<LibraryData>>[0]['query'];
chapters: Parameters<typeof Loaded<ChapterList>>[0]['query'];
openBookId?: string;
/** The panel's tab is lifted: the magnifier in the top bar switches it too. */
tab: string;
onTabChange: (id: string) => void;
selectedId?: string;
onSelect: (id: string) => void;
/** Одиночный клик — предпросмотр, двойной — закрепление (модель VS Code, замечание 2). */
onOpen: (id: string) => void;
onPin: (id: string) => void;
/** The row is about to open — read its chapter now, before the click completes. */
onPreload: (id: string) => void;
onAddBook: () => void;
onOpenSettings: () => void;
}
export function Library({
library,
chapters,
openBookId,
tab,
onTabChange,
selectedId,
onSelect,
onOpen,
onPin,
onPreload,
onAddBook,
onOpenSettings,
}: Props) {
const [query, setQuery] = useState('');
return (
<Panel
label="Библиотека"
landmark="navigation"
selectedId={tab}
onSelect={onTabChange}
selectedId="books"
onSelect={() => undefined}
// Вкладка здесь пока одна, но keep-alive — свойство ПАНЕЛИ: вернут вторую (поиск по
// книге, Ф-33) — прокрутка дерева на 2284 узла не потеряется вместе с ней.
keepAlive
// Ф-7: "+" stands where it has a real action. Our tab sets are fixed and there is nothing to
// add to them — but a book is added to the library from here.
add={{ label: 'Добавить книгу', onPress: onAddBook }}
@ -67,30 +65,15 @@ export function Library({
chapters={chapters}
openBookId={openBookId}
selectedId={selectedId}
onSelect={onSelect}
onOpen={onOpen}
onPin={onPin}
onPreload={onPreload}
/>
)}
</Loaded>
),
},
{
id: 'search',
label: 'Поиск',
content: (
<>
<FilterField label="Поиск по книге" value={query} onChange={setQuery} />
<p className={styles.hint}>Поиск по тексту книги появится вместе с читалкой.</p>
</>
),
},
]}
// The only way into settings in the whole interface (prompt §3.12).
footer={
<Button onPress={onOpenSettings}>
<Settings {...icon} />
Настройки
</Button>
}
/>
);
}
@ -100,8 +83,12 @@ function Books({
chapters,
openBookId,
selectedId,
onSelect,
}: Pick<Props, 'chapters' | 'openBookId' | 'selectedId' | 'onSelect'> & { books: Book[] }) {
onOpen,
onPin,
onPreload,
}: Pick<Props, 'chapters' | 'openBookId' | 'selectedId' | 'onOpen' | 'onPin' | 'onPreload'> & {
books: Book[];
}) {
// 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]);
@ -125,7 +112,9 @@ function Books({
label="Книги и разделы"
items={items}
selectedId={selectedId}
onSelect={onSelect}
onSelect={onOpen}
onActivate={onPin}
onPreload={onPreload}
defaultExpandedIds={openBookId === undefined ? [] : [openBookId]}
/>
);
@ -138,10 +127,16 @@ function bookNode(book: Book, chapters: Chapter[], chaptersState: string | null)
return {
id: book.id,
title: book.title,
icon: <BookText {...icon} className={styles.icon} />,
// The state tone lives ON the book glyph, not on a dot beside the title (remark 21).
icon: (
<span className={styles.bookIcon} data-tone={state.tone}>
<BookIcon {...icon} />
</span>
),
// Бейдж стоит в СВОЁМ месте фиксированной ширины, а название усекается: пересечься им
// теперь нечем (замечание 23).
trailing: (
<span className={styles.runState}>
{state.tone && <span className={styles.stateDot} data-tone={state.tone} />}
<span className={styles.badge} title={state.label}>
{state.label}
</span>
),
@ -178,7 +173,7 @@ function chapterNode(chapter: Chapter, index: number): TreeNode {
)}
{/* A chapter shows PROGRESS by units: it has no state of its own, the run has. */}
<span className={styles.progress} style={{ '--progress': done }}>
<span className={styles.progressFill} />
<span className={styles.progressFill} data-done={done >= 1 || undefined} />
<span className={styles.hidden}>
переведено {chapter.units_done} из {chapter.units_total}
</span>

View file

@ -39,7 +39,7 @@ const nothingChosen = {
export function Loaded<T>({ query, waiting, isEmpty, empty, idle, children }: Props<T>) {
if (query.isPending) {
if (query.fetchStatus === 'idle') return <Blank {...(idle ?? nothingChosen)} />;
return <Blank title={waiting} description="Идёт загрузка." />;
return <Blank title={waiting} description="Идёт загрузка." waiting />;
}
// A failed refetch does NOT blank a panel that already has something in it. The query layer keeps
// the previous data alongside the error, and replacing a rendered chapter with "не удалось

View file

@ -0,0 +1,39 @@
/* Строка сводки: фраза замечания и раздел, в котором оно стоит. Ступень той же полоской,
что и в тексте, чтобы список и читалка читались как одна вещь (замечание 10). */
.note {
display: grid;
gap: 0;
width: 100%;
min-width: 0;
border-left: 2px solid transparent;
padding-left: var(--space-3);
}
.note[data-tone='note'] {
border-left-color: var(--color-note);
}
.note[data-tone='quiet'] {
border-left-color: var(--color-separator);
}
/* Раздел у замечания по контракту необязателен. Такая строка никуда не ведёт и курсором
этого не обещает: список выбираемый целиком, отменить его на одной строке нечем. */
.note[data-orphan] {
cursor: default;
}
.message {
overflow: hidden;
color: var(--color-text);
white-space: nowrap;
text-overflow: ellipsis;
}
.where {
overflow: hidden;
color: var(--color-text-secondary);
font-size: var(--font-size-small);
white-space: nowrap;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,58 @@
import type { Chapter, Note } from '../api';
import { List, type ListRow } from '../ui/List';
import { measures } from '../tokens/measures';
import { chapterLabel, noteTone } from './format';
import styles from './Notes.module.css';
interface Props {
notes: Note[];
chapters: Chapter[];
/** Строка ведёт в раздел: без этого сводный список был бы просто вторым местом того же текста. */
onOpenChapter: (chapterId: string) => void;
}
/**
* Сводка замечаний по ВСЕЙ книге (замечание владельца 8 «непонятно зачем, всё рисуется
* в тексте»). Пер-главный список и правда был вторым экземпляром того, что видно в читалке.
* Пер-книжный другой сценарий: «пройтись по всем местам, требующим внимания», не открывая
* разделы по одному; строка называет свой раздел и открывает его.
*
* Данные для этого уже в памяти клиента: контракт даёт `GET /books/{id}/notes` пер-КНИЖНО и
* пер-главной ручки не имеет (Ф-32), так что сводка не стоит ни одного лишнего запроса.
*/
export function Notes({ notes, chapters, onOpenChapter }: Props) {
const rows: ListRow[] = notes.map((note, index) => {
const at = chapters.findIndex((chapter) => chapter.id === note.chapter_id);
const where = at >= 0 ? chapterLabel(chapters[at] as Chapter, at + 1) : '';
return {
// Ключ строки — ПОЗИЦИЯ, а не раздел: у контракта замечание не имеет собственного
// идентификатора, а в одном разделе их законно несколько (в фикстуре по умолчанию — три
// в одном), и ключ из `chapter_id` схлопывал их в одну строку.
id: String(index),
text: `${where} ${note.message}`,
content: (
<div
className={styles.note}
data-tone={noteTone(note.severity)}
data-orphan={note.chapter_id === undefined || undefined}
>
<span className={styles.message}>{note.message}</span>
<span className={styles.where}>{where}</span>
</div>
),
};
});
return (
<List
label="Замечания книги"
items={rows}
empty="Замечаний нет"
rowSize={measures['--row-height-term']}
onSelect={(id) => {
const chapterId = notes[Number(id)]?.chapter_id;
if (chapterId !== undefined) onOpenChapter(chapterId);
}}
/>
);
}

View file

@ -1,19 +1,39 @@
.reader {
.pairs {
flex: 1;
min-height: 0;
overflow: auto;
}
/* Блок без замечания встаёт по той же сетке, что и выноска: полоска у него прозрачная. */
.pair {
border-left: 2px solid transparent;
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
/* Волосяная линия по жёлобу между колонками ОДИН приём разделения (замечание 9). Нарисована
на СОДЕРЖИМОМ, а не на скролл-контейнере: у контейнера фон позиционируется по padding-box,
в который входит полоса прокрутки, и на классических полосах Windows центр уезжал бы от
границы колонок на половину их ширины. Здесь центр это центр контента, всегда.
Ровно 50% сходится потому, что поля блока симметричны (--space-4 плюс прозрачная граница 2px
с обеих сторон), см. .pair и .callout.
Painted in the SHELL colour: a black groove reads as the same gap that divides the panels,
rather than as one more grey line inside one. */
.sheet {
background-image: linear-gradient(var(--color-shell), var(--color-shell));
background-position: center top;
background-size: 1px 100%;
background-repeat: no-repeat;
}
/* Блок без замечания встаёт по той же сетке, что и выноска: полоска у него прозрачная. */
.pair {
border-inline: 2px solid transparent;
/* Padding + the transparent border equal the tab inset, so the text sits under the tab label. */
padding: var(--space-3);
}
/* Жёлоб набирается полями самих колонок, а не gap: тогда линия делит его пополам, а суммарный
отступ между текстами остаётся прежним приём ровно один, ширина колонок не поехала. */
.columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 var(--space-6);
gap: 0;
}
.source,
@ -31,10 +51,12 @@
бы вовсе; `:lang()`-ветка была бы той же пар-спецификой, только записанной в CSS. */
.source {
color: var(--color-text-secondary);
padding-right: var(--space-4);
}
.target {
color: var(--color-text);
padding-left: var(--space-4);
}
.missing {

View file

@ -15,6 +15,10 @@ interface Props {
* Two columns with NO scroll synchronisation: one scroll container, and the columns sit inside the
* row of a pair then they physically cannot drift apart (STACK_DECISIONS §2). The unit of a pair
* is the export's edit unit, and a whole chapter is sometimes a single block.
*
* The columns are told apart by ONE device a hairline down the gutter (owner's remark 9), drawn
* on the sheet rather than per block: a rule repeated on every pair breaks into dashes at the gaps
* between them, which reads as a table rather than as two columns.
*/
export function Reader({ sourceLang, targetLang, units }: Props) {
return (
@ -28,36 +32,41 @@ export function Reader({ sourceLang, targetLang, units }: Props) {
}}
>
{(data) => (
<div className={styles.reader}>
{data.units.map((unit) => {
const columns = (
<div className={styles.columns}>
<div className={styles.source} lang={sourceLang}>
{unit.source}
// tabIndex: a scrollable region has to be reachable from the keyboard, or the chapter can
// only be paged with a mouse. Found by the axe pass over the OPEN state — the route gate
// never saw it, because on its frame the text did not overflow the panel.
<div className={styles.pairs} tabIndex={0}>
<div className={styles.sheet}>
{data.units.map((unit) => {
const columns = (
<div className={styles.columns}>
<div className={styles.source} lang={sourceLang}>
{unit.source}
</div>
<div className={styles.target} lang={targetLang}>
{unit.target === undefined || unit.target === '' ? (
<span className={styles.missing}>перевод не получен</span>
) : (
unit.target
)}
</div>
</div>
<div className={styles.target} lang={targetLang}>
{unit.target === undefined || unit.target === '' ? (
<span className={styles.missing}>перевод не получен</span>
) : (
unit.target
)}
</div>
</div>
);
return unit.note ? (
<Callout
key={unit.id}
tone={noteTone(unit.note.severity)}
caption={unit.note.message}
>
{columns}
</Callout>
) : (
<article key={unit.id} className={styles.pair}>
{columns}
</article>
);
})}
);
return unit.note ? (
<Callout
key={unit.id}
tone={noteTone(unit.note.severity)}
caption={unit.note.message}
>
{columns}
</Callout>
) : (
<article key={unit.id} className={styles.pair}>
{columns}
</article>
);
})}
</div>
</div>
)}
</Loaded>

View file

@ -0,0 +1,27 @@
.sections {
display: grid;
gap: var(--space-5);
}
.section {
display: grid;
gap: var(--space-1);
border-left: 2px solid var(--color-border);
padding-left: var(--space-5);
}
.name {
color: var(--color-text);
}
.about {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.note {
padding-top: var(--space-5);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -0,0 +1,34 @@
import { Modal } from '../ui/Modal';
import styles from './Settings.module.css';
/**
* Настройки модальным окном из правого верхнего угла (замечания 11 и 19). Внутри КАРКАС:
* содержимое строит S7, а переключатели, которые ничего не переключают, это кнопки без
* действия, и норма зоны их запрещает (Ф-7/Ф-16).
*/
export function Settings({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
return (
<Modal title="Настройки" isOpen={isOpen} onClose={onClose}>
<dl className={styles.sections}>
{sections.map(([name, about]) => (
<div className={styles.section} key={name}>
<dt className={styles.name}>{name}</dt>
<dd className={styles.about}>{about}</dd>
</div>
))}
</dl>
<p className={styles.note}>Разделы наполняются отдельным пакетом работ.</p>
</Modal>
);
}
const sections: [string, string][] = [
['Профиль и доступ', 'Учётная запись, вход, завершение сеансов.'],
['Язык интерфейса', 'Сейчас интерфейс только русский.'],
['Светлая тема', 'Сейчас доступна только тёмная.'],
[
'Перевод по умолчанию',
'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.',
],
['Использование', 'Сколько израсходовано и что будет при исчерпании лимита.'],
];

View file

@ -1,23 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { Search } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query';
import { Search, Settings as SettingsIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { bankQuery, bookQuery, chaptersQuery, libraryQuery, notesQuery, unitsQuery } from '../api';
import { Panel } from '../shell/Panel';
import { Shell } from '../shell/Shell';
import { useLayout } from '../shell/layout';
import { Shell } from '../shell/Shell';
import { Button } from '../ui/Button';
import { icon } from '../ui/icon';
import type { TabItem } from '../ui/Tabs';
import { AddBook } from './AddBook';
import { Blank } from './Blank';
import { Context } from './Context';
import { Document } from './Document';
import { Goto } from './Goto';
import { Library } from './Library';
import type { Query } from './Loaded';
import { Reader } from './Reader';
import { Settings } from './Settings';
import { Status } from './Status';
import { chapterLabel } from './format';
import { rooms } from './rooms';
import {
closeDocument,
firstLook,
openedIds,
pinDocument,
previewDocument,
selectDocument,
} from './documents';
import { chapterLabel, isDraft } from './format';
import { useRunStream } from './useRunStream';
import { useScreenshotFlag } from './useScreenshotFlag';
type Overlay = 'settings' | 'add-book' | 'goto' | null;
/**
* The showcase, now on the data layer. One screen for every scenario: what changes between routes
@ -50,170 +63,174 @@ export function Showcase() {
};
const connection = useRunStream(bookId, book.data?.run?.id);
const expandPanel = useLayout((state) => state.expand);
const [leftTab, setLeftTab] = useState('books');
const [overlay, setOverlay] = useState<Overlay>(null);
const [contextTab, setContextTab] = useState('bank');
// Не строка поиска, а ПРОСЬБА палитры показать термин: строку банк держит у себя. С номером,
// потому что просьба показать ТОТ ЖЕ термин второй раз — тоже просьба, а по равному значению
// React ничего не пересчитает.
const [bankRequest, setBankRequest] = useState({ term: '', seq: 0 });
// `null` means "the user has not touched the tabs yet", which is not the same as "no tabs open".
// Kept as a derived default rather than seeded by an effect: writing state from an effect makes
// the first frame render twice, and the chapters arrive asynchronously, so it would render twice
// on every load.
const [opened, setOpened] = useState<{ ids: string[]; active: string } | null>(null);
const [opened, setOpened] = useState<ReturnType<typeof firstLook> | null>(null);
const rows = useMemo(() => chapters.data?.chapters ?? [], [chapters.data]);
const documents = useMemo(() => {
if (opened) return opened;
// The first two chapters open by themselves, so the screenshot shows the shell doing its job
// rather than an empty centre.
const ids = rows.slice(1, 3).map((chapter) => chapter.id);
return { ids, active: ids[0] ?? '' };
}, [opened, rows]);
const documents = opened ?? firstLook(rows.map((chapter) => chapter.id));
const activeChapter = rows.some((chapter) => chapter.id === documents.active)
? documents.active
: undefined;
const units = useQuery({
...unitsQuery(bookId ?? '', activeChapter ?? ''),
enabled: enabled && activeChapter !== undefined,
});
const open = (id: string) => setOpened(previewDocument(documents, id));
const pin = (id: string) => setOpened(pinDocument(documents, id));
const open = (id: string) =>
setOpened({
ids: documents.ids.includes(id) ? documents.ids : [...documents.ids, id],
active: id,
});
const close = (id: string) => {
const ids = documents.ids.filter((other) => other !== id);
setOpened({ ids, active: documents.active === id ? (ids[0] ?? '') : documents.active });
const positionOf = (id: string) => rows.findIndex((chapter) => chapter.id === id);
// Read the chapter while the button is still down, so the click lands on an answer that is
// already here. Measured: cold, the reader shows a "loading" card for 130230 ms before the
// text; warm, it never appears at all. The query layer drops a prefetch of something it already
// holds fresh, so pressing the same row twice costs one read, not two.
const client = useQueryClient();
const preload = (id: string) => {
if (bookId === undefined || positionOf(id) < 0) return;
void client.prefetchQuery(unitsQuery(bookId, id));
};
const titleOf = (id: string) => {
const index = rows.findIndex((chapter) => chapter.id === id);
const index = positionOf(id);
const chapter = rows[index];
return chapter ? chapterLabel(chapter, index + 1) : (rooms[id]?.title ?? '');
return chapter ? chapterLabel(chapter, index + 1) : '';
};
const tabs: TabItem[] = documents.ids.map((id) => {
const room = rooms[id];
return {
const draft = isDraft(book.data?.book.status);
const tabs: TabItem[] = openedIds(documents)
.filter((id) => positionOf(id) >= 0)
.map((id) => ({
id,
label: titleOf(id),
onClose: () => close(id),
content: room ? (
<Blank {...room} />
) : (
<Reader
preview: documents.preview === id,
onClose: () => setOpened(closeDocument(documents, id)),
content: (
<Document
bookId={bookId ?? ''}
chapterId={id}
sourceLang={book.data?.book.source_lang}
targetLang={book.data?.book.target_lang}
units={awaiting(units)}
draft={draft}
onEngage={() => pin(id)}
/>
),
};
});
}));
// "In flight" and not merely "pending": a switched-off query is pending forever, and counting it
// would hold the flag at `loading` on a screen that has long since settled.
useScreenshotFlag(
[library, book, chapters, notes, bank, units].some(
(query) => query.isPending && query.fetchStatus !== 'idle',
),
);
// would hold the flag at `loading` on a screen that has long since settled. Asked of the query
// client rather than of a hand-kept list — the chapter reads now live inside the tabs.
useScreenshotFlag(useIsFetching() > 0);
return (
<Shell
title={book.data?.book.title ?? 'TextMachine'}
actions={
<Button
look="icon"
aria-label="Поиск по книге"
onPress={() => {
// The panel unfolds with the same press: on a collapsed left panel the button would
// otherwise silently switch a tab nobody can see, and read as dead.
expandPanel('left');
setLeftTab('search');
}}
>
<Search {...icon} />
</Button>
}
left={
<Library
library={library}
chapters={awaiting(chapters)}
openBookId={bookId}
tab={leftTab}
onTabChange={setLeftTab}
selectedId={documents.active}
onSelect={(id) => {
if (rows.some((chapter) => chapter.id === id)) open(id);
}}
onAddBook={() => open('add-book')}
onOpenSettings={() => open('settings')}
/>
}
center={
<Panel
label="Открытые документы"
landmark="main"
look="document"
tabs={tabs}
selectedId={documents.active}
onSelect={(id) => {
setOpened({ ...documents, active: id });
}}
empty={
// Two different emptinesses, and telling the user to pick a chapter when there is no
// book to pick one from is the kind of wrong that reads as a broken screen.
pendingLibrary ? (
<Blank title="Библиотека" description="Идёт загрузка." />
) : failedLibrary ? (
<Blank
title="Библиотека недоступна"
description="Список книг не загрузился. Обновите страницу — ничего не потеряно."
/>
) : enabled ? (
<Blank
title="Ничего не открыто"
description="Выберите раздел книги слева — он откроется здесь вкладкой."
/>
) : (
<Blank
title="Библиотека пуста"
description="Добавьте книгу — её разделы откроются здесь вкладками."
/>
)
}
/>
}
right={
<Context
book={awaiting(book)}
chapterId={activeChapter}
libraryEmpty={!enabled && !pendingLibrary && !failedLibrary}
notes={awaiting(notes)}
bank={awaiting(bank)}
onSignBank={() => open('bank')}
/>
}
status={
<Status
book={book.data?.book}
run={book.data?.run ?? null}
connection={connection}
chapter={titleOf(documents.active)}
/>
}
/>
<>
<Shell
title={book.data?.book.title ?? 'TextMachine'}
actions={
<>
<Button
look="icon"
aria-label="Перейти к разделу или термину"
onPress={() => setOverlay('goto')}
>
<Search {...icon} />
</Button>
{/* Единственный вход в настройки, и он в правом верхнем углу, как у Fleet
(замечания 11 и 19); из низа левой панели вход убран. */}
<Button look="icon" aria-label="Настройки" onPress={() => setOverlay('settings')}>
<SettingsIcon {...icon} />
</Button>
</>
}
left={
<Library
library={library}
chapters={awaiting(chapters)}
openBookId={bookId}
selectedId={documents.active}
onOpen={(id) => {
if (positionOf(id) >= 0) open(id);
}}
onPin={(id) => {
if (positionOf(id) >= 0) pin(id);
}}
onPreload={preload}
onAddBook={() => setOverlay('add-book')}
/>
}
center={
<Panel
label="Открытые документы"
landmark="main"
look="document"
tabs={tabs}
selectedId={documents.active}
onSelect={(id) => setOpened(selectDocument(documents, id))}
onPin={pin}
keepAlive
empty={<Blank {...emptyCentre(pendingLibrary, failedLibrary, enabled)} />}
/>
}
right={
<Context
book={awaiting(book)}
chapters={rows}
libraryEmpty={!enabled && !pendingLibrary && !failedLibrary}
notes={awaiting(notes)}
bank={awaiting(bank)}
tab={contextTab}
onTabChange={setContextTab}
request={bankRequest}
onOpenChapter={open}
/>
}
status={
<Status
book={book.data?.book}
run={book.data?.run ?? null}
connection={connection}
chapter={titleOf(documents.active)}
/>
}
/>
<Settings isOpen={overlay === 'settings'} onClose={() => setOverlay(null)} />
<AddBook isOpen={overlay === 'add-book'} onClose={() => setOverlay(null)} />
<Goto
isOpen={overlay === 'goto'}
onClose={() => setOverlay(null)}
chapters={rows}
terms={bank.data?.terms ?? []}
onOpenChapter={open}
onOpenTerm={(src) => {
// Действие ведёт ВНУТРЬ правой панели, значит обязано её показать: на свёрнутой
// панели оно молча переключало бы вкладку, которой никто не видит.
expandPanel('right');
setContextTab('bank');
setBankRequest((current) => ({ term: src, seq: current.seq + 1 }));
}}
/>
</>
);
}
/**
* The screenshot cycle needs a deterministic signal, and `networkidle` cannot be it any more: a run
* holds an SSE connection open by design, so the network never goes idle. The screen says instead
* when it has decided what to draw `loading` is a final picture too, for the scenario built to
* hold it still.
*/
function useScreenshotFlag(waiting: boolean): void {
useEffect(() => {
document.documentElement.dataset.screen = waiting ? 'loading' : 'ready';
}, [waiting]);
// Two different emptinesses, and telling the user to pick a chapter when there is no book to pick
// one from is the kind of wrong that reads as a broken screen.
function emptyCentre(pending: boolean, failed: boolean, hasBook: boolean) {
if (pending) return { title: 'Библиотека', description: 'Идёт загрузка.' };
if (failed)
return {
title: 'Библиотека недоступна',
description: 'Список книг не загрузился. Обновите страницу — ничего не потеряно.',
};
if (hasBook)
return {
title: 'Ничего не открыто',
description: 'Выберите раздел книги слева — он откроется здесь вкладкой.',
};
return {
title: 'Библиотека пуста',
description: 'Добавьте книгу — её разделы откроются здесь вкладками.',
};
}

View file

@ -2,7 +2,7 @@
display: flex;
gap: var(--space-4);
margin-left: auto;
color: var(--color-text-muted);
color: var(--color-text-secondary);
}
/* A resumable stop is not an alarm: the same muted row, a slightly brighter word. */

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
closeDocument,
noDocuments,
openedIds,
pinDocument,
previewDocument,
selectDocument,
} from './documents';
// The owner's complaint (2) in executable form: five single clicks must leave ONE tab in the row.
describe('модель вкладок VS Code', () => {
it('пять одиночных кликов дают ровно одну вкладку предпросмотра', () => {
let documents = noDocuments;
for (const id of ['ch_1', 'ch_2', 'ch_3', 'ch_4', 'ch_5']) {
documents = previewDocument(documents, id);
}
expect(openedIds(documents)).toEqual(['ch_5']);
expect(documents.active).toBe('ch_5');
});
it('закрепление превращает предпросмотр в постоянную, следующий клик открывает новую рядом', () => {
let documents = previewDocument(noDocuments, 'ch_1');
documents = pinDocument(documents, 'ch_1');
expect(documents.preview).toBeNull();
documents = previewDocument(documents, 'ch_2');
expect(openedIds(documents)).toEqual(['ch_1', 'ch_2']);
expect(documents.preview).toBe('ch_2');
// И следующий одиночный клик замещает ТОЛЬКО предпросмотр, закреплённую не трогая.
documents = previewDocument(documents, 'ch_3');
expect(openedIds(documents)).toEqual(['ch_1', 'ch_3']);
});
it('клик по уже закреплённой вкладке выбирает её и предпросмотр не заводит', () => {
let documents = pinDocument(previewDocument(noDocuments, 'ch_1'), 'ch_1');
documents = previewDocument(documents, 'ch_2');
documents = previewDocument(documents, 'ch_1');
expect(documents.active).toBe('ch_1');
expect(documents.preview).toBe('ch_2');
});
it('закрытие выбранной вкладки уводит выбор на соседнюю справа', () => {
let documents = noDocuments;
for (const id of ['a', 'b', 'c']) documents = pinDocument(documents, id);
documents = selectDocument(documents, 'b');
documents = closeDocument(documents, 'b');
expect(documents.active).toBe('c');
expect(openedIds(documents)).toEqual(['a', 'c']);
});
it('закрытие последней вкладки оставляет центр пустым, а не выбранным в никуда', () => {
const documents = closeDocument(previewDocument(noDocuments, 'ch_1'), 'ch_1');
expect(openedIds(documents)).toEqual([]);
expect(documents.active).toBe('');
});
it('закрытие НЕ выбранной вкладки выбор не двигает', () => {
let documents = noDocuments;
for (const id of ['a', 'b']) documents = pinDocument(documents, id);
const after = closeDocument(documents, 'a');
expect(after.active).toBe('b');
});
});

View file

@ -0,0 +1,70 @@
/**
* Модель открытых документов центра та же, что у VS Code (замечание владельца 2).
* Закрепляет вкладку двойной клик либо содержательное взаимодействие с содержимым; что у нас
* считается содержательным и почему `Document.tsx`. Функции чистые: модель проверяется
* тестом, а не кадром (documents.test.ts).
*/
export interface Documents {
/** Закреплённые вкладки в порядке открытия. */
pinned: string[];
/** Вкладка предпросмотра. Её ровно одна или ни одной. */
preview: string | null;
/** Выбранная вкладка; пустая строка означает «не открыто ничего». */
active: string;
}
export const noDocuments: Documents = { pinned: [], preview: null, active: '' };
/** Предпросмотр всегда крайний справа: закрепление не двигает ряд. */
export const openedIds = ({ pinned, preview }: Documents): string[] =>
preview === null ? pinned : [...pinned, preview];
/** Одиночный клик. Уже закреплённая вкладка просто выбирается — предпросмотр её не трогает. */
export function previewDocument(documents: Documents, id: string): Documents {
if (documents.pinned.includes(id)) return { ...documents, active: id };
return { ...documents, preview: id, active: id };
}
/** Двойной клик или содержательное взаимодействие: предпросмотр становится постоянной вкладкой. */
export function pinDocument(documents: Documents, id: string): Documents {
if (documents.pinned.includes(id)) return { ...documents, active: id };
return {
pinned: [...documents.pinned, id],
preview: documents.preview === id ? null : documents.preview,
active: id,
};
}
export function selectDocument(documents: Documents, id: string): Documents {
return { ...documents, active: id };
}
/**
* Закрытие. Выбор переходит на соседнюю вкладку СПРАВА, а если её нет слева: так место
* в ряду не теряется, и закрытие подряд не выбрасывает читателя в пустой центр.
*/
export function closeDocument(documents: Documents, id: string): Documents {
const order = openedIds(documents);
const index = order.indexOf(id);
const next: Documents = {
pinned: documents.pinned.filter((other) => other !== id),
preview: documents.preview === id ? null : documents.preview,
active: documents.active,
};
if (documents.active !== id) return next;
const rest = openedIds(next);
next.active = rest[Math.min(index, rest.length - 1)] ?? '';
return next;
}
/**
* Что открыто, пока читатель не трогал вкладки: одна закреплённая и одна предпросмотра иначе
* обе формы вкладки на статичном кадре не видны, а различает их модель VS Code курсивом.
* Берутся второй и третий разделы: в фикстуре по умолчанию именно у них есть и текст, и
* замечания. Это подпорка ВИТРИНЫ и уйдёт вместе с ней, когда появится настоящая библиотека.
*/
export function firstLook(ids: string[]): Documents {
const [, pinned, preview] = ids;
if (pinned === undefined) return noDocuments;
return { pinned: [pinned], preview: preview ?? null, active: pinned };
}

View file

@ -1,14 +1,28 @@
import { bookStatus, noteSeverity, type Chapter, type Progress } from '../api';
import { bookStatus, noteSeverity, type BookStatus, type Chapter, type Progress } from '../api';
import { interfaceLocale } from '../ui/Locale';
// Language arrives as a code, never as a word: the second pair of the product has to work without
// an edit to the markup (the canon's review question, CLAUDE.md §2). Names and numbers are computed
// for the Russian interface.
const languages = new Intl.DisplayNames(['ru'], { type: 'language' });
const numbers = new Intl.NumberFormat('ru-RU');
const languages = new Intl.DisplayNames([interfaceLocale], { type: 'language' });
const numbers = new Intl.NumberFormat(interfaceLocale);
/** Дата — тоже формат интерфейса, а не данных: строка контракта всегда ISO. */
export const date = (value: string) => new Date(value).toLocaleDateString(interfaceLocale);
export const languageName = (code: string) => languages.of(code) ?? code;
export const number = (value: number) => numbers.format(value);
const plurals = new Intl.PluralRules(interfaceLocale);
/**
* Число со склоняемым словом: «1 термин», «2 термина», «60 терминов». Формы перечисляет
* вызывающий, разряд выбирает Intl таблицу окончаний руками не пишем.
*/
export function counted(value: number, forms: Record<Intl.LDMLPluralRule, string>): string {
return `${number(value)} ${forms[plurals.select(value)]}`;
}
/**
* Severity of a note look of the callout. The map itself lives on the `src/api/` seam together
* with the vocabulary, so a new step is added in one file; this is only the call.
@ -19,6 +33,13 @@ export const noteTone = (severity: Parameters<typeof noteSeverity.describe>[0])
export const statusOf = (status: Parameters<typeof bookStatus.describe>[0]) =>
bookStatus.describe(status);
/**
* Черновой, пока прогон не довёл книгу до `ready`: и перевод, и нарезка законно пересобираются.
* Неизвестное состояние тоже черновик (безопасная сторона); отсутствие книги пометки не даёт.
*/
export const isDraft = (status: BookStatus | null | undefined) =>
status !== undefined && status !== 'ready';
/**
* What a chapter is called in the tree.
*

View file

@ -1,23 +0,0 @@
// Кроме глав центр открывает ровно три документа, и все три ратифицированы: подписной экран
// банка (решение владельца 02.08), добавление книги (§3.1) и настройки (§3.12). Экраны за ними
// строят следующие сессии — витрина показывает, что действие оболочки настоящее, а не нарисованное.
export const rooms: Record<string, { title: string; description: string }> = {
bank: {
title: 'Банк памяти',
description:
'Подпись сотен терминов подряд: колонки, клавиатура вместо мыши, массовые действия. ' +
'В правой панели остаётся справочный вид — «что это за термин».',
},
'add-book': {
title: 'Добавить книгу',
description:
'Перенос файла книги, языки пары и жанр, затем видимое подтверждение загрузки и разбор ' +
'на разделы.',
},
settings: {
title: 'Настройки',
description:
'Профиль и доступ, конфигурация моделей, параметры перевода по умолчанию. Всё техническое ' +
'живёт здесь и только здесь.',
},
};

View file

@ -0,0 +1,20 @@
import type { BankTerm } from '../api';
/**
* Совпал ли термин с запросом. Один предикат на банк и на палитру перехода: разойдись они,
* палитра находила бы то, чего банк, куда она ведёт, не показывает.
*
* Регистр складывается с обеих сторон: у иероглифов его нет, но исходная сторона пары бывает
* латиницей, и односторонний фильтр вёл бы себя в двух колонках по-разному.
*/
export const matchesTerm = (query: string) => {
// Запрос приводится ОДИН раз, а не на каждый термин: предикат зовут по тысяче строк на
// перерисовку, и `trim().toLowerCase()` внутри стоил бы тысячи лишних строк.
const needle = query.trim().toLowerCase();
// ⚠ Поиска по описанию тут нет и быть не может: описание закрыто спойлером, а поле, по
// которому ищут, работает оракулом — по нему закрытое вычисляется, ничего не раскрывая.
return (term: BankTerm) =>
needle === '' ||
term.src.toLowerCase().includes(needle) ||
term.dst.toLowerCase().includes(needle);
};

View file

@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react';
/**
* Сигнал готовности для скриншот-цикла. `networkidle` не годится и не может годиться: живой
* прогон держит SSE открытым по построению. Экран говорит сам, когда решил, что рисовать,
* и «жду» тоже решение, для маршрута, который снят ради ожидания.
*
* Флаг ОДНОСТОРОННИЙ: однажды сошёлся больше не гаснет. Он отвечает на вопрос скриншот-цикла
* «экран уже собрался?», а не «сеть сейчас молчит»: фоновый рефетч по возврату фокуса и обновление
* от живого прогона происходят постоянно и по построению, и без защёлки кадр снимался бы в гонке
* с ними. Маршрут, снятый ради ожидания, защёлку не трогает он до «готово» не доходит.
*/
export function useScreenshotFlag(waiting: boolean): void {
const started = useRef(false);
const settled = useRef(false);
useEffect(() => {
// Защёлка закрывается только ПОСЛЕ того, как что-то реально полетело: на первом эффекте
// запросов ещё нет в полёте, и защёлка без этого условия срабатывала сразу — маршрут,
// снятый ради ожидания, немедленно объявлял себя готовым (поймал прогон скриншот-цикла).
if (waiting) started.current = true;
else if (started.current) settled.current = true;
document.documentElement.dataset.screen = waiting && !settled.current ? 'loading' : 'ready';
}, [waiting]);
}

View file

@ -4,9 +4,15 @@
// и падает при расхождении.
export const measures = {
'--row-height': 26,
'--row-height-term': 46,
'--row-height-table': 32,
'--column-fit-max': 260,
'--column-min': 110,
'--panel-side-width': 320,
'--panel-side-min': 220,
'--panel-side-max': 560,
'--panel-side-max': 900,
'--panel-context-width': 620,
'--panel-context-min': 532,
} as const;
export const px = (name: keyof typeof measures) => `${measures[name]}px`;

View file

@ -66,9 +66,13 @@
cursor: pointer;
}
/* Полпикселя лекарство от мыла на штрихах: сетка иконок 24 при размере 16 ставит центр
штриха на целый пиксель, и линия шириной 1px размазывается по двум половинкам. Замер,
отвергнутые варианты и цифры FRONTEND_PLAN.md §5.5. */
svg {
display: block;
flex: none;
transform: translate(0.5px, 0.5px);
}
/* Дефолтные скроллбары Chromium на Windows толстые и светлые на трёх панелях и в читалке

View file

@ -1,54 +1,98 @@
/* Единственный источник цвета, размера и шрифта во всём приложении.
Значения сняты с references/fleet.png замером (гистограмма кадра, срезы строк и столбцов,
ГЕОМЕТРИЯ снята с references/fleet.png замером (гистограмма кадра, срезы строк и столбцов,
детект края скругления по порогу яркости) таблица замеров и способ в docs/FRONTEND_PLAN.md §5.1.
SURFACE TONE is measured off references/fleet_2.png and antigravity_*.png the two editors the
owner called perfect (second-round remarks 2 and 9); the histograms are in FRONTEND_PLAN.md §5.6.
Литеральный цвет легален только здесь; во всех прочих файлах его запрещают гейты
stylelint.config.js и eslint.config.js. */
/* Порядок каскада объявлен в reset.css — он импортируется первым. Токены живут вне слоёв. */
:root {
/* --- Поверхности Fleet --- */
--color-shell: #090909; /* фон оболочки: поля, верхняя и статус-полоса */
--color-panel: #17191a; /* заливка панели; все три панели одинаковы */
--color-raised: #27292b; /* приподнятая поверхность, наведение, активная вкладка панели */
--color-selected: #353739; /* выбранная строка дерева, клавиша-чип */
/* --- Surfaces: strictly neutral, as both references are (fleet_2 #181818/#292929,
antigravity #101010/#161616/#1c1c1c/#252525). --color-shell stays as it is. */
--color-shell: #090909; /* margins, top bar, status bar */
--color-panel: #181818; /* panel canvas — the ground the tab chips lie on */
--color-chrome: #242424; /* the ground of a table header, which stands above its rows */
--color-raised: #212121; /* row hover */
--color-raised-strong: #2f2f2f; /* the one step above it: primary action of a form */
--color-selected: #1c4478; /* fleet_2 paints the selected row blue (#184176), not grey */
--color-current-line: #152945;
--color-selection: #164e8d;
--color-tab-active: #142f4c; /* fleet.png fills the open document's tab blue, not grey */
/* Активная вкладка открытого документа у Fleet синеватая, а не серая: замер вкладки
rpc-node.ts дал #142f4c, тогда как #353739 лежит только под строкой дерева.
§1.1 промта эти две роли смешал здесь они разделены. */
--color-tab-active: #142f4c;
--color-current-line: #152945; /* подсветка строки под курсором */
--color-selection: #164e8d; /* выделение текста */
/* --- Text --- */
--color-text: #dedede;
--color-text-secondary: #9b9b9b;
--color-text-muted: #707070; /* ⚠ NON-text only: 3.9:1 on the panel is below WCAG (Ф-11) */
/* --- Текст --- */
--color-text: #dfe1e3;
--color-text-secondary: #8a8e91; /* статус-полоса, подписи */
--color-text-muted: #707479; /* номера строк, третьестепенное */
/* --- Цвет означает состояние и больше ничего (промт §4.2) --- */
--color-accent: #746deb; /* единственный акцент: фокус ввода */
/* --- Colour means state (prompt §4.2): in progress · done · waiting on a person · refusal.
Blue/green/red are measured (antigravity NOTE/TIP, fleet); no amber occurs in any frame. */
--color-accent: #746deb; /* input focus and the open document */
--color-note: #2964ad;
--color-ok: #3c7d4f;
--color-warn: #b0863a;
--color-danger: #b82e45;
--color-note: #2964ad; /* полоска замечания-заметки (antigravity_chat.png) */
--color-hint: #3c7d4f; /* полоска замечания-подсказки */
--color-separator: #27292b; /* разделители и вторая, спокойная ступень замечания */
--color-scrollbar-thumb: #353739;
--color-separator: #262626; /* rules, and the quiet severity step of a note */
--color-scrollbar-thumb: #333;
/* Modal layer; one step lighter than the panel, or it reads as part of it. */
--color-scrim: #090909cc;
--color-elevated: #202020;
--color-border: #2e2e2e;
/* --- Геометрия (CSS-пиксели; снимок референса сделан при 2x) --- */
--gap: 8px; /* промежуток между панелями и поле оболочки — замер: 16 физ. без исключений */
--radius-panel: 6px; /* угол выходит на прямую за 12 физ. */
--radius-control: 6px;
--radius-round: 50%; /* точка состояния */
--radius-bar: 2px; /* полоска выполнения главы */
--topbar-height: 28px;
--statusbar-height: 20px;
--panel-header-height: 26px;
/* Пере-замер S3.5: полосы у Fleet прижаты к краям окна, содержимое центрируется в самой
полосе. Прежняя разбивка «8 поля + 28 полосы» держала текст снизу на 4px выше.
Числа и способ FRONTEND_PLAN.md §5.5. */
--topbar-height: 36px;
--statusbar-height: 28px;
--bar-inset: 12px; /* поле текста полос от края окна: замер Fleet — 12.5 слева и 13.5 справа */
--panel-padding: 6px;
--panel-header-height: 26px; /* высота пилюли вкладки: замер fleet.png */
--tab-inset: 10px; /* поле вкладки: замер Fleet — пилюля «Files» 15‥59 при тексте 25‥52 */
--tab-width-max: 216px; /* a long title ellipsizes instead of squeezing its neighbours */
--panel-side-width: 320px; /* замер: боковые панели 8‥328 и 952‥1272 при вьюпорте 1280 */
--panel-side-min: 220px; /* уже этого справочные колонки перестают быть читаемыми */
--panel-side-max: 560px;
--panel-side-max: 900px; /* банк живёт в правой панели, и его таблицу тянут шире справки */
--panel-context-width: 620px; /* правая шире левой: в ней таблица, а не список */
--panel-context-min: 532px; /* уже своей таблицы панель не сжимается: её скроллер уехал бы за край */
--row-height: 26px; /* шаг строки дерева и таблицы */
--row-fill-inset: 1px; /* подложка выбранной строки 24px при шаге 26 */
--row-inset: 10px; /* текст подвала встаёт по тексту строк: 6 поля панели + 4 поля строки */
--row-height-term: 46px; /* virtualiser STEP of the notes summary and the bank lookup list */
--row-height-table: 32px; /* virtualiser STEP of the bank table */
/* Bank table. Column widths are measured off the content, so these two are the only bounds:
how far a column may grow to fit what it holds, and the floor for a column of prose, which no
width would ever fit. Below the sum of the floors the table scrolls sideways. */
--column-fit-max: 260px; /* «Монах Цветочного Вина» + поля укладываются в 200 */
--column-min: 110px; /* ниже этого столбец не сжимается ни раскладкой, ни рукой */
/* Фикс-место бейджа: название усекается, бейдж не наезжает. Самые длинные метки словаря
(«остановлена: лимиты», «неизвестное состояние») сюда не влезают и усекаются с тултипом
какое слово стоит в дереве, решает владелец (В-8), а не ширина колонки. */
--badge-width: 104px;
/* Resize handle, numbers copied verbatim from vojo (`components/page/style.css.ts:11-70`):
rest 2×36, drag 48, min stop 3×28, max stop 2×76. */
--handle-width: 2px;
--handle-width-limit: 3px;
--handle-length: 36px;
--handle-length-active: 48px;
--handle-length-min: 28px;
--handle-length-max: 76px;
/* Модальное окно поверх оболочки: настройки, добавление книги, палитра перехода. */
--modal-offset: 12vh; /* окно стоит выше центра: под ним видно оболочку, из которой позвали */
--modal-width: 460px;
--modal-width-wide: 560px; /* палитра перехода: строка ввода плюс список результатов */
--modal-height-list: 320px; /* сколько списка палитры видно до прокрутки */
/* --- Шкала отступов --- */
--space-1: 2px;
@ -64,6 +108,7 @@
--font-size-ui: 13px;
--font-size-small: 12px;
--font-size-content: 13px;
--font-size-source: 14px; /* исходная сторона: CJK при равном кегле читается мельче */
--line-height-ui: 18px;
--line-height-content: 21px;
}

View file

@ -21,43 +21,76 @@ const sources = import.meta.glob('../**/*.{ts,tsx}', {
// Значение разошлось с таблицей — это либо новый замер, либо ошибка; тихо править нельзя.
const measured: Record<string, string> = {
'--color-shell': '#090909',
'--color-panel': '#17191a',
'--color-raised': '#27292b',
'--color-selected': '#353739',
'--color-tab-active': '#142f4c',
'--color-panel': '#181818', // fleet_2 canvas, verbatim
'--color-current-line': '#152945',
'--color-selection': '#164e8d',
'--color-text': '#dfe1e3',
'--color-text-secondary': '#8a8e91',
'--color-text-muted': '#707479',
'--color-tab-active': '#142f4c',
'--color-accent': '#746deb',
'--color-danger': '#b82e45',
'--color-note': '#2964ad',
'--color-hint': '#3c7d4f',
'--color-ok': '#3c7d4f',
'--gap': '8px',
'--radius-panel': '6px',
'--topbar-height': '28px',
'--statusbar-height': '20px',
'--panel-header-height': '26px',
// Пере-замер S3.5, не подгонка под зелень: полосы прижаты к краям окна (FRONTEND_PLAN §5.5).
'--topbar-height': '36px',
'--statusbar-height': '28px',
'--bar-inset': '12px',
'--panel-padding': '6px',
'--tab-inset': '10px',
'--row-height': '26px',
'--panel-header-height': '26px',
'--row-fill-inset': '1px',
'--line-height-content': '21px',
'--panel-side-width': '320px',
};
/**
* Tones taken FROM a reference but not verbatim: the reference value is in the comment, ours on
* the left. Same lock as the measurements the number is pinned, so a tone cannot move quietly.
*/
const toned: Record<string, string> = {
'--color-chrome': '#242424', // fleet_2 chrome #292929, one step calmer on a card
'--color-raised': '#212121', // fleet_2 #292929 is the strip; hover sits below it
'--color-selected': '#1c4478', // fleet_2 selected row #184176
'--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
'--color-text-muted': '#707070', // fleet #707479
};
// Замера у этих значений нет: кегли и интерлиньяж подобраны на глаз (померить их больше негде),
// шкала отступов и радиусы заданы нами, шрифтовые стеки — выбор. Список существует ради замка
// полноты ниже: без него новый токен приезжает вообще без гейта, по построению (Ф-10).
const chosen = [
'--color-raised-strong',
'--color-separator',
'--color-scrollbar-thumb',
'--color-scrim',
'--color-elevated',
'--color-border',
'--radius-control',
'--radius-round',
'--radius-bar',
'--panel-side-min',
'--panel-side-max',
'--row-inset',
'--row-height-term',
'--badge-width',
'--row-height-table',
'--column-fit-max',
'--column-min',
'--panel-context-width',
'--panel-context-min',
'--tab-width-max',
'--handle-width',
'--handle-width-limit',
'--handle-length',
'--handle-length-active',
'--handle-length-min',
'--handle-length-max',
'--modal-offset',
'--modal-width',
'--modal-width-wide',
'--modal-height-list',
'--space-1',
'--space-2',
'--space-3',
@ -69,6 +102,7 @@ const chosen = [
'--font-size-ui',
'--font-size-small',
'--font-size-content',
'--font-size-source',
'--line-height-ui',
];
@ -83,7 +117,7 @@ describe('tokens.css', () => {
afterAll(() => style.remove());
it.each(Object.entries(measured))('%s = %s', (name, value) => {
it.each([...Object.entries(measured), ...Object.entries(toned)])('%s = %s', (name, value) => {
const computed = getComputedStyle(document.documentElement).getPropertyValue(name);
expect(computed.trim()).toBe(value);
});
@ -98,7 +132,7 @@ describe('tokens.css', () => {
// Замок полноты: прежде тест сверял 23 токена из 38, а новый приезжал мимо гейта по построению.
it('каждый токен либо замерен, либо явно назван подобранным', () => {
const declared = [...tokens.matchAll(/^\s*(--[\w-]+)\s*:/gm)].map((match) => match[1] ?? '');
const classified = new Set([...Object.keys(measured), ...chosen]);
const classified = new Set([...Object.keys(measured), ...Object.keys(toned), ...chosen]);
const unclassified = declared.filter((name) => !classified.has(name));
expect(unclassified, 'новый токен не попал ни в замеры, ни в подобранные').toEqual([]);
@ -138,4 +172,26 @@ describe('tokens.css', () => {
const dangling = [...used].filter((name) => !declared.has(name) && !local.has(name)).sort();
expect(dangling, 'имя токена нигде не объявлено').toEqual([]);
});
// Обратное направление того же замка: объявленный, но никем не используемый токен. Прежде
// он проходил насквозь — «классифицирован» ещё не значит «нужен», и мёртвые тона копились.
it('каждый объявленный токен кем-то используется', () => {
const declared = [...tokens.matchAll(/^\s*(--[\w-]+)\s*:/gm)].map((match) => match[1] ?? '');
const used = new Set<string>();
for (const source of Object.values(styleSheets)) {
for (const m of source.matchAll(/var\(\s*(--[\w-]+)/g)) used.add(m[1] as string);
}
for (const [path, source] of Object.entries(sources)) {
if (path.endsWith('/api/schema.ts')) continue;
for (const m of source.matchAll(/['"`](--[\w-]+)['"`]/g)) used.add(m[1] as string);
}
const dead = declared.filter((name) => !used.has(name) && !reserved.includes(name));
expect(dead, 'токен объявлен, но не применён ни одним правилом').toEqual([]);
});
});
// Объявлены намеренно и пока не применены. Список короткий и именной по построению: строка
// BACKLOG Ф-20 держит решение «либо моноширинный находит работу на читалке S6, либо импорт
// и токены уходят», и до этого решения тона стоят в резерве, а не тихо в мусоре.
const reserved = ['--color-current-line', '--font-mono'];

View file

@ -18,6 +18,27 @@
width: var(--row-height);
}
.button[data-look='action'],
.button[data-look='primary'] {
justify-content: center;
padding-inline: var(--space-5);
background-color: var(--color-raised);
color: var(--color-text);
}
/* Главное действие формы отличается СТУПЕНЬЮ поверхности, а не цветом: зелёных «сохранить»
в этом интерфейсе не бывает (промт §4.2). Именно поэтому здесь НЕ --color-selected: с S3.6
это синий цвет выбранной строки, и кнопка стала бы цветной вопреки правилу. */
.button[data-look='primary'] {
background-color: var(--color-raised-strong);
}
.button[data-disabled] {
background-color: var(--color-panel);
color: var(--color-text-muted);
cursor: default;
}
.button[data-hovered] {
background-color: var(--color-raised);
color: var(--color-text);

View file

@ -3,8 +3,12 @@ import { Button as AriaButton, type ButtonProps } from 'react-aria-components';
import styles from './Button.module.css';
interface Props extends Omit<ButtonProps, 'className' | 'style'> {
/** Строка — действие во всю ширину (подвал панели); иконка — квадрат в полосе или ряду вкладок. */
look?: 'row' | 'icon';
/**
* Строка действие во всю ширину (подвал панели); иконка квадрат в полосе или ряду
* вкладок; действие кнопка формы, приподнятая поверхность вместо цвета (промт §4.2:
* зелёных «сохранить» и красных «удалить» не бывает).
*/
look?: 'row' | 'icon' | 'action' | 'primary';
}
// Вид задаётся атрибутом, а не набором классов: так имя класса остаётся одно и опечатка в нём

View file

@ -1,6 +1,11 @@
.callout {
border-left: 2px solid transparent;
padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4);
/* Прозрачная граница справа не декоративная: без неё содержимое выноски смещено на 2px
относительно блока без замечания, и жёлоб колонок уходит с волосяной линии читалки. */
border-inline: 2px solid transparent;
padding: var(--space-3);
transition:
border-left-color 140ms ease,
color 140ms ease;
}
/* Две ступени, а не одна: у движка есть ратифицированная лестница важности вердиктов
@ -13,9 +18,45 @@
border-left-color: var(--color-separator);
}
/* Подпись стоит НАД содержимым: под ним она читается как заголовок следующего блока. */
/* Подпись стоит НАД содержимым: под ним она читается как заголовок следующего блока.
Значок у самого начала строки тот же тон, что у полоски: так они читаются как одна вещь,
а не как случайная линия рядом с серым текстом. */
.caption {
display: flex;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
color: var(--color-text-muted);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.mark {
flex: none;
color: var(--color-separator);
}
.callout[data-tone='note'] .mark {
color: var(--color-note);
}
/* Наведение подсвечивает ПАРУ «полоска подпись»: связь между ними видна действием,
а не объяснением (замечание 10). */
.callout:hover .caption {
color: var(--color-text);
}
.callout[data-tone='note']:hover {
border-left-color: var(--color-accent);
}
.callout[data-tone='note']:hover .mark {
color: var(--color-accent);
}
.callout[data-tone='quiet']:hover {
border-left-color: var(--color-text-secondary);
}
.callout[data-tone='quiet']:hover .mark {
color: var(--color-text-secondary);
}

View file

@ -1,6 +1,8 @@
import { Info } from 'lucide-react';
import type { ReactNode } from 'react';
import styles from './Callout.module.css';
import { icon } from './icon';
interface Props {
/** Спокойная ступень или та, что просит посмотреть глазами. Заливки текста нет ни у одной. */
@ -10,14 +12,22 @@ interface Props {
}
/**
* Выноска по образцу antigravity_chat.png: тонкая полоска у левого края и тусклая подпись,
* без заливки текста и без значков-восклицаний. Промт §3.8: если экран выглядит тревожным
* он неправильный, замечание это приглашение посмотреть, а не сигнал аварии.
* Выноска по образцу antigravity_chat.png: тонкая полоска у левого края и подпись, без заливки
* текста и без значков-восклицаний. Промт §3.8: если экран выглядит тревожным он неправильный,
* замечание это приглашение посмотреть, а не сигнал аварии.
*
* Замечание владельца 10: полоску «не удалось прочитать» она висела отдельно от подписи,
* и её значение приходилось угадывать. Полоска и подпись сведены в ОДНУ пару: значок стоит
* у самой полоски, а наведение на блок подсвечивает обе разом. Слово ступени тут по-прежнему
* не выдумывается оно продуктовое и стоит на владельце (Ф-21, В-3).
*/
export function Callout({ tone, caption, children }: Props) {
return (
<div className={styles.callout} data-tone={tone}>
<p className={styles.caption}>{caption}</p>
<p className={styles.caption}>
<Info {...icon} className={styles.mark} />
{caption}
</p>
{children}
</div>
);

View file

@ -0,0 +1,36 @@
.chips {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.chip {
display: flex;
gap: var(--space-2);
align-items: center;
height: var(--row-height);
padding-inline: var(--space-4);
border-radius: var(--radius-control);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
cursor: pointer;
}
.chip[data-hovered] {
background-color: var(--color-raised);
color: var(--color-text);
}
.chip[data-selected] {
background-color: var(--color-raised);
color: var(--color-text);
}
.chip[data-focus-visible] {
outline: 1px solid var(--color-accent);
outline-offset: -1px;
}
.count {
color: var(--color-text-secondary);
}

41
frontend/src/ui/Chips.tsx Normal file
View file

@ -0,0 +1,41 @@
import { ToggleButton, ToggleButtonGroup } from 'react-aria-components';
import styles from './Chips.module.css';
export interface Chip {
id: string;
label: string;
/** How many rows this chip would leave: a filter that hides the count hides its own cost. */
count?: number;
}
interface Props {
/** Names the group for a screen reader — the chips themselves are only their own labels. */
label: string;
items: Chip[];
selectedId: string;
onSelect: (id: string) => void;
}
/** One-of-many filter, always with something selected: "no filter" is a chip of its own. */
export function Chips({ label, items, selectedId, onSelect }: Props) {
return (
<ToggleButtonGroup
className={styles.chips}
aria-label={label}
selectionMode="single"
disallowEmptySelection
selectedKeys={[selectedId]}
onSelectionChange={(keys) => {
for (const key of keys) onSelect(String(key));
}}
>
{items.map((item) => (
<ToggleButton className={styles.chip} key={item.id} id={item.id}>
{item.label}
{item.count !== undefined && <span className={styles.count}>{item.count}</span>}
</ToggleButton>
))}
</ToggleButtonGroup>
);
}

View file

@ -1,5 +1,7 @@
.field {
display: flex;
flex: 1;
min-width: 0;
margin-bottom: var(--space-2);
}
@ -13,7 +15,14 @@
}
.input::placeholder {
color: var(--color-text-muted);
color: var(--color-text-secondary);
}
/* Родная кнопка очистки поля поиска приезжает ярко-синей и в монохромный интерфейс не
встраивается: цвет здесь означает состояние и больше ничего (промт §4.2). Поле очищается
Escape это делает сам примитив. */
.input::-webkit-search-cancel-button {
appearance: none;
}
.input:focus {

View file

@ -7,13 +7,15 @@ interface Props {
label: string;
value: string;
onChange: (value: string) => void;
/** Поле, ради которого открыли окно, обязано получить курсор само (палитра перехода). */
autoFocus?: boolean;
}
// Единственное цветное пятно в панели — рамка сфокусированного поля (акцент, промт §1).
export function FilterField({ label, value, onChange }: Props) {
export function FilterField({ label, value, onChange, autoFocus }: Props) {
return (
<SearchField className={styles.field} aria-label={label} value={value} onChange={onChange}>
<Input className={styles.input} placeholder={label} />
<Input className={styles.input} placeholder={label} autoFocus={autoFocus} />
</SearchField>
);
}

View file

@ -9,12 +9,22 @@
display: flex;
gap: var(--space-4);
align-items: center;
/* The row fills the virtualizer's band, or its fill is shorter than the band and sticks to the
top edge the same defect the table cells had. */
height: 100%;
border-block: var(--row-fill-inset) solid transparent;
padding-inline: var(--space-2);
border-radius: var(--radius-control);
background-clip: padding-box;
}
/* Строка, которая куда-то ведёт, обязана это показывать. Списку без onSelect курсор не меняем:
иначе он обещает действие, которого нет (норма Ф-7). */
.selectable .item {
cursor: pointer;
}
.item[data-hovered] {
background-color: var(--color-raised);
}
@ -26,5 +36,5 @@
.empty {
padding: var(--space-4) var(--space-2);
color: var(--color-text-muted);
color: var(--color-text-secondary);
}

View file

@ -16,21 +16,40 @@ interface Props {
items: ListRow[];
/** Что показать вместо списка, когда он пуст. */
empty?: ReactNode;
/** Высота строки в пикселях: виртуализатору она нужна числом, CSS ему не виден. */
rowSize?: number;
/**
* Строка куда-то ведёт. Тогда список становится выбираемым целиком вкладывать в строку
* кнопку нельзя: у роли `option` дети презентационные, и axe роняет сборку на
* `nested-interactive` (та же ловушка, что у крестика вкладки, Ф-17).
*/
onSelect?: (id: string) => void;
}
/**
* Плоский список одной высоты строки, виртуализованный по умолчанию: банк книги это сотни
* терминов подряд, и невиртуализованный список тут ложится так же, как дерево глав (Ф-12).
*/
export function List({ label, items, empty }: Props) {
export function List({ label, items, empty, rowSize, onSelect }: Props) {
// Пустое состояние рисуется ВМЕСТО списка, а не внутри него: `renderEmptyState` библиотеки
// заворачивает содержимое в `role="option"`, и скринридер объявляет заглушку выбираемым
// вариантом — по которому вдобавок нечего выбирать.
if (items.length === 0) return <p className={styles.empty}>{empty}</p>;
return (
<Virtualizer layout={ListLayout} layoutOptions={{ rowSize: measures['--row-height'] }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{ rowSize: rowSize ?? measures['--row-height'] }}
>
<ListBox
className={styles.list}
className={onSelect ? `${styles.list} ${styles.selectable}` : styles.list}
aria-label={label}
items={items}
selectionMode="none"
renderEmptyState={() => <p className={styles.empty}>{empty}</p>}
selectionMode={onSelect ? 'single' : 'none'}
selectedKeys={[]}
onSelectionChange={(keys) => {
if (keys !== 'all') for (const key of keys) onSelect?.(String(key));
}}
>
{(item) => (
<ListBoxItem className={styles.item} textValue={item.text}>

View file

@ -1,9 +1,19 @@
import type { ReactNode } from 'react';
import { I18nProvider } from 'react-aria-components';
/**
* Язык ИНТЕРФЕЙСА один на всё приложение и в одном месте.
*
* Выбора языка в интерфейсе пока нет (замечание владельца 15), и строить его в S3.5 дорого:
* UI-строки лежат литералами в разметке, словаря нет. Но три места, которые зашивали локаль
* порознь провайдер примитивов, имена языков и форматы чисел и дат, сведены сюда: в день,
* когда выбор появится, меняется значение, а не пятнадцать файлов. Язык переводимой ПАРЫ
* к этому отношения не имеет, он живёт в данных книги.
*/
export const interfaceLocale = 'ru-RU';
// Служебные строки примитивов (подсказки прокрутки, «очистить поле») приходят из библиотеки
// и берут язык из браузера — на чужой машине это был бы английский посреди русского интерфейса.
// Речь про язык ИНТЕРФЕЙСА, он русский по промту §4.6; язык переводимой пары живёт в данных.
export function Locale({ children }: { children: ReactNode }) {
return <I18nProvider locale="ru-RU">{children}</I18nProvider>;
return <I18nProvider locale={interfaceLocale}>{children}</I18nProvider>;
}

View file

@ -0,0 +1,60 @@
/* Затемнение не чёрная заливка во всю силу: оболочка под окном должна остаться узнаваемой,
иначе модал читается как переход на другой экран. */
.overlay {
position: fixed;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: var(--modal-offset);
inset: 0;
background-color: var(--color-scrim);
}
.window {
width: var(--modal-width);
max-width: 90vw;
max-height: 76vh;
border: 1px solid var(--color-border);
border-radius: var(--radius-panel);
background-color: var(--color-elevated);
}
.window[data-size='wide'] {
width: var(--modal-width-wide);
}
.dialog {
display: grid;
grid-template-rows: auto 1fr auto;
max-height: inherit;
outline: none;
}
.header {
display: flex;
gap: var(--space-4);
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-5) var(--space-4);
}
.title {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-ui);
font-weight: inherit;
}
.body {
min-height: 0;
overflow: auto;
padding-inline: var(--space-5);
}
.footer {
display: flex;
gap: var(--space-4);
align-items: center;
justify-content: flex-end;
padding: var(--space-5);
}

53
frontend/src/ui/Modal.tsx Normal file
View file

@ -0,0 +1,53 @@
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
import { Dialog, Heading, Modal as AriaModal, ModalOverlay } from 'react-aria-components';
import { Button } from './Button';
import { icon } from './icon';
import styles from './Modal.module.css';
interface Props {
title: string;
isOpen: boolean;
onClose: () => void;
/** Широкая форма — для палитры перехода: строка ввода плюс список результатов. */
size?: 'regular' | 'wide';
/** Прибитая к низу строка действий; у палитры её нет. */
footer?: ReactNode;
children: ReactNode;
}
/**
* Модальное окно поверх оболочки (замечание 11): настройки и добавление книги открываются
* им, а не вкладкой-документом. Вкладка-документ отдаёт экрану всю ширину центра и требует
* закрытия крестиком для короткой формы это лишний след в ряду вкладок.
*
* Ловушка фокуса, возврат фокуса, Escape и клик мимо работа примитива, а не наша.
*/
export function Modal({ title, isOpen, onClose, size = 'regular', footer, children }: Props) {
return (
<ModalOverlay
className={styles.overlay}
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
isDismissable
>
<AriaModal className={styles.window} data-size={size}>
<Dialog className={styles.dialog}>
<div className={styles.header}>
<Heading className={styles.title} slot="title">
{title}
</Heading>
<Button look="icon" aria-label="Закрыть" onPress={onClose}>
<X {...icon} />
</Button>
</div>
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</Dialog>
</AriaModal>
</ModalOverlay>
);
}

View file

@ -0,0 +1,90 @@
/* In a virtualized table the library puts EVERY CELL in its own absolutely positioned band of
one row step, and `role="row"` stays a ZERO-height container (measured: band 32px, row 1px).
Hence rules are drawn by the CELL, the cell fills the band, and hover and selection paint through
a descendant selector. The header group is zero-height for the same reason and the library
already wraps it in a sticky box so its ground is painted by the header CELLS, or rows scroll
straight through the column titles. */
.frame {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
/* Both axes on the scroll container the virtualizer measures: when the columns together need more
than the panel gives, the table scrolls sideways instead of squeezing text out of existence. */
.table {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
outline: none;
}
/* With nothing to show the table is its head alone. The height is spelled out because an empty
collection makes the library reserve the whole viewport for an empty state of its own (702px). */
.frameEmpty .table {
height: var(--row-height-table);
flex: none;
}
/* A column header has to read as a header, not as one more line of data. */
.column {
display: flex;
contain: layout paint style;
overflow: hidden;
height: 100%;
align-items: center;
padding-inline: var(--space-4);
border-bottom: 1px solid var(--color-separator);
background-color: var(--color-chrome);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
font-weight: inherit;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
outline: none;
}
.row {
cursor: default;
outline: none;
}
.cell {
display: flex;
contain: layout paint style;
overflow: hidden;
height: 100%;
align-items: center;
padding-inline: var(--space-4);
border-bottom: 1px solid var(--color-separator);
white-space: nowrap;
text-overflow: ellipsis;
outline: none;
}
/* The column rule is the SAME line that divides rows: lines that mean the same are drawn the
same. On the cell, so it spans the band edge to edge and joins the one below. */
.divided {
border-left: 1px solid var(--color-separator);
}
.row[data-hovered] .cell {
background-color: var(--color-raised);
}
.row[data-selected] .cell {
background-color: var(--color-selected);
}
.row[data-focus-visible] .cell {
border-block-color: var(--color-accent);
}
.empty {
padding: var(--space-5) var(--space-4);
color: var(--color-text-secondary);
}

358
frontend/src/ui/Table.tsx Normal file
View file

@ -0,0 +1,358 @@
import { type ReactNode, type RefObject, useEffect, useRef, useState } from 'react';
import type { ColumnProps } from 'react-aria-components';
import {
Cell,
Column,
Row,
Table as AriaTable,
TableBody,
TableHeader,
TableLayout,
Virtualizer,
} from 'react-aria-components';
import { measures } from '../tokens/measures';
import styles from './Table.module.css';
export interface TableColumn<Item> {
id: string;
name: string;
/** Share of what the fitted columns leave over. Only columns without `fit` divide it. */
share?: number;
/** The column never shrinks below this. */
minWidth?: number;
/** Sizes the column to the widest `text` it holds, and never past `max` itself. */
fit?: { text: (item: Item) => string; max: number };
/** The column that names the row for a screen reader. Exactly one per table. */
isRowHeader?: boolean;
/** Extra class for the column's cells — a caller's device (a rule, a tone), not a layout knob. */
className?: string;
render: (item: Item) => ReactNode;
}
interface Props<Item extends { id: string }> {
label: string;
columns: TableColumn<Item>[];
items: Item[];
/** Rows the widths are measured over — the whole set, or a search resizes columns while typing. */
sample?: Item[];
empty: ReactNode;
/**
* Everything `render` takes from OUTSIDE the row: the library caches cells by row object and
* drops that cache only on this list. Primitives only these are compared as text.
*/
dependencies?: unknown[];
selectedId?: string;
onSelect?: (id: string) => void;
}
/**
* A virtualized table. A column with `fit` is measured off its own content and never grows past it;
* columns without one take what is left over, and when nothing is left the fitted ones give way in
* proportion to what they hold.
*
* The column SET must never depend on the container width: changing it rebuilds the collection,
* and doing that per drag frame measured 133200 ms p90 (1200 terms). A filter may change it.
*/
export function Table<Item extends { id: string }>({
label,
columns,
items,
sample,
empty,
dependencies = [],
selectedId,
onSelect,
}: Props<Item>) {
const frame = useRef<HTMLDivElement>(null);
const sizes = useFittedWidths(frame, columns, sample ?? items, dependencies, items.length > 0);
// Names too: a header is a floor of its own, so a renamed column must be re-measured.
const named = columns.map((column) => `${column.id}:${column.name}`).join(',');
const layout = [named, ...dependencies];
// Widths reach the layout through the collection, which is cached by column object — and a
// fitted width changes without the column changing, so it needs its own key.
const head = [
...layout,
columns
.map((column) => `${String(sizes[column.id]?.fit)}:${String(sizes[column.id]?.floor)}`)
.join(','),
];
// Something must take what the others do not need, or the table stops short of its right edge.
// Normally that is a column without a content width; if all have one, the last gives it up.
const free = columns.some((column) => sizes[column.id]?.fit === undefined);
const stretch = free ? undefined : columns.at(-1)?.id;
// The head stays when the search finds nothing — the columns are what is searched through. The
// message sits BESIDE the table: the library's own empty state announces itself as a table row.
const isEmpty = items.length === 0;
return (
<div className={`${styles.frame} ${isEmpty ? styles.frameEmpty : ''}`} ref={frame}>
<Virtualizer
layout={TableLayout}
layoutOptions={{
rowHeight: measures['--row-height-table'],
headingHeight: measures['--row-height-table'],
}}
>
<AriaTable
className={styles.table}
aria-label={label}
selectionMode={onSelect ? 'single' : 'none'}
selectedKeys={selectedId === undefined ? [] : [selectedId]}
onSelectionChange={(keys) => {
if (keys !== 'all') for (const key of keys) onSelect?.(String(key));
}}
>
<TableHeader columns={columns} dependencies={head}>
{(column) => (
<Column
className={`${styles.column} ${column.className ?? ''}`}
id={column.id}
isRowHeader={column.isRowHeader}
{...sizeOf(column, sizes[column.id], column.id === stretch)}
>
{column.name}
</Column>
)}
</TableHeader>
<TableBody items={items} dependencies={layout}>
{(item) => (
<Row className={styles.row} columns={columns} dependencies={layout}>
{(column) => (
<Cell className={`${styles.cell} ${column.className ?? ''}`}>
{column.render(item)}
</Cell>
)}
</Row>
)}
</TableBody>
</AriaTable>
</Virtualizer>
{isEmpty && <p className={styles.empty}>{empty}</p>}
</div>
);
}
/**
* A fitted column asks in TWO ways at once: its content is the ceiling AND the weight by which it
* divides what there is. Both halves are needed a ceiling alone never reaches its content when a
* hungrier column is beside it; an exact width alone cannot give way and pushes a column off.
*/
function sizeOf<Item>(
column: TableColumn<Item>,
size: Fitted | undefined,
stretch: boolean,
): Pick<ColumnProps, 'width' | 'minWidth' | 'maxWidth'> {
// Spelled out even at zero: unsaid, the library puts a floor of 75px under every column.
const minWidth = Math.max(column.minWidth ?? 0, size?.floor ?? 0);
if (size?.fit !== undefined && !stretch) {
const weight: `${number}fr` = `${size.fit}fr`;
return { width: weight, minWidth, maxWidth: size.fit };
}
const share: `${number}fr` = `${column.share ?? 1}fr`;
return { width: share, minWidth };
}
/** `fit` — as wide as the column's content, capped. `floor` — its own title, which always fits. */
interface Fitted {
fit?: number;
floor: number;
}
/**
* Widths that fit the content, measured on a canvas with the font of the cells on screen: a hidden
* DOM pass would cost a layout per row. Runs on a data change, never on a resize.
*/
function useFittedWidths<Item>(
frame: RefObject<HTMLElement | null>,
columns: TableColumn<Item>[],
/** Every row the widths are measured over — the whole set, not what a search left on screen. */
sample: Item[],
dependencies: unknown[],
/** Not read here: fonts come off RENDERED rows, so losing them all and getting them back
* is a reason to measure again. */
hasRows: boolean,
): Record<string, Fitted> {
const [sizes, setSizes] = useState<Record<string, Fitted>>({});
// Names too: the header is a floor of its own, so a retitled column must be re-measured.
const named = columns.map((column) => `${column.id}:${column.name}`).join(',');
const marks = dependencies.map(String).join('|');
// Columns are rebuilt every render, so the effect keys on what IDENTIFIES them and reads the
// definitions from here. Effects run in order, so this one lands first.
const latest = useRef(columns);
useEffect(() => {
latest.current = columns;
});
useEffect(() => {
let live = true;
void measure();
return () => {
live = false;
};
async function measure() {
const ruler = measurer();
if (ruler === null) return;
const columns = latest.current;
// The virtualizer fills its rows a frame after the table mounts, so the first look can land
// on a head with no body under it. A few frames of patience, then keep the old widths.
let lined = read(frame.current, columns);
for (let frames = 0; lined === null && frames < 10; frames += 1) {
await new Promise((done) => requestAnimationFrame(done));
if (!live) return;
lined = read(frame.current, columns);
}
if (lined === null) return;
await load(lined, sample);
if (!live) return;
const next: Record<string, Fitted> = {};
for (const { column, title, head, cell, around } of lined) {
const floor = Math.ceil(textWidth(ruler, head, title) + around);
let fit: number | undefined;
if (column.fit) {
let width = 0;
for (const item of sample)
width = Math.max(width, textWidth(ruler, cell, column.fit.text(item)));
fit = Math.min(column.fit.max, Math.max(floor, Math.ceil(width + around)));
}
next[column.id] = { fit, floor };
}
// Merged, not replaced: a column the caller has hidden is not in `lined`, and dropping its
// width brings it back as a plain share for one frame — the neighbour jumped 13px.
setSizes((current) => {
const merged = { ...current, ...next };
return same(current, merged) ? current : merged;
});
}
}, [frame, named, sample, marks, hasRows]);
return sizes;
}
/**
* Asks for the faces the text will be measured against. A webfont split by unicode-range arrives
* only once a glyph is DRAWN, and a canvas draws nothing measured off the fallback, a Cyrillic
* line comes out 4.7% wide. `document.fonts.ready` does not cover it: it promises only the faces
* already asked for, not the subset whose glyph lives in an unrendered row.
*/
async function load<Item>(lined: Lined<Item>[], sample: Item[]): Promise<void> {
if (document.fonts === undefined) return;
const wanted = new Map<string, Set<string>>();
const want = ({ font }: Face, text: string) => {
const glyphs = wanted.get(font) ?? new Set<string>();
for (const glyph of text) glyphs.add(glyph);
wanted.set(font, glyphs);
};
for (const { column, title, head, cell } of lined) {
want(head, title);
if (column.fit) for (const item of sample) want(cell, column.fit.text(item));
}
await Promise.all(
[...wanted].map(([font, glyphs]) =>
document.fonts.load(font, [...glyphs].join('')).catch(() => []),
),
);
}
/**
* A column paired with what the browser actually draws it with. Copied out of the computed styles
* rather than held as them: a `CSSStyleDeclaration` is LIVE, and the row it was read from can be
* gone by the time the fonts finish loading it then reads back blank, and every column measures
* against the canvas default. That is what an emptied search did.
*/
interface Lined<Item> {
column: TableColumn<Item>;
title: string;
head: Face;
cell: Face;
around: number;
}
interface Face {
font: string;
spacing: string;
}
const faceOf = (style: CSSStyleDeclaration): Face => ({
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
spacing: style.letterSpacing.endsWith('px') ? style.letterSpacing : '0px',
});
/** Everything of the cell's width that is not text: its padding and its rules. */
const outside = (box: CSSStyleDeclaration) =>
parseFloat(box.paddingLeft) +
parseFloat(box.paddingRight) +
parseFloat(box.borderLeftWidth) +
parseFloat(box.borderRightWidth);
/**
* Every column paired with the header and data cell standing in it, or null when the table is not
* all there pairing by position across a partial row hands a column its neighbour's font.
*/
function read<Item>(node: HTMLElement | null, columns: TableColumn<Item>[]): Lined<Item>[] | null {
if (node === null) return null;
const heads = byColumn(node.querySelectorAll('[role="columnheader"]'));
const cells = byColumn(node.querySelectorAll('[role="gridcell"],[role="rowheader"]'));
if (heads.length !== columns.length || cells.length !== columns.length) return null;
const lined = columns.map((column, index) => {
const head = getComputedStyle(heads[index] as Element);
const cell = cells[index] as Element;
return {
column,
// The title as it is SET, not as it is written: the stylesheet draws headers in capitals.
title: head.textTransform === 'uppercase' ? column.name.toUpperCase() : column.name,
head: faceOf(head),
// The content font lives on what the cell RENDERS, not on the cell: the source side is set
// one step larger, and measuring it at the cell's size loses about a glyph.
cell: faceOf(getComputedStyle(cell.firstElementChild ?? cell)),
// Padding AND rules: the box is border-box, so a column rule eats a pixel of the text.
around: outside(getComputedStyle(cell)),
};
});
// A blank computed style parses to NaN, and a NaN width loops the library's flex pass for ever:
// it freezes items by the SIGN of their violation, and NaN has none.
return lined.every((part) => Number.isFinite(part.around)) ? lined : null;
}
/**
* One element per column, left to right. Cells of a column share a left edge; DOM order does not
* follow column order, because the row header and any sticky column are kept out of turn.
*/
function byColumn(nodes: NodeListOf<Element>): Element[] {
const found = new Map<number, Element>();
for (const node of nodes) {
const left = Math.round(node.getBoundingClientRect().left);
if (!found.has(left)) found.set(left, node);
}
return [...found].sort(([a], [b]) => a - b).map(([, node]) => node);
}
let ruler: CanvasRenderingContext2D | null | undefined;
const measurer = () => (ruler ??= document.createElement('canvas').getContext('2d'));
const measured = new Map<string, number>();
function textWidth(ruler: CanvasRenderingContext2D, { font, spacing }: Face, text: string) {
const key = `${font}|${spacing}|${text}`;
let width = measured.get(key);
if (width === undefined) {
ruler.font = font;
ruler.letterSpacing = spacing;
width = ruler.measureText(text).width;
measured.set(key, width);
}
return width;
}
const same = (a: Record<string, Fitted>, b: Record<string, Fitted>) => {
const keys = Object.keys(b);
return (
keys.length === Object.keys(a).length &&
keys.every((key) => a[key]?.fit === b[key]?.fit && a[key]?.floor === b[key]?.floor)
);
};

View file

@ -4,6 +4,8 @@
min-height: 0;
}
/* The strip is the panel's own canvas: the active tab is a rounded chip lying ON it, as in
fleet.png. An earlier round cut the tab out of a chrome plate instead; the owner prefers this. */
.row {
display: flex;
gap: var(--space-1);
@ -11,20 +13,25 @@
padding: var(--panel-padding) var(--panel-padding) 0;
}
/* Ряд не переносится и не растягивает панель: лишние вкладки уезжают за край, как во Fleet. */
/* The row scrolls instead of shrinking its tabs. Flex shrink distributed the overflow in
proportion to each tab's width, so a SHORT title could ellipsize while a longer neighbour
did not the defect the owner reported. */
.list {
display: flex;
gap: var(--space-1);
min-width: 0;
overflow: hidden;
overflow-x: auto;
scrollbar-width: none;
}
.tab {
display: flex;
gap: var(--space-2);
align-items: center;
height: var(--panel-header-height);
padding-inline: var(--space-4);
max-width: var(--tab-width-max);
flex: none;
align-items: center;
padding-inline: var(--tab-inset);
gap: var(--space-2);
border-radius: var(--radius-control);
color: var(--color-text-secondary);
white-space: nowrap;
@ -40,24 +47,32 @@
color: var(--color-text);
}
/* Вкладка открытого документа у Fleet синеватая, а не серая (замер §5.1). */
/* An open document reads blue where a tool tab reads grey: fleet.png draws that distinction with
the fill itself, not with a mark along an edge. */
.row[data-look='document'] .tab[data-selected] {
background-color: var(--color-tab-active);
}
.tab[data-focus-visible] {
outline: 1px solid var(--color-accent);
outline-offset: -1px;
}
.tab .title {
overflow: hidden;
text-overflow: ellipsis;
}
/* Крестик проявляется у выбранной вкладки и под курсором: во Fleet он не висит на всех сразу. */
/* Предпросмотр — курсив, как в VS Code: вкладка временная и будет замещена следующим кликом. */
.tab[data-preview='true'] .title {
font-style: italic;
}
.tab[data-focus-visible] {
outline: 1px solid var(--color-accent);
outline-offset: -1px;
}
/* The close mark shows on the selected and hovered tab only. Its space is reserved always
(visibility, not display), or the row would jump on every switch. */
.close {
display: flex;
flex: none;
align-items: center;
border-radius: var(--radius-control);
color: var(--color-text-muted);
@ -74,8 +89,20 @@
visibility: visible;
}
/* «+» follows the tabs but stays OUT of the scrolling list, so it cannot scroll away from them. */
.add {
display: flex;
flex: none;
align-items: center;
padding-inline: var(--space-1);
}
/* Все панели живут во ВТОРОЙ строке грида и лежат друг на друге: при keepAlive они
отрисованы одновременно, и без явного места невыбранные создавали бы новые строки грида. */
.panel {
display: flex;
grid-row: 2;
grid-column: 1;
flex-direction: column;
min-height: 0;
@ -83,3 +110,10 @@
overflow: hidden;
padding: var(--space-1) var(--panel-padding) var(--panel-padding);
}
/* An unselected keep-alive panel: `content-visibility: hidden` skips layout and paint while keeping
state `display: none` would destroy the scroll offset, `visibility: hidden` would still lay the
subtree out on every resize (that is what made the right separator drag twice as costly). */
.panel[data-inert] {
content-visibility: hidden;
}

View file

@ -10,6 +10,8 @@ export interface TabItem {
id: string;
label: string;
content: ReactNode;
/** Предпросмотр (модель VS Code): заголовок курсивом, следующий одиночный клик её замещает. */
preview?: boolean;
/** Есть обработчик — у вкладки появляется крестик (вкладки-документы центра). */
onClose?: () => void;
}
@ -20,17 +22,35 @@ export interface TabsProps {
items: TabItem[];
selectedId: string;
onSelect: (id: string) => void;
/** Вкладка инструмента (серая) или открытого документа (синеватая, как файл во Fleet). */
/** Tool tab, or an open document — the latter is additionally marked along its top edge. */
look?: 'tool' | 'document';
/** Двойной клик по вкладке закрепляет её — модель VS Code (замечание 2). */
onPin?: (id: string) => void;
/** «+» в конце ряда. Ф-7: кнопка появляется только вместе с действием, декоративной не бывает. */
add?: { label: string; onPress: () => void };
/** Что показать, когда вкладок нет вовсе (все документы закрыты). */
empty?: ReactNode;
/**
* Панели не размонтируются при переключении (Ф-19). Невыбранные остаются в раскладке
* невидимыми, поэтому прокрутка и позиция живут, а пересборка коллекции на 2284 узла
* при каждом возврате не платится.
*/
keepAlive?: boolean;
}
// Область с вкладками целиком: ряд сверху, содержимое выбранной вкладки под ним. Содержимое —
// один скролл-контейнер на вкладку; длинные списки внутри скроллятся сами (виртуализация).
export function Tabs({ label, items, selectedId, onSelect, look = 'tool', add, empty }: TabsProps) {
export function Tabs({
label,
items,
selectedId,
onSelect,
look = 'tool',
onPin,
add,
empty,
keepAlive = false,
}: TabsProps) {
return (
<AriaTabs
className={styles.tabs}
@ -58,7 +78,12 @@ export function Tabs({ label, items, selectedId, onSelect, look = 'tool', add, e
{items.length > 0 && (
<TabList className={styles.list} aria-label={label} items={items}>
{(item) => (
<Tab className={styles.tab} id={item.id}>
<Tab
className={styles.tab}
id={item.id}
data-preview={item.preview}
onDoubleClick={() => onPin?.(item.id)}
>
<span className={styles.title}>{item.label}</span>
{/* Крестик не кнопка, и это не небрежность: у роли tab дети презентационные
(ARIA), поэтому вложенный фокусируемый элемент выпадает из дерева доступности,
@ -80,14 +105,16 @@ export function Tabs({ label, items, selectedId, onSelect, look = 'tool', add, e
</TabList>
)}
{add && (
<Button look="icon" aria-label={add.label} onPress={add.onPress}>
<Plus {...icon} />
</Button>
<span className={styles.add}>
<Button look="icon" aria-label={add.label} onPress={add.onPress}>
<Plus {...icon} />
</Button>
</span>
)}
</div>
{items.length === 0 && <div className={styles.panel}>{empty}</div>}
{items.map((item) => (
<TabPanel className={styles.panel} key={item.id} id={item.id}>
<TabPanel className={styles.panel} key={item.id} id={item.id} shouldForceMount={keepAlive}>
{item.content}
</TabPanel>
))}

View file

@ -0,0 +1,32 @@
.field {
display: grid;
gap: var(--space-2);
}
.label {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.input {
width: 100%;
height: var(--row-height);
border: 1px solid var(--color-border);
padding-inline: var(--space-4);
border-radius: var(--radius-control);
color: var(--color-text);
}
.input::placeholder {
color: var(--color-text-secondary);
}
.input:focus {
border-color: var(--color-accent);
outline: none;
}
.hint {
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}

View file

@ -0,0 +1,31 @@
import { Input, Label, Text, TextField as AriaTextField } from 'react-aria-components';
import styles from './TextField.module.css';
interface Props {
label: string;
value: string;
onChange: (value: string) => void;
/** Подсказка в пустом поле; у неё своя работа — сказать, что будет, если поле не трогать. */
placeholder?: string;
/** Строка под полем: чем именно кончится пустое поле, а не общий совет. */
hint?: string;
}
// Обычное текстовое поле формы. Отличается от FilterField ролью, а не видом: у фильтра нет
// подписи и он ничего не отправляет, здесь подпись обязательна — это форма.
export function TextField({ label, value, onChange, placeholder, hint }: Props) {
return (
<AriaTextField className={styles.field} value={value} onChange={onChange}>
<Label className={styles.label}>{label}</Label>
<Input className={styles.input} placeholder={placeholder} />
{/* slot="description" не украшение: библиотека вешает `aria-describedby`, и без него
подсказку не слышит тот, кому она нужнее всех. */}
{hint && (
<Text className={styles.hint} slot="description">
{hint}
</Text>
)}
</AriaTextField>
);
}

View file

@ -0,0 +1,26 @@
.toggle {
display: flex;
width: var(--row-height);
height: var(--row-height);
flex: none;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
color: var(--color-text-secondary);
cursor: pointer;
}
.toggle[data-hovered] {
background-color: var(--color-raised);
color: var(--color-text);
}
.toggle[data-selected] {
background-color: var(--color-raised);
color: var(--color-text);
}
.toggle[data-focus-visible] {
outline: 1px solid var(--color-accent);
outline-offset: -1px;
}

View file

@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { ToggleButton } from 'react-aria-components';
import styles from './Toggle.module.css';
interface Props {
/** Name for a screen reader; it changes with the state, like the panel buttons do. */
label: string;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
children: ReactNode;
}
/** A state button: pressed or not. The library keeps `aria-pressed` itself. */
export function Toggle({ label, isSelected, onChange, children }: Props) {
return (
<ToggleButton
className={styles.toggle}
aria-label={label}
isSelected={isSelected}
onChange={onChange}
>
{children}
</ToggleButton>
);
}

View file

@ -1,3 +1,12 @@
/* Обёртка нужна только ради клавиатуры (Enter закрепляет вкладку) и потому размеров не задаёт
сверх того, что уже отдала панель. */
.frame {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
/* Само дерево скролл-контейнер: виртуализатору нужна собственная высота, а панель отдаёт
ему всё, что осталось от шапки. */
.tree {
@ -13,6 +22,11 @@
display: flex;
gap: var(--space-2);
align-items: center;
/* The row fills the virtualizer's band: otherwise its fill is shorter than the band, the row
sticks to the top edge and leaves an unpainted gap below the same defect the table cells
had, found there by measurement and generalized here. */
height: 100%;
border-block: var(--row-fill-inset) solid transparent;
padding-inline: var(--space-2);
border-radius: var(--radius-control);

View file

@ -14,6 +14,12 @@ import { measures } from '../tokens/measures';
import { icon } from './icon';
import styles from './Tree.module.css';
/** Ключ строки, в которой произошло событие. `null` — событие пришло мимо строк. */
function rowKeyOf(target: EventTarget | null): string | null {
if (!(target instanceof HTMLElement)) return null;
return target.closest('[role="row"]')?.getAttribute('data-key') ?? null;
}
export interface TreeNode {
id: string;
title: string;
@ -29,6 +35,20 @@ interface Props {
items: TreeNode[];
selectedId?: string;
onSelect: (id: string) => void;
/**
* Двойной клик или Enter по строке. У нас это «закрепить вкладку» (модель VS Code): одиночный
* клик остаётся предпросмотром. Через `onAction` библиотеки это не делается при заданном
* действии одиночный клик начинает означать активацию, и предпросмотр исчезает как понятие.
*/
onActivate?: (id: string) => void;
/**
* The row is about to be opened: the button is down on it. Early enough to read what it needs
* before the click completes, and once per intent not for every row a pointer sweeps over.
*
* NOT on focus. Arrow keys move focus without selecting anything (measured: four presses, the
* 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[];
}
@ -36,29 +56,17 @@ interface Props {
* Дерево виртуализовано по умолчанию, а не по замеру: у книги 2284 раздела (замер D39.84),
* и список без виртуализации кладёт вкладку ещё до того, как экран нарисован (Ф-12).
*/
export function Tree({ label, items, selectedId, onSelect, defaultExpandedIds }: Props) {
return (
<Virtualizer layout={ListLayout} layoutOptions={{ rowSize: measures['--row-height'] }}>
<AriaTree
className={styles.tree}
aria-label={label}
items={items}
selectionMode="single"
selectedKeys={selectedId === undefined ? [] : [selectedId]}
onSelectionChange={(keys) => {
if (keys !== 'all') for (const key of keys) onSelect(String(key));
}}
defaultExpandedKeys={defaultExpandedIds}
>
{renderNode}
</AriaTree>
</Virtualizer>
);
}
// Отступ уровня даёт сама библиотека через data-level, поэтому вложенность не считается руками.
function renderNode(node: TreeNode) {
return (
export function Tree({
label,
items,
selectedId,
onSelect,
onActivate,
onPreload,
defaultExpandedIds,
}: Props) {
// Отступ уровня даёт сама библиотека через data-level, поэтому вложенность не считается руками.
const renderNode = (node: TreeNode) => (
<TreeItem className={styles.item} textValue={node.title}>
<TreeItemContent>
{({ hasChildItems, isExpanded }) => (
@ -79,4 +87,53 @@ function renderNode(node: TreeNode) {
{node.children && <Collection items={node.children}>{renderNode}</Collection>}
</TreeItem>
);
return (
// ⚠ Двойной клик и Enter ловит ОБЁРТКА, а не строка, и это не стилистика: строки живут
// в КОЛЛЕКЦИИ библиотеки и пересобираются только при смене `items`, поэтому замыкание
// внутри строки застывает на первом рендере (поймано сценарием, а не рассуждением).
// Строка при этом берётся из САМОГО события — по `data-key`, который библиотека кладёт
// на строку: брать «текущий выбор» нельзя, двойной клик по книге или по пустому месту
// выбор не двигает и закрепил бы чужую вкладку.
// Enter ловится в фазе ПЕРЕХВАТА: `usePress` библиотеки останавливает всплытие
// синтетического keydown, и обычный обработчик на обёртке до него не доходит.
<div
className={styles.frame}
onDoubleClick={(event) => {
const id = rowKeyOf(event.target);
// Шеврон — это «свернуть и развернуть», а не «закрепить».
if (id !== null && !(event.target instanceof HTMLElement && event.target.closest('button')))
onActivate?.(id);
}}
onPointerDown={(event) => {
const id = rowKeyOf(event.target);
if (id !== null) onPreload?.(id);
}}
onKeyDownCapture={(event) => {
// Тот же гард, что у двойного клика: Enter на шевроне — это «свернуть», а не «закрепить».
// И строка берётся только из САМОГО события: Enter, пришедший не из строки, ничего
// не закрепляет — иначе он закреплял бы чужую, случайно выбранную.
if (event.key !== 'Enter') return;
if (event.target instanceof HTMLElement && event.target.closest('button')) return;
const id = rowKeyOf(event.target);
if (id !== null) onActivate?.(id);
}}
>
<Virtualizer layout={ListLayout} layoutOptions={{ rowSize: measures['--row-height'] }}>
<AriaTree
className={styles.tree}
aria-label={label}
items={items}
selectionMode="single"
selectedKeys={selectedId === undefined ? [] : [selectedId]}
onSelectionChange={(keys) => {
if (keys !== 'all') for (const key of keys) onSelect(String(key));
}}
defaultExpandedKeys={defaultExpandedIds}
>
{renderNode}
</AriaTree>
</Virtualizer>
</div>
);
}