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:
parent
90c23cbb2e
commit
57e6bd1d63
73 changed files with 5062 additions and 597 deletions
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
|
|
@ -4,3 +4,7 @@ dist/
|
|||
# Снимки скриншот-цикла и системные библиотеки Chromium — бинарники в репозиторий не едут.
|
||||
.shots/
|
||||
.tooling/
|
||||
|
||||
# Референсы владельца — чужие скриншоты (JetBrains Fleet, Antigravity), в репозиторий не едут;
|
||||
# кладёт их на диск владелец, процедура — docs/FRONTEND_PLAN.md §4.
|
||||
references/
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
# frontend — веб-интерфейс
|
||||
|
||||
Зона записи сессий «Фронт». Пройдены S0 (план), S1 (инструменты, скриншот-цикл, токены, витрина)
|
||||
и S2 (оболочка трёх панелей и слой `src/ui/`). Продуктовых экранов ещё нет — они на S4–S7
|
||||
(`docs/BACKLOG.md`, Ф-1); вход в S3 заблокирован отсутствием контракта API v0.
|
||||
Зона записи сессий «Фронт». Пройдены S0 (план), S1 (инструменты, скриншот-цикл, токены, витрина),
|
||||
S2 (оболочка трёх панелей и слой `src/ui/`), S3 (слой данных на контракте API) и S3.5 (фикс-пак
|
||||
оболочки по 24 замечаниям владельца). Продуктовых экранов ещё нет — они на S4–S7
|
||||
(`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
|
|
@ -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/`
|
||||
|
|
|
|||
208
frontend/docs/S37_SESSION_PROMPT.md
Normal file
208
frontend/docs/S37_SESSION_PROMPT.md
Normal 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. Контекст холодному исполнителю:
|
||||
> оболочка и витрина построены сессиями S0–S3.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 (⚠ её разделы 12–15 стоят
|
||||
в ОБРАТНОМ порядке после §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`); поправить при закрытии строки.
|
||||
|
||||
## Что НЕ делать
|
||||
|
||||
- Не строить экраны S4–S7 (загрузка книги, механизм подписи, содержимое настроек, экспорт).
|
||||
- Не трогать канон контракта `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: если по ходу покажется, что пора вернуть ресайз столбцов — НЕ возвращать; строка
|
||||
требует свежего замера от новой базы, это не задача пака.
|
||||
|
||||
## Отчёт и приёмка
|
||||
|
||||
- Каждая задача 1–3: ТЕСТ, падающий на до-фиксном коде (vitest поверх MSW; поток мокается
|
||||
событиями), и зелёный после; в журнал — что именно ловит тест и как воспроизводился дефект.
|
||||
- `npm run check` и `npm run check:full` зелёные на сдаче; счётчик тестов вырос, ни одна
|
||||
существующая проверка не ослаблена.
|
||||
- **Мандат самопроверки:** ревью ИСПОЛНЕНИЕМ своего кода, своих кадров и своих сценариев +
|
||||
адверсариальное ревью диффа (author≠reviewer) в конце; находки с диспозициями — в журнал.
|
||||
- Прогон по всем задачам таблицей в `frontend-PROGRESS.md`: задача → что сделано → тест/замер →
|
||||
либо диспозиция. Хвосты — строками Ф-N в `BACKLOG.md` (Ф-49/50/51/40/47 закрыть или
|
||||
переформулировать честно), вопросы владельцу — В-строками.
|
||||
- Промт S4 не писать — его выдаёт оркестратор после приёмки этого пака.
|
||||
|
|
@ -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 (разделы 12–15 хроники).** Дерево готово к лендингу, фронт не
|
||||
> коммитит. Гейты: `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`). Сторожа валидны для настоящего
|
||||
механизма; ложна формулировка буллета — она описывает промежуточную редакцию.
|
||||
- **Число-дрейф разделов 7–14 против финального дерева:** §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`.
|
||||
- **Разделы 12–15 стоят в обратном порядке** после §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`, номера 99–101 и 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 узлов) — виртуализация жива |
|
||||
| поиск: от ввода до перерисовки | **137–199 мс** |
|
||||
|
||||
Пороги в сценарии грубые (медиана ≤ 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, ДО правок | 133–200 мс | 250–500 мс | 5–9 из ~150 |
|
||||
| dev, после правок | 33 мс | 250–433 мс | 4–5 |
|
||||
| сборка, после правок | **17 мс** | 100 мс | 4 из 129 |
|
||||
|
||||
Найдено две причины, обе мои:
|
||||
|
||||
1. **Состав столбцов зависел от ширины.** Коллекция react-aria пересобирается, когда меняется
|
||||
набор столбцов, — а я менял его прямо во время драга, на каждом пересечении порога: отсюда
|
||||
кадры по 300–500 мс. Адаптивность по ширине снята целиком; вместо неё у таблицы есть мера
|
||||
(`--table-min-width`), ниже которой она уезжает вбок, а не ужимается.
|
||||
2. **Строка поиска банка жила в состоянии ВСЕГО экрана.** Каждая буква перерисовывала дерево на
|
||||
2284 раздела, читалку и статус-полосу разом: замер — 150 мс на символ. Состояние переехало
|
||||
в сам банк; палитра перехода теперь не «пишет в строку поиска», а ПРОСИТ показать термин
|
||||
(`requestedTerm`). Отклик поля: **451 мс на три буквы → 43 мс на символ**.
|
||||
|
||||
⚠ Честно про остаток: на dev-сервере 4–5 длинных кадров за драг остаются, и это НЕ банк — тот же
|
||||
драг с открытой вкладкой «О книге» и на пустой библиотеке даёт ту же картину (замер). Дальше это
|
||||
уже цена 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` честно рисует ожидание.
|
||||
|
||||
**Замер до правки** (покадрово, что нарисовано в центре): холодный кэш — карточка видна
|
||||
130–230 мс; тёплый (та же глава второй раз) — карточки нет вовсе. То есть лечить надо ОЖИДАНИЕ,
|
||||
а не показ.
|
||||
|
||||
Сделано двумя ходами, оба — отраслевая практика, ни одного таймера в компоненте:
|
||||
1. **Чтение главы стартует по НАМЕРЕНИЮ, а не по клику.** `onPreload` у дерева срабатывает на
|
||||
нажатии кнопки и на приходе фокуса (клавиатура), экран зовёт `prefetchQuery`. Рука держит
|
||||
кнопку 60–100 мс — этого хватает, чтобы клик пришёл на готовый ответ. Не на наведение: провести
|
||||
мышью по списку из 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
|
||||
|
||||
**Замок открыт, и это проверено, а не принято на слово.** Контракт лежит в
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
81
frontend/scripts/measure.py
Normal file
81
frontend/scripts/measure.py
Normal 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
894
frontend/scripts/scenes.mjs
Normal 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 мс на символ (перерисовывалась вся
|
||||
// оболочка), после — 40–45. 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все сценарии прошли');
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
48
frontend/src/showcase/About.tsx
Normal file
48
frontend/src/showcase/About.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/showcase/AddBook.module.css
Normal file
30
frontend/src/showcase/AddBook.module.css
Normal 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);
|
||||
}
|
||||
90
frontend/src/showcase/AddBook.tsx
Normal file
90
frontend/src/showcase/AddBook.tsx
Normal 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();
|
||||
}
|
||||
98
frontend/src/showcase/Bank.module.css
Normal file
98
frontend/src/showcase/Bank.module.css
Normal 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);
|
||||
}
|
||||
243
frontend/src/showcase/Bank.tsx
Normal file
243
frontend/src/showcase/Bank.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
frontend/src/showcase/Document.module.css
Normal file
24
frontend/src/showcase/Document.module.css
Normal 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);
|
||||
}
|
||||
51
frontend/src/showcase/Document.tsx
Normal file
51
frontend/src/showcase/Document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/showcase/Goto.module.css
Normal file
31
frontend/src/showcase/Goto.module.css
Normal 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);
|
||||
}
|
||||
122
frontend/src/showcase/Goto.tsx
Normal file
122
frontend/src/showcase/Goto.tsx
Normal 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>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "не удалось
|
||||
|
|
|
|||
39
frontend/src/showcase/Notes.module.css
Normal file
39
frontend/src/showcase/Notes.module.css
Normal 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;
|
||||
}
|
||||
58
frontend/src/showcase/Notes.tsx
Normal file
58
frontend/src/showcase/Notes.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
27
frontend/src/showcase/Settings.module.css
Normal file
27
frontend/src/showcase/Settings.module.css
Normal 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);
|
||||
}
|
||||
34
frontend/src/showcase/Settings.tsx
Normal file
34
frontend/src/showcase/Settings.tsx
Normal 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][] = [
|
||||
['Профиль и доступ', 'Учётная запись, вход, завершение сеансов.'],
|
||||
['Язык интерфейса', 'Сейчас интерфейс только русский.'],
|
||||
['Светлая тема', 'Сейчас доступна только тёмная.'],
|
||||
[
|
||||
'Перевод по умолчанию',
|
||||
'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.',
|
||||
],
|
||||
['Использование', 'Сколько израсходовано и что будет при исчерпании лимита.'],
|
||||
];
|
||||
|
|
@ -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 130–230 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: 'Добавьте книгу — её разделы откроются здесь вкладками.',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
66
frontend/src/showcase/documents.test.ts
Normal file
66
frontend/src/showcase/documents.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
70
frontend/src/showcase/documents.ts
Normal file
70
frontend/src/showcase/documents.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
'Профиль и доступ, конфигурация моделей, параметры перевода по умолчанию. Всё техническое ' +
|
||||
'живёт здесь и только здесь.',
|
||||
},
|
||||
};
|
||||
20
frontend/src/showcase/terms.ts
Normal file
20
frontend/src/showcase/terms.ts
Normal 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);
|
||||
};
|
||||
24
frontend/src/showcase/useScreenshotFlag.ts
Normal file
24
frontend/src/showcase/useScreenshotFlag.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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 толстые и светлые — на трёх панелях и в читалке
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
// Вид задаётся атрибутом, а не набором классов: так имя класса остаётся одно и опечатка в нём
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
36
frontend/src/ui/Chips.module.css
Normal file
36
frontend/src/ui/Chips.module.css
Normal 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
41
frontend/src/ui/Chips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
60
frontend/src/ui/Modal.module.css
Normal file
60
frontend/src/ui/Modal.module.css
Normal 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
53
frontend/src/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/ui/Table.module.css
Normal file
90
frontend/src/ui/Table.module.css
Normal 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
358
frontend/src/ui/Table.tsx
Normal 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 133–200 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)
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
32
frontend/src/ui/TextField.module.css
Normal file
32
frontend/src/ui/TextField.module.css
Normal 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);
|
||||
}
|
||||
31
frontend/src/ui/TextField.tsx
Normal file
31
frontend/src/ui/TextField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/ui/Toggle.module.css
Normal file
26
frontend/src/ui/Toggle.module.css
Normal 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;
|
||||
}
|
||||
26
frontend/src/ui/Toggle.tsx
Normal file
26
frontend/src/ui/Toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue