From 57e6bd1d63522d51aed61c1f8cd907a29844a6b3 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 10 Aug 2026 22:30:45 +0300 Subject: [PATCH] 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 --- frontend/.gitignore | 4 + frontend/README.md | 21 +- frontend/docs/BACKLOG.md | 25 +- frontend/docs/FRONTEND_PLAN.md | 83 ++ frontend/docs/S37_SESSION_PROMPT.md | 208 ++++ frontend/docs/frontend-PROGRESS.md | 822 +++++++++++++++- frontend/package.json | 3 +- frontend/scripts/measure.py | 81 ++ frontend/scripts/scenes.mjs | 894 ++++++++++++++++++ frontend/scripts/shot.mjs | 35 +- frontend/src/api/index.ts | 2 +- frontend/src/api/vocabulary.ts | 68 +- frontend/src/mock/bank.ts | 37 +- frontend/src/mock/scale.ts | 17 +- frontend/src/shell/Shell.module.css | 78 +- frontend/src/shell/Shell.tsx | 53 +- .../{Context.module.css => About.module.css} | 20 +- frontend/src/showcase/About.tsx | 48 + frontend/src/showcase/AddBook.module.css | 30 + frontend/src/showcase/AddBook.tsx | 90 ++ frontend/src/showcase/Bank.module.css | 98 ++ frontend/src/showcase/Bank.tsx | 243 +++++ frontend/src/showcase/Blank.module.css | 28 + frontend/src/showcase/Blank.tsx | 9 +- frontend/src/showcase/Context.tsx | 162 +--- frontend/src/showcase/Document.module.css | 24 + frontend/src/showcase/Document.tsx | 51 + frontend/src/showcase/Goto.module.css | 31 + frontend/src/showcase/Goto.tsx | 122 +++ frontend/src/showcase/Library.module.css | 57 +- frontend/src/showcase/Library.tsx | 79 +- frontend/src/showcase/Loaded.tsx | 2 +- frontend/src/showcase/Notes.module.css | 39 + frontend/src/showcase/Notes.tsx | 58 ++ frontend/src/showcase/Reader.module.css | 34 +- frontend/src/showcase/Reader.tsx | 67 +- frontend/src/showcase/Settings.module.css | 27 + frontend/src/showcase/Settings.tsx | 34 + frontend/src/showcase/Showcase.tsx | 309 +++--- frontend/src/showcase/Status.module.css | 2 +- frontend/src/showcase/documents.test.ts | 66 ++ frontend/src/showcase/documents.ts | 70 ++ frontend/src/showcase/format.ts | 27 +- frontend/src/showcase/rooms.ts | 23 - frontend/src/showcase/terms.ts | 20 + frontend/src/showcase/useScreenshotFlag.ts | 24 + frontend/src/tokens/measures.ts | 8 +- frontend/src/tokens/reset.css | 4 + frontend/src/tokens/tokens.css | 101 +- frontend/src/tokens/tokens.test.ts | 84 +- frontend/src/ui/Button.module.css | 21 + frontend/src/ui/Button.tsx | 8 +- frontend/src/ui/Callout.module.css | 49 +- frontend/src/ui/Callout.tsx | 18 +- frontend/src/ui/Chips.module.css | 36 + frontend/src/ui/Chips.tsx | 41 + frontend/src/ui/FilterField.module.css | 11 +- frontend/src/ui/FilterField.tsx | 6 +- frontend/src/ui/List.module.css | 12 +- frontend/src/ui/List.tsx | 29 +- frontend/src/ui/Locale.tsx | 14 +- frontend/src/ui/Modal.module.css | 60 ++ frontend/src/ui/Modal.tsx | 53 ++ frontend/src/ui/Table.module.css | 90 ++ frontend/src/ui/Table.tsx | 358 +++++++ frontend/src/ui/Tabs.module.css | 58 +- frontend/src/ui/Tabs.tsx | 41 +- frontend/src/ui/TextField.module.css | 32 + frontend/src/ui/TextField.tsx | 31 + frontend/src/ui/Toggle.module.css | 26 + frontend/src/ui/Toggle.tsx | 26 + frontend/src/ui/Tree.module.css | 14 + frontend/src/ui/Tree.tsx | 103 +- 73 files changed, 5062 insertions(+), 597 deletions(-) create mode 100644 frontend/docs/S37_SESSION_PROMPT.md create mode 100644 frontend/scripts/measure.py create mode 100644 frontend/scripts/scenes.mjs rename frontend/src/showcase/{Context.module.css => About.module.css} (58%) create mode 100644 frontend/src/showcase/About.tsx create mode 100644 frontend/src/showcase/AddBook.module.css create mode 100644 frontend/src/showcase/AddBook.tsx create mode 100644 frontend/src/showcase/Bank.module.css create mode 100644 frontend/src/showcase/Bank.tsx create mode 100644 frontend/src/showcase/Document.module.css create mode 100644 frontend/src/showcase/Document.tsx create mode 100644 frontend/src/showcase/Goto.module.css create mode 100644 frontend/src/showcase/Goto.tsx create mode 100644 frontend/src/showcase/Notes.module.css create mode 100644 frontend/src/showcase/Notes.tsx create mode 100644 frontend/src/showcase/Settings.module.css create mode 100644 frontend/src/showcase/Settings.tsx create mode 100644 frontend/src/showcase/documents.test.ts create mode 100644 frontend/src/showcase/documents.ts delete mode 100644 frontend/src/showcase/rooms.ts create mode 100644 frontend/src/showcase/terms.ts create mode 100644 frontend/src/showcase/useScreenshotFlag.ts create mode 100644 frontend/src/ui/Chips.module.css create mode 100644 frontend/src/ui/Chips.tsx create mode 100644 frontend/src/ui/Modal.module.css create mode 100644 frontend/src/ui/Modal.tsx create mode 100644 frontend/src/ui/Table.module.css create mode 100644 frontend/src/ui/Table.tsx create mode 100644 frontend/src/ui/TextField.module.css create mode 100644 frontend/src/ui/TextField.tsx create mode 100644 frontend/src/ui/Toggle.module.css create mode 100644 frontend/src/ui/Toggle.tsx diff --git a/frontend/.gitignore b/frontend/.gitignore index 817f86c2..11503e94 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -4,3 +4,7 @@ dist/ # Снимки скриншот-цикла и системные библиотеки Chromium — бинарники в репозиторий не едут. .shots/ .tooling/ + +# Референсы владельца — чужие скриншоты (JetBrains Fleet, Antigravity), в репозиторий не едут; +# кладёт их на диск владелец, процедура — docs/FRONTEND_PLAN.md §4. +references/ diff --git a/frontend/README.md b/frontend/README.md index 1ef075d6..2f7383aa 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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). diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md index e2ae4e24..24a65c61 100644 --- a/frontend/docs/BACKLOG.md +++ b/frontend/docs/BACKLOG.md @@ -11,7 +11,7 @@ | ~~Ф-8~~ | ~~Вопрос владельцу: закрывать ли `/* eslint-disable */` в TSX~~ — **закрыт 02.08 без решения владельца.** Вопрос был поставлен как выбор из двух плохих вариантов, а у него есть отраслевой третий: `@eslint-community/eslint-plugin-eslint-comments` 4.7.2 (`no-unlimited-disable` + `require-description` + `disable-enable-pair`). Голое отключение падает тремя ошибками, точечное подавление с причиной проходит. Урок: не выносить владельцу технический вопрос, не поискав отраслевую практику | закрыт | адверсариальное ревью S1 | | ~~Ф-9~~ | ~~**Хвосты гейтов после адверсариального ревью S1**~~ — **все четыре закрыты 04.08 в S2**, каждая живым нарушением: именованные и системные цвета в TSX ловятся разрешающим списком на цветных атрибутах (`fill`/`stroke`/`color`/`stopColor`/`floodColor`/`lightingColor` — только `none`, `currentColor`, `inherit`, `transparent`, `var(--…)`) · значение CSS-переменной в `style={{…}}` обязано быть числом или токеном · носитель `style` внутри объектного литерала запрещён, и тем самым закрыт JSX-спред · системные цветовые ключевые слова закрыты `declaration-property-value-disallowed-list` по ВСЕМ свойствам, то есть и в сокращённых записях `outline`/`box-shadow`/`border`. Перечень проверенных форм (15 TSX + 14 CSS) — `FRONTEND_PLAN.md` §5.4.1. **Граница названа честно и там же:** гейт цветных атрибутов ловит литерал и шаблон, но не вычисленное значение (`const c = 'red'; `) — синтаксически это не берётся, и объявлять строку «закрытой без остатка» было бы ровно тем переобещанием, из-за которого она и заведена | закрыт для литеральных форм, граница названа | адверсариальное ревью S1 | | ~~Ф-10~~ | ~~**Хвосты структурных тестов**~~ — **закрыты 04.08 в S2.** `cssModules.test.ts` строит пары по ИМПОРТАМ, а не по совпадению имён файлов, видит деструктуризацию `const { name } = styles` и выкидывает `url(...)` и строковые литералы до поиска классов; проверка сиротства стала «модуль, который никто не импортирует». `tokens.test.ts` получил замок полноты: каждый токен обязан попасть либо в замеры, либо в явный список подобранных, и обратно — список не может ссылаться на исчезнувший токен. Плюс новое: числа для виртуализатора живут в `src/tokens/measures.ts` и сверяются с одноимёнными токенами. Семь живых нарушений, перечень — `FRONTEND_PLAN.md` §5.4 | закрыт | адверсариальное ревью S1 | -| Ф-11 | **Контраст ниже порога WCAG на 4 узлах на маршрут** (приглушённый текст `#707479` и `#8a8e91` на `#17191a`); число пере-сверено живым прогоном `npm run check:full` 08.08 — axe печатает **6** узлов на маршрутах с данными (`/showcase`, `/scale`, `/offline`) и **3** на пустых (`/empty`, `/loading`, `/error`); было по 4 на двух маршрутах 04.08. Рост не регресс палитры, а рост числа приглушённых надписей: статус-полоса получила ETA, причину паузы и состояние живого канала, и все они на `--color-text-muted`. Маршрутов стало шесть, поэтому и сумма по прогону выросла. Это прямое следствие копирования палитры Fleet, которую владелец принял; axe отчитывается о них отдельно и команду не валит. Решение владельца: оставить как есть ради верности референсу или поднять приглушённые тона | решение владельца | ревью S1 №2 | +| ~~Ф-11~~ | ~~**Контраст ниже порога WCAG на 4 узлах на маршрут**~~ — **ЗАКРЫТА 09.08 в S3.5 замером: axe печатает НОЛЬ узлов контраста на всех семи маршрутах.** Решение владельца тут не понадобилось, потому что вопрос был поставлен неверно: узлы давал не «весь приглушённый тон», а КОНКРЕТНАЯ роль — `--color-text-muted` (#707479 на панели = 3.85:1) стоял под текстом, несущим смысл. В S3.5 весь такой текст переведён на `--color-text-secondary` (5.36:1 до перекраски, 5.6:1 после), а `--color-text-muted` остался только под НЕтекстом — иконками и дорожками индикаторов, к которым порог для текста не предъявляется. Заодно палитра стала тёплой (замечание 16), и это подняло числа ещё немного. Верности референсу это не стоило ничего: приглушённая ступень как ступень осталась | закрыт | ревью S1 №2 → S3.5 | | Ф-12 | **Масштаб списков** — **главное закрыто 04.08 в S2 замером, а не обещанием.** Заведён маршрут `/scale`: та же оболочка на настоящем масштабе — 2284 раздела и банк на 1200 терминов (`src/mock/scale.ts` через `getScaleFixture()`). Дерево и банк виртуализованы по умолчанию (RAC `Virtualizer` + `ListLayout`), и это измерено в живом браузере: на 2293 узлах дерева в DOM держится **31 строка**, `scrollHeight` 59 618px, кадр при прокрутке — медиана 22 мс, p95 31 мс; банк на 1200 терминах — 30 строк в DOM, медиана 16,7 мс. Прокрутка до конца и обратно счётчик строк не растит. **Замечания намеренно НЕ виртуализованы:** их список пер-главный, а юнитов на главу ≈1,9 (ПТ-21) и флаг у юнита один — 10³ там не бывает по построению; сводный вид по книге, если он появится, придёт с S6 и получит тот же примитив `ui/List`. **Два хвоста строки НЕ сделаны и получают диспозицию, а не зачёркивание** (нашло адверсариальное ревью S2): дерево осталось плоским без группировки по сотням — это работа экрана глав (S4), у оболочки для неё нет ни данных, ни требования; банк без разделения «подписано / ждёт решения» — это работа подписного экрана (S5), справочному виду в 320px разделение только мешает | масштаб закрыт; группировка — S4, разделение банка — S5 | ревью S1 №2 | | Ф-14 | **Вход фронта в контракт API (единый бэклог, строка 95)** — **доведён до артефакта 04.08 в S3: [`API_CONTRACT_INPUT.md`](API_CONTRACT_INPUT.md)**, перечень с обоснованием на каждый пункт, каждое обоснование грунтовано `file:line` движка (прочитано, не пересказано). Внутри: язык кодами · идентификатор главы отдельно от номера (номер производный от чанкера, `chunker.go:101-118` — пустая глава не потребляет номер, правка исходника сдвигает нумерацию) · единица пары = ЮНИТ с ключом лидера `(chapter, firstChunkIdx)`, `export.go:88-93` · состояние у прогона, у главы выполнение · прогресс пофазно по юнитам · `run_id` · трёхзначный `auto\|draft\|approved` вместо булева `signed` · непрозрачный ключ строки банка (автоинкремент движка стабильным НЕ является — `migrate.go:172-174`, банк заменяется целиком каждый прогон) · поля `type`/`sense`/`since_ch`/`source` с точным словарём · подпись = набор решений promote/decline, стоп снимается ТОЛЬКО полным набором (`mining.go:200-202`) · продуктовый словарь вердиктов и запрет `detail` на стороне контракта · монотонный `id` + `Last-Event-ID` + ревизия на чтениях из того же счётчика · **разрешающий список полей вместо вычитания** (инвентарь протечек `StatusReport`/`ChapterPassport`: деньги · `routing`/модели · снапшот/дрифт/ре-билл) · правило эволюции словарей под Ф-22. Плюс 6 вопросов (К-1..К-6), на которые фронт ответа не имеет. **Найдено при сборке — четвёртый экземпляр класса «фикстура выдумала форму»:** титульная политика pack-13 рендерит заголовок ДЕТЕРМИНИСТИЧЕСКИ («Глава {n}» из `configs/langpacks/zh-ru/heading.txt`), вырезает исходный маркер из текста для модели (`chunker.go:110-118`) и ВКЛЕИВАЕТ титул внутрь финального текста первого юнита (`export.go:209-215`), оставляя колонку исходника без него — а фикстура рисует «Раздел N. …» в дереве и неснятый «第二节:» в оригинале. Не чинится до ответа на К-2: правка формы заголовка без контракта даст пятую выдумку. **По слову владельца тем же днём написан и ЧЕРНОВИК самого контракта — [`API_CONTRACT_DRAFT.md`](API_CONTRACT_DRAFT.md)** (12 разделов + приложение-карта вердиктов; каждое место помечено «выведено из кода» / «предложено фронтом» / «открыто»; двухфазный поток ВЫВЕДЕН из `--verify-bank`, а не спроектирован; в конце — прогон ревью-вопроса строки 95 по шести изменениям движка). Самопроверка черновика нашла дыру в самой лестнице статусов строки 95: между «разбор» и «перевод» нет состояния покоя, а библиотека обязана показывать разобранную книгу, которую не запускали ⇒ заведено `not_started`, и сегодняшние девять значений типов разводятся на десять. **Дофикс по ревью оркестратора (04.08) исполнен, форма сменена по решению владельца:** нормативная поверхность контракта — теперь **OpenAPI 3.1 `docs/api-contract/openapi.yaml`** (spectral шестым шагом `check`, openapi-typescript генерит `src/api/schema.ts`, тест дрифта + ветка неизвестного значения на шве `src/api/contract.ts`, MSW-хендлер как proof-of-form); md стал спутником — провенанс, обоснования, К-вопросы. Закрыты оба блокера ревью (состояние пары выводится из ПАРЫ «вердикт + наличие текста», а не из вердикта — флагнутый юнит легально приходит С текстом; чтение банка помечено зависимостью «канала нет»), восемь ✓-инфляций пере-размечены на ◆, внесены все фактические правки (фантом `auto` в провенансе убран по путям записи · `upstream_not_ok` добавлен · ПТ-34 заведён пунктом · стоп по потолку разведён с `failed` · правило разрыва `Last-Event-ID` · смягчён §12 про чанкер). Открытых вопросов стало девять: добавлен К-9 — **отказ прескрина (строка 94) не выразим ни одним из десяти статусов**. **Ратифицировать и перенести в `docs/architecture/14-api-contract/` — оркестратор; фронт этого не может** (шапка этого файла). **Подрезана 04.08 по слову владельца:** из описаний спеки убран провенанс и грунты `file:line` — они дублировали спутник и разошлись бы с ним при первой правке; осталось норматив плюс та оговорка «почему», что предотвращает конкретную ошибку. Спека переведена на английский, потому что её описания приезжают доккомментариями в генерённый `src/api/schema.ts` (Ф-25). Адверсариальная проверка подрезки нашла в спеке три расхождения с самим спутником (провенанс `mined` подан как «никем не подписано» — это ось `status`; ревизия кадра объявлена фактом при открытом К-4; `Run.status` берёт книжный словарь целиком) и два вопроса, которых не было: **К-10** пофазность выполнения главы и **К-11** условная обязательность полей ⇒ открытых стало одиннадцать. **Бумага сокращена тем же днём:** `API_CONTRACT_INPUT.md` под ⚠-баннером «исполнено, не поддерживается» — его 18 разделов лежат один-в-один в §2 спутника, и как живой документ он был третьим местом правки; не удалён (untracked, удаление невосстановимо), перенос в `docs/archive/` — оркестратору. Из спутника убран §1 (пины и отклонение по пиру дублировали `STACK_DECISIONS.md` §3 и Ф-23/Ф-24). **Второй пасс ревью (round-2) исполнен:** чтение выгрузки + К-12 · условие `if action=promote → dst` непустой (spectral валидирует, генератор ИГНОРИРУЕТ — записано в К-11 измерением) · `kind` nullable с правилом пустого, пометка ✓→◆ · `resume` после потолка помечен зависимостью на платформенный механизм поднятия · §2.7 переписан: «текст + замечание» это класс, но `glossary_miss` в него не входит (проверено по `memberDrops`/`waverun`), фикстура `r2-2` переведена на c-lite drop · ветка неизвестного добита до шести словарей. Открытых вопросов двенадцать. **08.08 (S3): РАТИФИЦИРОВАН и ПРАВЛЕН.** Контракт ратифицирован оркестратором как D39.99; S3 внесла в зонную копию весь список правок своего промта и бампнула версию до **0.2.0** — `paused`, `eta_seconds`, метка главы из данных, пер-книжная ревизия с правилом `>= R` и обязательным `resync_required` после полной замены, keyset-курсор на ВСЕХ списках, `X-TM-Client`, `Run.paused_reason`, `GET /usage`, `GET /books/{id}/run-options` с потолком прогона в ГЛАВАХ, same-origin. Байт-сверка с каноном намеренно даёт расхождение: канон правит оркестратор, диффом. Открытых вопросов стало **семь** (закрыты К-1/К-3/К-4/К-5/К-7/К-8/К-12, добавлен К-13 — Ф-31). Что переносить в канон — канон `docs/architecture/14-api-contract/` (патч ПРИМЕНЁН и удалён, D39.115) | правки внесены · ждут пере-ратификации диффом | ревью S1 №3 · сборка S3 04.08 · дофикс ревью 04.08 · подрезка 04.08 | | ~~Ф-15~~ | ~~**`src/api/` синхронен, состояний загрузки и ошибки в типах нет**~~ — **ЗАКРЫТА 08.08 в S3.** `src/api/` асинхронен и разговаривает HTTP: MSW как СЕТЕВОЙ мок (а не подменённый модуль), поэтому приложение делает настоящий `fetch` и настоящий `EventSource`, и ветки ожидания/ошибки/пустоты — это код, который работает в бою, а не второй его экземпляр. Ветки живут в одном месте (`showcase/Loaded.tsx`) и приезжают ВМЕСТЕ со слоем данных, до первого продуктового экрана, как строка и требовала. Типы сведены с контрактом: `types.ts` удалён, экраны живут на суженных типах `api/contract.ts`. **Что нашёл сам скриншот-цикл, а не рассуждение:** выключенный запрос TanStack Query остаётся `pending` НАВСЕГДА, и на пустой библиотеке правая панель вечно показывала «Идёт загрузка» — различение «ждём» и «выключено» (`fetchStatus === 'idle'`) добавлено после того, как это увидели на кадре. Фикстуры всех состояний — шесть миров, по маршруту на каждый | закрыт | ревью S1 №3 | @@ -19,7 +19,7 @@ | Ф-7 | **Хвосты сведения витрины с референсом** (`FRONTEND_PLAN.md` §5.2). **Часть S2 закрыта 04.08:** `+` стоит в ряду вкладок ЛЕВОЙ панели и запускает «Добавить книгу» (решение владельца 04.08); дублирующая строка «+ Добавить книгу» из дерева убрана — одна вещь, одно место. В центре и справа `+` нет и не будет: наборы вкладок там фиксированные, пользователь их не расширяет, и кнопка вышла бы декоративной — ровно то, что эта строка запрещает. Состояния наведения перестали быть «непроверенными»: они сняты не статичным кадром, а прогоном браузера (`data-hovered`/`data-selected`/`data-focus-visible` у кнопки, вкладки, строки дерева и списка). **Осталось на S6:** приглушённая колонка оригинала — решение, а не замер, пересмотреть на настоящей читалке | ~~S2~~ · S6 | S1, сверка с fleet.png | | Ф-16 | **Выгрузка книги (§3.11) ушла из верхней полосы.** В витрине S1 там висела иконка загрузки без действия; по правилу Ф-7 («кнопка только вместе с действием») она снята до S7, который строит экспорт. Вернуть иконку вместе с самим действием | S7 | S2 | | Ф-17 | **Закрытие вкладки с клавиатуры не объявлено скринридеру.** Крестик у вкладки-документа сознательно НЕ кнопка: у роли `tab` дети презентационные (ARIA), вложенный фокусируемый элемент выпал бы из дерева доступности, оставшись в порядке табуляции (это же ловит axe правилом `nested-interactive`). Мышь закрывает крестиком, клавиатура — `Delete` на выбранной вкладке. Объявить сочетание нечем: `aria-keyshortcuts` не входит в типизированный API `Tab`/`TabList` в react-aria-components 1.20.0. Пересмотреть при обновлении библиотеки либо при появлении палитры команд | триггер: мажор RAC или экран горячих клавиш | S2 | -| Ф-19 | **Переключение вкладки размонтирует её содержимое, и прокрутка длинного списка теряется.** Нашло адверсариальное ревью S2 и подтвердило независимым прогоном: `react-aria-components` не рендерит невыбранную `TabPanel` (`dist/private/Tabs.mjs:276` — `if (!isSelected && !shouldForceMount) return null`), поэтому на `/scale` прокрутка дерева `scrollTop=9000` → вкладка «Поиск» → обратно даёт `scrollTop=0`, а пересборка коллекции из 2284 узлов стоит 70–140 мс (два независимых замера). Ф-12 это не переоткрывает: там речь про поведение списка при 10³, и оно держится. **Чинить сейчас не стали:** `shouldForceMount` не лечит (панель остаётся видимым `div` и легла бы поверх соседней в той же ячейке грида), а настоящее лекарство — keep-alive либо запоминание офсета на вкладку — это решение об устройстве оболочки, и принимать его вслепую, до настоящих экранов, значит строить механизм не под задачу | S4 (экран глав) либо раньше, если владелец увидит потерю места | адверсариальное ревью S2 | +| ~~Ф-19~~ | ~~**Переключение вкладки размонтирует её содержимое, и прокрутка длинного списка теряется.**~~ — **ЗАКРЫТА 09.08 в S3.5, и механизмом.** Лекарство выбрано не вслепую: решение об устройстве оболочки принято тем же паком (модель вкладок VS Code, замечание владельца 2), и под него подошёл keep-alive, а не запоминание офсета. Реализация — проп `keepAlive` у примитива `ui/Tabs`: невыбранные `TabPanel` монтируются (`shouldForceMount`), стоят в одной ячейке грида и гасятся `visibility: hidden`. Именно `visibility`, а не `display: none`: последний уничтожает бокс и вместе с ним офсет прокрутки — то есть лечил бы половину болезни. Библиотека сама вешает на невыбранную панель `inert` и `data-inert`, поэтому из дерева доступности и порядка табуляции она вынута, а axe на дублях содержимого не срабатывает (проверено прогоном на семи маршрутах). **Цена, которую пришлось заплатить и назвать:** при keep-alive все открытые вкладки живут одновременно, поэтому общий запрос глав отдал бы каждой текст АКТИВНОЙ главы — запрос переехал в `showcase/Document.tsx`, по запросу на открытую вкладку. Приёмка: `npm run scenes scroll` — прокрутка банка на `/scale` до 9000 → уход на «О книге» → возврат → офсет 9000. **Хвост, которого раньше не было:** скрытые панели остаются в DOM, поэтому глобальный локатор по роли в тестах находит строку соседней вкладки — сценарии обязаны искать внутри своей области | закрыт | адверсариальное ревью S2 → S3.5 | | Ф-20 | **Мёртвый шрифт и мёртвые токены в поставке.** `@fontsource-variable/jetbrains-mono` грузится в бандл, но `--font-mono` не выбран ни одним правилом: художественную прозу моноширинным не набирают, а во Fleet на этом месте был код. Туда же несколько токенов, которые ни разу не используются (`--color-current-line`, `--color-hint`). Замок полноты токенов (S2) требует классифицировать токен, но не требует его использовать. Решать на S6, когда читалка станет настоящей: либо моноширинный находит работу, либо импорт и токены уходят | S6 | адверсариальное ревью S2 | | ~~Ф-22~~ | ~~**У закрытых юнионов нет ветки неизвестного значения**~~ — **ЗАКРЫТА 08.08 в S3, и механизмом, а не дисциплиной.** Ветка стоит НА ШВЕ (`src/api/vocabulary.ts` + нормализация в `contract.ts`): значение, пришедшее с провода, сужается один раз, там, где входит, и незнакомое становится `null`, а не роняет дерево на `undefined.tone`. Словарь объявлен как `Record<Значение, Смысл>`, поэтому список значений ВЫВОДИТСЯ из записей — разойтись с типом ему нечем, а новое значение контракта роняет `tsc` с именем пропавшего значения в тексте ошибки (проверено живым нарушением, `FRONTEND_PLAN.md` §5.4.3). Тем же ходом закрыт названный в строке второй дефект того же класса: незнакомая ступень замечания больше НЕ уходит в спокойную ветку — неизвестная ступень берёт ветку внимания, потому что единственное, чего ось важности делать не вправе, это прятать важное. Продуктовое слово ступени по-прежнему за владельцем (Ф-21), и оно тут не выдумано | закрыт | адверсариальное ревью S2 → ревью оркестратора 04.08 → S3 08.08 | | Ф-21 | **Ступень замечания различается только цветом полоски** (`attention` синяя, `glance` серая). Зрячему разница читается ещё и светлотой, но скринридеру приоритет не достаётся вовсе. Лечение — слово, как это уже сделано для состояния прогона книги; **какое именно слово, решает владелец**: словарь замечаний продуктовый (ПТ-33), а §3.8 требует, чтобы экран не выглядел тревожным, так что «важно/неважно» не годится | решение владельца | адверсариальное ревью S2 | @@ -33,6 +33,25 @@ | Ф-29 | **Мок-сеть едет в продакшн-бандл.** `startMocking()` подтягивает `src/mock/` динамическим импортом, но скриншот-цикл гоняет `vite build` + `preview`, то есть продакшн-режим, и без мока там нечего показывать. Пока платформы на том же origin нет, это цена работоспособности; в день, когда фронт заговорит с настоящей платформой, папка `mock/` удаляется целиком и импорт уходит вместе с ней — ровно так, как обещал шов. Строка нужна, чтобы это удаление кто-то сделал ОСОЗНАННО, а не обнаружил мок в бою | триггер: живая платформа на том же origin | S3 08.08 | | Ф-30 | **Метка главы без заголовка — голый порядковый номер.** Контракт запретил синтез «Глава {n}» (К-3: метка из ДАННЫХ книги, легальна книга без номеров и глав вовсе), и фикстуры содержат такие главы намеренно. Когда у главы нет ни `heading`, ни `number`, в дереве остаётся позиция в порядке чтения — на кадре это строка «12» среди названий. Врать словом фронт не стал: словарь продуктовый, и это ровно вопрос В-4, который уже стоит владельцу. Строка держит хвост до его ответа | решение владельца (В-4) | S3 08.08, кадр `/scale` | | Ф-31 | **К-13 — новый вопрос контракта, заведённый этой сессией.** Достижение СОБСТВЕННОГО потолка прогона (`ceiling_chapters`) и исчерпание кредита аккаунта сегодня неразличимы: `paused_reason` имеет ровно одно значение, `credit_exhausted`, и оно про кредит. Механически они могут совпасть — платформа берёт холд на сумму потолка до спавна, — но человек, упершийся в свой колпак, и человек, у которого кончились деньги, находятся в разных положениях, и фраза у них разная. Передано оркестратору в канон `docs/architecture/14-api-contract/` (патч применён и удалён, D39.115) §4; в спеке помечено в `EventCeiling` | владелец + платформа | S3 08.08 | -| Ф-32 | **Замечания читаются пер-КНИЖНО, а показываются пер-главно.** Контракт даёт `GET /books/{id}/notes` и не даёт пер-главной ручки; панель контекста фильтрует по открытой главе на клиенте. Пока это дёшево — дефолт страницы 500, а на книге с сотнями замечаний клиент тянет весь список ради трёх строк, и с курсором дотянет ВЕСЬ. Ф-12 обосновывал отказ от виртуализации замечаний тем, что список пер-главный и мал; после этой правки он пер-главный на ЭКРАНЕ, но пер-книжный в ПАМЯТИ. Решать не фронту: либо пер-главная ручка (вопрос автору контракта), либо явный потолок с честным «показаны не все». Обнаружено вторым адверсариальным проходом 08.08 при починке того, что панель «О читаемой главе» показывала замечания всей книги | S6 (читалка) либо раньше, если появится книга с тысячами замечаний | второй проход ревью S3 | +| Ф-32 | **Замечания читаются пер-КНИЖНО, а показываются пер-главно.** Контракт даёт `GET /books/{id}/notes` и не даёт пер-главной ручки; панель контекста фильтровала по открытой главе на клиенте — **с S3.5 фильтр снят, сводка пер-книжная (замечание владельца 8, строка Ф-38), то есть пер-книжное чтение теперь и показывается пер-книжно**. Пока это дёшево — дефолт страницы 500, а на книге с сотнями замечаний клиент тянет весь список ради трёх строк, и с курсором дотянет ВЕСЬ. Ф-12 обосновывал отказ от виртуализации замечаний тем, что список пер-главный и мал; после этой правки он пер-главный на ЭКРАНЕ, но пер-книжный в ПАМЯТИ. Решать не фронту: либо пер-главная ручка (вопрос автору контракта), либо явный потолок с честным «показаны не все». Обнаружено вторым адверсариальным проходом 08.08 при починке того, что панель «О читаемой главе» показывала замечания всей книги | S6 (читалка) либо раньше, если появится книга с тысячами замечаний | второй проход ревью S3 | | Ф-5 | **Tauri 2.x вторым шагом** — по явным триггерам (трей · глобальные горячие клавиши · распространяемый .exe · офлайн · хранилище учёток ОС); до того — установимое PWA, только браузерные API | триггеры названы | STACK_DECISIONS §6, D39.84 | | Ф-6 | **`@tanstack/react-virtual`** — в резерве, подключать только по замеру (дефолт виртуализации — RAC Virtualizer). **Замер сделан 04.08 (S2) и триггер НЕ наступил:** RAC `Virtualizer` держит 2284 раздела на 31 строке в DOM при медиане кадра 22 мс и 1200 терминов на 30 строках при 16,7 мс. Вторая библиотека виртуализации сейчас была бы платой без выигрыша; пересматривать — если появится список с переменной высотой строки (читалка S6) и `estimatedRowSize` не справится | по замеру; замер 04.08 отрицательный | STACK_DECISIONS §2 | +| Ф-33 | **Поиска по ТЕКСТУ книги в контракте нет вовсе.** Замечание владельца 4 («две кнопки поиска, делают одно и то же») разведено так: лупа в правом верхнем углу стала настоящей палитрой быстрого перехода к разделу или термину, а вкладка-заглушка «Поиск» из левой панели СНЯТА. Снята потому, что построить её нечем: у контракта пятнадцать ручек и ни одной поисковой (`GET /books/{id}/…` даёт списки, не запросы), то есть вкладка была обещанием без адресата. Вопрос не фронта: полнотекстовый поиск по книге — это либо ручка платформы, либо индекс на клиенте поверх выгрузки, и то и другое решает автор контракта. До ответа поиск по книге живёт как хвост читалки | S6 либо ручка контракта | S3.5, замечание 4 | +| Ф-34 | **Светлая тема: цена честно замерена, дёшево не выходит.** Проверено по коду, а не на глаз. Дёшево — только половина: значения цвета лежат в одном месте (`tokens.css`), второй набор под `:root[data-theme='light']` механичен. Дорого — вторая половина: подпись оболочки, снятая с Fleet, это «карточки на почти чёрном фоне», и она не инвертируется значениями — в светлой теме отношения поверхностей приходится ПЕРЕПРИДУМАТЬ (что светлее, панель или геп; чем отделять карточку без теней; что делать с вырезом активной вкладки, который держится на паре «хром ↔ полотно»). Плюс переключатель и его персист, плюс пере-прогон axe по семи маршрутам на второй теме, плюс замок токенов на второй набор. Оценка: отдельный проход масштаба S7, а не хвост фикс-пака. Пункт назван в каркасе настроек честно: «сейчас доступна только тёмная» | S7 либо отдельный пакет | S3.5, замечание 15 | +| Ф-35 | **Язык интерфейса: словаря строк нет, есть ~90 литералов в разметке.** Дёшево сделана только та часть, что снижает цену завтра: локаль интерфейса сведена в ОДНО место (`ui/Locale.tsx`, `interfaceLocale`) — прежде её порознь зашивали провайдер примитивов, `Intl.DisplayNames`, `Intl.NumberFormat` и `toLocaleDateString`. Само переключение требует словаря строк, хранилища выбора и гейта против литералов в разметке (иначе следующая сессия заведёт строку мимо словаря — класс ошибки известный), а ещё продуктового решения «какие языки». Оценка: механический проход по ~15 файлам плюс гейт плюс решение владельца. Пункт назван в каркасе настроек: «сейчас интерфейс только русский» | S7 либо отдельный пакет | S3.5, замечание 15 | +| Ф-36 | **Физический масштаб интерфейса на мониторе — ручки нет.** Замечание владельца 1 говорит «великоваты, особенно на 27″ 2K». Замер при вьюпорте референса 1280×764 расхождения не подтвердил: промежутки, ширины панелей, шаг строки и высота вкладки совпадают с Fleet (FRONTEND_PLAN §5.5). Значит речь про ФИЗИЧЕСКИЙ размер на конкретном экране, а он зависит от масштабирования ОС, и лечится он не сдвигом токенов вслепую (это разъедет витрину с референсом), а ручкой «плотность интерфейса» в настройках. Цена ручки названа честно: все размеры сегодня в px, для одного множителя их надо перевести в rem и задать `font-size` на корне — механически, но трогает КАЖДЫЙ размерный токен и все замеренные числа, поэтому отдельным проходом с пере-замером. ⚠ **Дополнено 10.08:** предел меры набора (`--reader-width`), заведённый S3.5 «пока нет ручки», владелец отверг словами «жёстко приклеен к центру, до этого было намного лучше» — токен удалён, набор снова во всю ширину панели. Значит длина строки на 2K остаётся открытой ровно до ручки плотности, и подпирать её геометрией больше нельзя | владелец (В-7) + отдельный проход | S3.5, замечание 1 | +| Ф-37 | **Пометка черновика висит на статусе КНИГИ, а не главы.** Замечание 24 сделано данными контракта: пока прогон не довёл книгу до `ready`, текст помечен как черновой. Но у главы есть своё выполнение (`units_done/units_total`), и глава, уже прошедшая обе волны в незавершённой книге, получает ту же пометку, что и непереведённая. Точнее было бы по главе, и для этого в контракте не хватает одного: `units_done` не разведён по фазам (открытый вопрос К-10 спутника). До ответа взята безопасная сторона — предупредить лишний раз дешевле, чем не предупредить | контракт (К-10) → S6 | S3.5, замечание 24 | +| Ф-38 | **Вкладка «Замечания» защищена сводкой по книге — слово за владельцем.** Замечание 8 («непонятно зачем, всё рисуется в тексте») по пер-ГЛАВНОМУ списку было верным: он дублировал читалку. Вкладка переделана в пер-КНИЖНУЮ сводку со ссылкой в раздел — сценарий «пройтись по всем местам, требующим внимания», который без неё не живёт и который не стоит ни одного лишнего запроса (контракт даёт замечания пер-книжно, Ф-32). Если владелец сценария не признает — вкладка снимается одной строкой в `Context.tsx`, вместе с ней уходит и `notesQuery` с экрана | решение владельца | S3.5, замечание 8 | +| Ф-39 | **Банку не хватает полей для проверки термина, и выдумывать их фронт не стал.** Замечание 7/13 просит на термин род/пол, описание-обоснование и варианты перевода; в контракте (`BankTerm`) их нет — есть `src`, `dst`, `kind`, `status`, `origin`, `sense`, окно `since_chapter`/`until_chapter`. Показано всё, что есть; недостающее оформлено ЧЕРНОВИКОМ правки спеки с пометкой «предложено» (`frontend-PROGRESS.md`, запись S3.5) и не заведено ни в фикстуру, ни в типы — класс ошибки «фикстура выдумала форму» ловился четырежды (Ф-14). Ратифицирует оркестратор | оркестратор + владелец | S3.5, замечания 7 и 13 | +| Ф-40 | **Вид поля ввода описан в двух модулях.** `ui/TextField.module.css` и `ui/FilterField.module.css` держат байт-в-байт одинаковые `.input`, `::placeholder` и `:focus`; отличие одно — цвет границы в покое. Роли примитивов при этом РАЗНЫЕ и слить их нельзя: `SearchField` очищается Escape и не требует подписи, `TextField` подпись требует — это форма. Лечение — общий модуль стилей поля на оба примитива, и оно требует переезда классов; делать его внутри фикс-пака оболочки значило бы менять то, что пак не трогает, ради семнадцати строк CSS. Нашло адверсариальное ревью S3.5 | ближайшая сессия, трогающая `src/ui/` | S3.5, ревью энтропии | +| Ф-41 | **Спойлер банка раскрывается только наведением.** Клавиатура и тач его не раскроют, а перевод остаётся в DOM (скринридер прочитает). Кнопка раскрытия внутри строки списка невозможна: у роли `option` дети презентационные, и вложенный интерактив роняет гейт axe (та же ловушка, что Ф-17). Настоящее лечение — раскрытие на уровне СПИСКА (один переключатель «показывать термины из будущих разделов» над ним) либо экран подписи S5, где строка перестаёт быть строкой списка. ⚠ **Уточнено 10.08 (S3.6):** спойлер снят с ПЕРЕВОДА и поставлен на описание (`sense`) — закрытый перевод делал строку непроверяемой, а таблица существует ради проверки. Второй хвост строки («состояние замещается объяснением с N-го») ЗАКРЫТ: в таблице окно применимости и состояние — разные столбцы. Остался ровно один: раскрытие только наведением | S5 либо раньше по слову владельца | S3.5, ревью точности | +| Ф-42 | **У keep-alive нет потолка.** Вкладки центра не размонтируются (в этом и лекарство Ф-19), поэтому каждая закреплённая глава держит смонтированную читалку и свой запрос: замерено адверсариальным ревью — семь вкладок дают семь запросов и семь читалок. На фикстурных главах это ничего не стоит, на настоящих (глава бывает одним блоком в тысячи знаков) — стоит. Лечение из отраслевой практики: вытеснять самые давние закреплённые вкладки сверх N смонтированных, оставляя их в ряду. Порог называть по замеру на настоящей книге, а не на глаз | S4 (экран глав) либо при первой жалобе на память | S3.5, ревью корректности | +| Ф-43 | **У чтения банка нет канала — вся витрина банка стоит на моке.** Банк живёт в приватном SQLite движка, платформе читать его запрещено (D39.85), артефакта экспорта банка нет; компаньон контракта (`14-api-contract/README.md` §3) прямо пишет «строки нет — заводит оркестратор», и в живом `docs/PROGRESS.md` я её не нашёл. Пока канала нет, таблица банка (замечание владельца второго круга 7) работает только на фикстуре, а подписной экран S5 строить не на чем | **оркестратор: завести строку единого бэклога** → бэкенд (экспорт банка) → платформа (проекция) | S3.6 10.08, разбор движка | +| Ф-44 | **Действие «подписать банк» не нарисовано — намеренно.** Модель владельца и движка совпадают: подписывается банк ЦЕЛИКОМ, стоп снимается только когда решён каждый термин (`pipeline/mining.go:201`), в контракте это `POST /runs/{id}/resume` с `409` при неполном наборе. Но за действием стоит механизм подписи (S5), а под ним — отсутствующий канал чтения (Ф-43). Кнопка без действия запрещена нормой зоны, поэтому в S3.6 построен ВИД и честное состояние. ⚠ **Уточнено 10.08 по слову владельца:** счётчики «подписано N / ждут решения N» с экрана СНЯТЫ вместе со столбцом состояния строки — они и были той самой путаницей «подписываем банк или термины» из замечания 20 первого круга. В подвале — размер банка, при сужении «показано N из M», и состояние ВСЕГО банка («банк ждёт подписи») из статуса книги. Рисовать действие — когда есть чем его исполнить | S5, после Ф-43 | S3.6 10.08 | +| Ф-45 | **Править термин в таблице нельзя — только читать.** Владелец: «разумеется в банке можно подправить какой то термин». Контракт это выражает (`BankDecision`: `promote` с непустым `dst` либо `decline`, частичное накопление на сервере), то есть редактирование строки — это отправка решения, а не PATCH. Экран решений = S5; в S3.6 таблица читающая, и это названо честно, а не замаскировано неактивными полями | S5 | S3.6 10.08, замечание 7 | +| Ф-46 | **У таблицы банка нет сортировки, и она не различает канон от предложенного.** Первое: сортировки по столбцу нет — примитив `ui/Table.tsx` на RAC `Table` её допускает (`sortDescriptor`), но проектировать навигацию по банку на 60 фикстурных терминах нельзя. Второе, найдено при снятии столбца подписи (10.08): в банке лежат и уже канонические термины, и свежепредложенные — подписывают, по сути, вторые, а таблица их не различает. Возвращать это как «состояние подписи строки» нельзя (владелец снял его прямо), значит нужен другой различитель, и он продуктовый. Сегодня путей два: поиск по трём полям и фильтр по типу со счётчиками. Оба вопроса упираются в настоящий банк книги (Ф-43) и в слово владельца | после Ф-43 + владелец | S3.6 10.08, замечание 7 | +| Ф-47 | **Дефект контракта: `sense` не обязателен, хотя объявлен частью ключа уникальности.** `BankTerm.required` перечисляет восемь полей и `sense` в них нет, а его же описание говорит: термин уникален по `(book, src, sense, since, until)` (`openapi.yaml:983-1023`). Клиент вынужден писать `term.sense ?? ''` в трёх местах и не может отличить «различителя нет» от «различитель пуст» — при том что ровно по этому полю две легальные строки одного иероглифа отличаются друг от друга. Либо поле обязательно (пустая строка = нет различителя), либо ключ описан неверно | автор контракта (оркестратор) | S3.6 10.08, тайпчек | +| Ф-48 | **Перетаскивание границ столбцов снято по замеру, а не по вкусу.** Отраслевая норма таблиц данных (MUI X `flex`+`minWidth`+resize, AG Grid) — колонки тянут рукой; в react-aria это `ResizableTableContainer` + `ColumnResizer`, и оно работает (проверено сценарием: столбец растянут 194→274px). Но A/B-замер драга ПАНЕЛИ с открытым банком на dev-сервере: без ресайза p90 33 мс и 7 длинных кадров, с ресайзом — **p90 83 мс и 44 длинных кадра**: контейнер пересчитывает ширины на каждый кадр изменения своей ширины. Владелец дважды жаловался именно на драг, поэтому выбрана плавность: остались текучие доли (`fr`) с минимумами на столбец. Возвращать — когда банк станет поверхностью РЕДАКТИРОВАНИЯ (S5, там ширины важнее) либо когда у библиотеки появится способ не пересчитывать ширины на ресайз контейнера. ⚠ **База замера УСТАРЕЛА (10.08, четвёртый круг):** «без ресайза p90 33 мс» на текущем дереве не воспроизводится — драг держит **p90 17 мс, 1 длинный кадр из 84**, и A/B по блюру и `contain` числа не двигает. Значит и цену ресайза (83 мс) надо мерить заново от новой базы, а не наследовать: возвращая ресайз, начинать с замера, а не со ссылки на эту строку | S5 либо новая версия библиотеки | S3.6 10.08, A/B-замер; база пере-замерена 10.08 | +| Ф-49 | **Текст открытой главы не подписан ни на один сигнал изменения.** `keys.units` не инвалидируется нигде (`src/showcase/useRunStream.ts` — только notes/bank/book и общий resync); кадр `chapter` патчит лишь СПИСОК глав. Контракт при этом прямо предписывает обратное: `Unit` — «the live "something changed" signal arrives as an event; the text arrives with a read after the boundary» (`openapi.yaml:899-901`). Уточнение против первой формулировки: это не «устаревает на 15 секунд» — `staleTime` лишь разрешает рефетч, а триггеров у него три (фокус окна, реконнект, монтирование), и вкладки keep-alive не размонтируются. Читатель, не уводивший фокус из окна, нового текста НЕ УВИДИТ ВООБЩЕ. Инвалидации на `chapter` мало: если `units_done` останется единым счётчиком (открытый вопрос К-10 контракта), за всю черновую волну кадр `chapter` не придёт ни разу, а `target` на границе стадии уже наполнится — значит нужна и инвалидация открытых глав на смене `status` (те самые «stage boundaries and stops») | фронт, после ясности по К-10 | S3.6 10.08, ресёрч-агент + контракт | +| Ф-50 | **Кадр потока, пришедший до того как чтение легло в кэш, теряется молча — а высокая вода уже сдвинута.** `useRunStream.ts`: обработчики `progress`/`status`/`chapter` делают `list === undefined ? list : {...}`, то есть в пустой кэш не пишут, а `acceptFrame` поднял отметку ревизии ДО этого. Дальше `freshest(undefined, next)` принимает первый ответ независимо от ревизии — в кэш ложится снимок, сделанный ДО кадра, а кадр уже «применён». Окно открывается на каждом старте экрана и при каждом resync. Рядом второе того же класса: многостраничное чтение штампует «рваный» список ревизией ПОСЛЕДНЕЙ страницы (`queries.ts`), хотя контракт предупреждает, что ревизия во время пагинации движется (`openapi.yaml:658-661`) | фронт | S3.6 10.08, ресёрч-агент | +| Ф-51 | **`resync` сбрасывает один гард из двух.** `useRunStream.ts` на `resync_required` зовёт `invalidateQueries()` без ключа: данные при этом СОХРАНЯЮТСЯ, и гард `dropStaleReads` продолжает сравнивать пришедший снапшот со старым — а по контракту resync выдаётся именно после полной замены («the bank rebuilt from scratch, re-chunking replacing the chapters», `openapi.yaml:646-648`), где монотонность ревизии не обещана. На стриминговой стороне отметка честно сбрасывается, на стороне кэша — нет. Правильный примитив — `resetQueries`/`removeQueries`. Вторым слоем: тот же вызов без ключа бьёт по чужим скоупам (библиотека, usage, run-options, другие книги) | фронт | S3.6 10.08, ресёрч-агент | diff --git a/frontend/docs/FRONTEND_PLAN.md b/frontend/docs/FRONTEND_PLAN.md index 2c381ac3..31be332e 100644 --- a/frontend/docs/FRONTEND_PLAN.md +++ b/frontend/docs/FRONTEND_PLAN.md @@ -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/` diff --git a/frontend/docs/S37_SESSION_PROMPT.md b/frontend/docs/S37_SESSION_PROMPT.md new file mode 100644 index 00000000..54cabf6f --- /dev/null +++ b/frontend/docs/S37_SESSION_PROMPT.md @@ -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 не писать — его выдаёт оркестратор после приёмки этого пака. diff --git a/frontend/docs/frontend-PROGRESS.md b/frontend/docs/frontend-PROGRESS.md index c7c6ff58..28c9fd76 100644 --- a/frontend/docs/frontend-PROGRESS.md +++ b/frontend/docs/frontend-PROGRESS.md @@ -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` у подсказки поля** | подсказка в форме добавления книги была голым `

` | `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`. Ширины считались от «устоявшейся» ширины контейнера, но результат попадал в +коллекцию через ``, а библиотека кэширует отрисованные колонки по объекту столбца +(`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 **Замок открыт, и это проверено, а не принято на слово.** Контракт лежит в diff --git a/frontend/package.json b/frontend/package.json index ff5f1fef..87dc79c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/scripts/measure.py b/frontend/scripts/measure.py new file mode 100644 index 00000000..9496f379 --- /dev/null +++ b/frontend/scripts/measure.py @@ -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) diff --git a/frontend/scripts/scenes.mjs b/frontend/scripts/scenes.mjs new file mode 100644 index 00000000..8f5ef130 --- /dev/null +++ b/frontend/scripts/scenes.mjs @@ -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} 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 Promise>} */ +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все сценарии прошли'); diff --git a/frontend/scripts/shot.mjs b/frontend/scripts/shot.mjs index c165c764..fde8e7d9 100644 --- a/frontend/scripts/shot.mjs +++ b/frontend/scripts/shot.mjs @@ -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 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f086e62b..a723ca1c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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'; /** diff --git a/frontend/src/api/vocabulary.ts b/frontend/src/api/vocabulary.ts index ce2c09e9..9977ef96 100644 --- a/frontend/src/api/vocabulary.ts +++ b/frontend/src/api/vocabulary.ts @@ -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 { /** The values of version 0.x. Derived from the entries, so the two cannot drift. */ @@ -48,15 +51,18 @@ function vocabulary( */ export const bookStatus = vocabulary( { - 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( - { 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>( - { 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( + { + name: { label: 'имя' }, + place: { label: 'место' }, + title: { label: 'титул' }, + term: { label: 'термин' }, + nickname: { label: 'прозвище' }, + }, + { label: 'тип не определён' }, ); /** Provenance of a bank row. Narrowing only, same reason as `termKind`. */ diff --git a/frontend/src/mock/bank.ts b/frontend/src/mock/bank.ts index db5b4bdc..7f53c5d3 100644 --- a/frontend/src/mock/bank.ts +++ b/frontend/src/mock/bank.ts @@ -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`. diff --git a/frontend/src/mock/scale.ts b/frontend/src/mock/scale.ts index 8a5b8a12..66dce1c9 100644 --- a/frontend/src/mock/scale.ts +++ b/frontend/src/mock/scale.ts @@ -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, }; }); diff --git a/frontend/src/shell/Shell.module.css b/frontend/src/shell/Shell.module.css index d03006e2..238344f4 100644 --- a/frontend/src/shell/Shell.module.css +++ b/frontend/src/shell/Shell.module.css @@ -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); } diff --git a/frontend/src/shell/Shell.tsx b/frontend/src/shell/Shell.tsx index a205450d..09a889ec 100644 --- a/frontend/src/shell/Shell.tsx +++ b/frontend/src/shell/Shell.tsx @@ -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>({ 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 (

@@ -66,10 +81,17 @@ export function Shell({ title, actions, left, center, right, status }: Props) { > {!collapsed.left && ( <> - + {left} - + + + )} {/* Центр тянется долей, боковые — пикселями: на 1920 лишнюю ширину получает читалка, @@ -80,8 +102,17 @@ export function Shell({ title, actions, left, center, right, status }: Props) { {!collapsed.right && ( <> - - + + + + {right} @@ -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'), diff --git a/frontend/src/showcase/Context.module.css b/frontend/src/showcase/About.module.css similarity index 58% rename from frontend/src/showcase/Context.module.css rename to frontend/src/showcase/About.module.css index bceae743..5710d00d 100644 --- a/frontend/src/showcase/Context.module.css +++ b/frontend/src/showcase/About.module.css @@ -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); -} diff --git a/frontend/src/showcase/About.tsx b/frontend/src/showcase/About.tsx new file mode 100644 index 00000000..6911c06f --- /dev/null +++ b/frontend/src/showcase/About.tsx @@ -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 ( +
+ {fields.map(([name, value]) => ( +
+
{name}
+
+ {value} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/showcase/AddBook.module.css b/frontend/src/showcase/AddBook.module.css new file mode 100644 index 00000000..cc59f088 --- /dev/null +++ b/frontend/src/showcase/AddBook.module.css @@ -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); +} diff --git a/frontend/src/showcase/AddBook.tsx b/frontend/src/showcase/AddBook.tsx new file mode 100644 index 00000000..3795e9a1 --- /dev/null +++ b/frontend/src/showcase/AddBook.tsx @@ -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(null); + const [title, setTitle] = useState(''); + const input = useRef(null); + + const close = () => { + setFile(null); + setTitle(''); + onClose(); + }; + + return ( + + + + + } + > +
+
+

Файл книги

+ {/* Настоящий выбор файла, а не нарисованный: имя файла нужно живьём — из него берёт + название авто-разбор, и без реального имени эту ветку не проверить кадром. */} + setFile(event.target.files?.[0]?.name ?? null)} + /> + +
+ + + + {/* Место будущих пер-книжных настроек. Названо, но не нарисовано переключателями, + которых нет: кнопка без действия запрещена (Ф-7). */} +
+

Настройки книги

+

Пара языков, жанр и параметры запуска появятся здесь.

+
+
+
+ ); +} + +/** Авто-разбор названия из имени файла — тот самый второй источник, что и подписан в форме. */ +function parsedTitle(file: string): string { + return file + .replace(/\.[^.]+$/, '') + .replace(/[_-]+/g, ' ') + .trim(); +} diff --git a/frontend/src/showcase/Bank.module.css b/frontend/src/showcase/Bank.module.css new file mode 100644 index 00000000..a8282775 --- /dev/null +++ b/frontend/src/showcase/Bank.module.css @@ -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); +} diff --git a/frontend/src/showcase/Bank.tsx b/frontend/src/showcase/Bank.tsx new file mode 100644 index 00000000..7ab7e418 --- /dev/null +++ b/frontend/src/showcase/Bank.tsx @@ -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 | 'undecided'; + +/** Plural forms for the bank counter; Intl picks the category. */ +const terms: Record = { + 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('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[] = [ + { + 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 ( + <> + + {term.src} + + {window !== null && ( + + {window} + + )} + + ); + }, + }, + { + 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 === '' ? ( + не предложен + ) : ( + // Full text as a tooltip: a clipped cell is a dead end, and the translation is what is + // being checked here. + + {term.dst} + + ), + }, + ...(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) => ( + {termKind.describe(term.kind).label} + ), + }, + ] + : []), + { + 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 ?? '') === '' ? ( + + ) : ( + + {term.sense} + + ), + }, + ]; + + return ( +
+
+
+ + + {senseShown ? : } + +
+ setKind(id as KindFilter)} + /> +
+ + {/* ⚠ Everything the render closures take from outside the row belongs in `dependencies`, or + the collection cache serves cells drawn with the old value. */} + + + {/* 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. */} +

+ + {rows.length === bank.total + ? counted(bank.total, terms) + : `показано ${number(rows.length)} из ${number(bank.total)}`} + + {awaitingSignature === true && банк ждёт подписи} +

+ + ); +} + +/** `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 { + const counts: Record = { all: terms.length }; + for (const term of terms) { + const key = term.kind ?? 'undecided'; + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} diff --git a/frontend/src/showcase/Blank.module.css b/frontend/src/showcase/Blank.module.css index def8659a..62c49104 100644 --- a/frontend/src/showcase/Blank.module.css +++ b/frontend/src/showcase/Blank.module.css @@ -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; + } +} diff --git a/frontend/src/showcase/Blank.tsx b/frontend/src/showcase/Blank.tsx index acf8bbf2..9d407c12 100644 --- a/frontend/src/showcase/Blank.tsx +++ b/frontend/src/showcase/Blank.tsx @@ -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 (
-
+

{title}

{description}

diff --git a/frontend/src/showcase/Context.tsx b/frontend/src/showcase/Context.tsx index 647e99d2..0f16cc0c 100644 --- a/frontend/src/showcase/Context.tsx +++ b/frontend/src/showcase/Context.tsx @@ -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>[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>[0]['query']; - bank: Parameters>[0]['query']; - onSignBank: () => void; + bank: Parameters>[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 ( - {(detail) => } + {(detail) => } ), }, @@ -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) => ( -
- {ofChapter(data.notes, chapterId).map((note, index) => ( - - ))} -
+ )} ), @@ -103,92 +95,18 @@ export function Context({ book, chapterId, libraryEmpty, notes, bank, onSignBank description: 'Термины книги появятся после первого прохода перевода.', }} > - {() => ( - <> - - - + {(data) => ( + )} ), }, ]} - // 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={ - - } /> ); } - -// 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. */} - {term.src} - {/* 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. */} - - {term.dst} - - - ), - }; -} - -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 ( -
- {fields.map(([name, value]) => ( -
-
{name}
-
{value}
-
- ))} -
- ); -} diff --git a/frontend/src/showcase/Document.module.css b/frontend/src/showcase/Document.module.css new file mode 100644 index 00000000..87aac176 --- /dev/null +++ b/frontend/src/showcase/Document.module.css @@ -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); +} diff --git a/frontend/src/showcase/Document.tsx b/frontend/src/showcase/Document.tsx new file mode 100644 index 00000000..c6a62b97 --- /dev/null +++ b/frontend/src/showcase/Document.tsx @@ -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 ( +
{ + // Выделение текста — то самое «содержательное взаимодействие»: читатель сравнил или + // скопировал кусок, значит глава нужна ему дальше. Простая прокрутка не пинит. + if ((document.getSelection()?.toString().length ?? 0) > 0) onEngage(); + }} + > + {draft && ( +

+ + черновой вариант, может быть перегенерирован +

+ )} + +
+ ); +} diff --git a/frontend/src/showcase/Goto.module.css b/frontend/src/showcase/Goto.module.css new file mode 100644 index 00000000..11769dff --- /dev/null +++ b/frontend/src/showcase/Goto.module.css @@ -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); +} diff --git a/frontend/src/showcase/Goto.tsx b/frontend/src/showcase/Goto.tsx new file mode 100644 index 00000000..bc689902 --- /dev/null +++ b/frontend/src/showcase/Goto.tsx @@ -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 ( + + {/* Escape перехватывается ДО поля: примитив поиска первым нажатием очищает свою строку + и наружу его не пускает, поэтому окно закрывалось только со второго раза. */} +
{ + if (event.key === 'Escape') close(); + }} + > + +
+ { + const [what, ...rest] = id.split(':'); + const value = rest.join(':'); + if (what === 'chapter') onOpenChapter(value); + else onOpenTerm(terms.find((term) => term.id === value)?.src ?? ''); + close(); + }} + /> +
+ {/* Обрезка списка названа вслух: молчаливое «показаны не все» читается как «это всё». */} + {truncated &&

Показаны первые {LIMIT} совпадений.

} +
+
+ ); +} + +const row = (id: string, label: string, what: string): ListRow => ({ + id, + text: label, + content: ( + <> + {label} + {what} + + ), +}); diff --git a/frontend/src/showcase/Library.module.css b/frontend/src/showcase/Library.module.css index a805711a..50d68909 100644 --- a/frontend/src/showcase/Library.module.css +++ b/frontend/src/showcase/Library.module.css @@ -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 { diff --git a/frontend/src/showcase/Library.tsx b/frontend/src/showcase/Library.tsx index 005094c0..4229e49c 100644 --- a/frontend/src/showcase/Library.tsx +++ b/frontend/src/showcase/Library.tsx @@ -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>[0]['query']; chapters: Parameters>[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 ( 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} /> )} ), }, - { - id: 'search', - label: 'Поиск', - content: ( - <> - -

Поиск по тексту книги появится вместе с читалкой.

- - ), - }, ]} - // The only way into settings in the whole interface (prompt §3.12). - footer={ - - } /> ); } @@ -100,8 +83,12 @@ function Books({ chapters, openBookId, selectedId, - onSelect, -}: Pick & { books: Book[] }) { + onOpen, + onPin, + onPreload, +}: Pick & { + 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: , + // The state tone lives ON the book glyph, not on a dot beside the title (remark 21). + icon: ( + + + + ), + // Бейдж стоит в СВОЁМ месте фиксированной ширины, а название усекается: пересечься им + // теперь нечем (замечание 23). trailing: ( - - {state.tone && } + {state.label} ), @@ -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. */} - + = 1 || undefined} /> переведено {chapter.units_done} из {chapter.units_total} diff --git a/frontend/src/showcase/Loaded.tsx b/frontend/src/showcase/Loaded.tsx index af8c822b..6a35f228 100644 --- a/frontend/src/showcase/Loaded.tsx +++ b/frontend/src/showcase/Loaded.tsx @@ -39,7 +39,7 @@ const nothingChosen = { export function Loaded({ query, waiting, isEmpty, empty, idle, children }: Props) { if (query.isPending) { if (query.fetchStatus === 'idle') return ; - return ; + return ; } // 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 "не удалось diff --git a/frontend/src/showcase/Notes.module.css b/frontend/src/showcase/Notes.module.css new file mode 100644 index 00000000..20208e40 --- /dev/null +++ b/frontend/src/showcase/Notes.module.css @@ -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; +} diff --git a/frontend/src/showcase/Notes.tsx b/frontend/src/showcase/Notes.tsx new file mode 100644 index 00000000..da95bfec --- /dev/null +++ b/frontend/src/showcase/Notes.tsx @@ -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: ( +
+ {note.message} + {where} +
+ ), + }; + }); + + return ( + { + const chapterId = notes[Number(id)]?.chapter_id; + if (chapterId !== undefined) onOpenChapter(chapterId); + }} + /> + ); +} diff --git a/frontend/src/showcase/Reader.module.css b/frontend/src/showcase/Reader.module.css index f7cc5717..266ae3c1 100644 --- a/frontend/src/showcase/Reader.module.css +++ b/frontend/src/showcase/Reader.module.css @@ -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 { diff --git a/frontend/src/showcase/Reader.tsx b/frontend/src/showcase/Reader.tsx index 972aa9d7..5edce302 100644 --- a/frontend/src/showcase/Reader.tsx +++ b/frontend/src/showcase/Reader.tsx @@ -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) => ( -
- {data.units.map((unit) => { - const columns = ( -
-
- {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. +
+
+ {data.units.map((unit) => { + const columns = ( +
+
+ {unit.source} +
+
+ {unit.target === undefined || unit.target === '' ? ( + перевод не получен + ) : ( + unit.target + )} +
-
- {unit.target === undefined || unit.target === '' ? ( - перевод не получен - ) : ( - unit.target - )} -
-
- ); - return unit.note ? ( - - {columns} - - ) : ( -
- {columns} -
- ); - })} + ); + return unit.note ? ( + + {columns} + + ) : ( +
+ {columns} +
+ ); + })} +
)} diff --git a/frontend/src/showcase/Settings.module.css b/frontend/src/showcase/Settings.module.css new file mode 100644 index 00000000..d15f9e34 --- /dev/null +++ b/frontend/src/showcase/Settings.module.css @@ -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); +} diff --git a/frontend/src/showcase/Settings.tsx b/frontend/src/showcase/Settings.tsx new file mode 100644 index 00000000..05e1c7bd --- /dev/null +++ b/frontend/src/showcase/Settings.tsx @@ -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 ( + +
+ {sections.map(([name, about]) => ( +
+
{name}
+
{about}
+
+ ))} +
+

Разделы наполняются отдельным пакетом работ.

+
+ ); +} + +const sections: [string, string][] = [ + ['Профиль и доступ', 'Учётная запись, вход, завершение сеансов.'], + ['Язык интерфейса', 'Сейчас интерфейс только русский.'], + ['Светлая тема', 'Сейчас доступна только тёмная.'], + [ + 'Перевод по умолчанию', + 'Что подставлять в форму запуска: пара языков, потолок, остановка на подписи.', + ], + ['Использование', 'Сколько израсходовано и что будет при исчерпании лимита.'], +]; diff --git a/frontend/src/showcase/Showcase.tsx b/frontend/src/showcase/Showcase.tsx index 44cf9aaf..9e47406e 100644 --- a/frontend/src/showcase/Showcase.tsx +++ b/frontend/src/showcase/Showcase.tsx @@ -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(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 | 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 ? ( - - ) : ( - setOpened(closeDocument(documents, id)), + content: ( + 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 ( - { - // 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'); - }} - > - - - } - left={ - { - if (rows.some((chapter) => chapter.id === id)) open(id); - }} - onAddBook={() => open('add-book')} - onOpenSettings={() => open('settings')} - /> - } - center={ - { - 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 ? ( - - ) : failedLibrary ? ( - - ) : enabled ? ( - - ) : ( - - ) - } - /> - } - right={ - open('bank')} - /> - } - status={ - - } - /> + <> + + + {/* Единственный вход в настройки, и он в правом верхнем углу, как у Fleet + (замечания 11 и 19); из низа левой панели вход убран. */} + + + } + left={ + { + if (positionOf(id) >= 0) open(id); + }} + onPin={(id) => { + if (positionOf(id) >= 0) pin(id); + }} + onPreload={preload} + onAddBook={() => setOverlay('add-book')} + /> + } + center={ + setOpened(selectDocument(documents, id))} + onPin={pin} + keepAlive + empty={} + /> + } + right={ + + } + status={ + + } + /> + setOverlay(null)} /> + setOverlay(null)} /> + 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: 'Добавьте книгу — её разделы откроются здесь вкладками.', + }; } diff --git a/frontend/src/showcase/Status.module.css b/frontend/src/showcase/Status.module.css index 4c93ca4d..d53cd2db 100644 --- a/frontend/src/showcase/Status.module.css +++ b/frontend/src/showcase/Status.module.css @@ -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. */ diff --git a/frontend/src/showcase/documents.test.ts b/frontend/src/showcase/documents.test.ts new file mode 100644 index 00000000..10e29372 --- /dev/null +++ b/frontend/src/showcase/documents.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/showcase/documents.ts b/frontend/src/showcase/documents.ts new file mode 100644 index 00000000..be686f0c --- /dev/null +++ b/frontend/src/showcase/documents.ts @@ -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 }; +} diff --git a/frontend/src/showcase/format.ts b/frontend/src/showcase/format.ts index a5ed0593..ff9e1beb 100644 --- a/frontend/src/showcase/format.ts +++ b/frontend/src/showcase/format.ts @@ -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): 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[0]) export const statusOf = (status: Parameters[0]) => bookStatus.describe(status); +/** + * Черновой, пока прогон не довёл книгу до `ready`: и перевод, и нарезка законно пересобираются. + * Неизвестное состояние — тоже черновик (безопасная сторона); отсутствие книги пометки не даёт. + */ +export const isDraft = (status: BookStatus | null | undefined) => + status !== undefined && status !== 'ready'; + /** * What a chapter is called in the tree. * diff --git a/frontend/src/showcase/rooms.ts b/frontend/src/showcase/rooms.ts deleted file mode 100644 index 4abf044a..00000000 --- a/frontend/src/showcase/rooms.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Кроме глав центр открывает ровно три документа, и все три ратифицированы: подписной экран -// банка (решение владельца 02.08), добавление книги (§3.1) и настройки (§3.12). Экраны за ними -// строят следующие сессии — витрина показывает, что действие оболочки настоящее, а не нарисованное. -export const rooms: Record = { - bank: { - title: 'Банк памяти', - description: - 'Подпись сотен терминов подряд: колонки, клавиатура вместо мыши, массовые действия. ' + - 'В правой панели остаётся справочный вид — «что это за термин».', - }, - 'add-book': { - title: 'Добавить книгу', - description: - 'Перенос файла книги, языки пары и жанр, затем видимое подтверждение загрузки и разбор ' + - 'на разделы.', - }, - settings: { - title: 'Настройки', - description: - 'Профиль и доступ, конфигурация моделей, параметры перевода по умолчанию. Всё техническое ' + - 'живёт здесь и только здесь.', - }, -}; diff --git a/frontend/src/showcase/terms.ts b/frontend/src/showcase/terms.ts new file mode 100644 index 00000000..cc4929da --- /dev/null +++ b/frontend/src/showcase/terms.ts @@ -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); +}; diff --git a/frontend/src/showcase/useScreenshotFlag.ts b/frontend/src/showcase/useScreenshotFlag.ts new file mode 100644 index 00000000..78594cc0 --- /dev/null +++ b/frontend/src/showcase/useScreenshotFlag.ts @@ -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]); +} diff --git a/frontend/src/tokens/measures.ts b/frontend/src/tokens/measures.ts index 6c52e895..80711338 100644 --- a/frontend/src/tokens/measures.ts +++ b/frontend/src/tokens/measures.ts @@ -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`; diff --git a/frontend/src/tokens/reset.css b/frontend/src/tokens/reset.css index 104655bb..22ec7f8e 100644 --- a/frontend/src/tokens/reset.css +++ b/frontend/src/tokens/reset.css @@ -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 толстые и светлые — на трёх панелях и в читалке diff --git a/frontend/src/tokens/tokens.css b/frontend/src/tokens/tokens.css index 06d99d8c..e8383295 100644 --- a/frontend/src/tokens/tokens.css +++ b/frontend/src/tokens/tokens.css @@ -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; } diff --git a/frontend/src/tokens/tokens.test.ts b/frontend/src/tokens/tokens.test.ts index e3b1d972..604055ad 100644 --- a/frontend/src/tokens/tokens.test.ts +++ b/frontend/src/tokens/tokens.test.ts @@ -21,43 +21,76 @@ const sources = import.meta.glob('../**/*.{ts,tsx}', { // Значение разошлось с таблицей — это либо новый замер, либо ошибка; тихо править нельзя. const measured: Record = { '--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 = { + '--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(); + 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']; diff --git a/frontend/src/ui/Button.module.css b/frontend/src/ui/Button.module.css index 6db58825..9a622bbe 100644 --- a/frontend/src/ui/Button.module.css +++ b/frontend/src/ui/Button.module.css @@ -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); diff --git a/frontend/src/ui/Button.tsx b/frontend/src/ui/Button.tsx index 9751deb5..d1a789c6 100644 --- a/frontend/src/ui/Button.tsx +++ b/frontend/src/ui/Button.tsx @@ -3,8 +3,12 @@ import { Button as AriaButton, type ButtonProps } from 'react-aria-components'; import styles from './Button.module.css'; interface Props extends Omit { - /** Строка — действие во всю ширину (подвал панели); иконка — квадрат в полосе или ряду вкладок. */ - look?: 'row' | 'icon'; + /** + * Строка — действие во всю ширину (подвал панели); иконка — квадрат в полосе или ряду + * вкладок; действие — кнопка формы, приподнятая поверхность вместо цвета (промт §4.2: + * зелёных «сохранить» и красных «удалить» не бывает). + */ + look?: 'row' | 'icon' | 'action' | 'primary'; } // Вид задаётся атрибутом, а не набором классов: так имя класса остаётся одно и опечатка в нём diff --git a/frontend/src/ui/Callout.module.css b/frontend/src/ui/Callout.module.css index 8c5cba01..3d7500ba 100644 --- a/frontend/src/ui/Callout.module.css +++ b/frontend/src/ui/Callout.module.css @@ -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); +} diff --git a/frontend/src/ui/Callout.tsx b/frontend/src/ui/Callout.tsx index 2f976641..d73eb5d1 100644 --- a/frontend/src/ui/Callout.tsx +++ b/frontend/src/ui/Callout.tsx @@ -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 (
-

{caption}

+

+ + {caption} +

{children}
); diff --git a/frontend/src/ui/Chips.module.css b/frontend/src/ui/Chips.module.css new file mode 100644 index 00000000..c4b4d2c3 --- /dev/null +++ b/frontend/src/ui/Chips.module.css @@ -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); +} diff --git a/frontend/src/ui/Chips.tsx b/frontend/src/ui/Chips.tsx new file mode 100644 index 00000000..d3b4b828 --- /dev/null +++ b/frontend/src/ui/Chips.tsx @@ -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 ( + { + for (const key of keys) onSelect(String(key)); + }} + > + {items.map((item) => ( + + {item.label} + {item.count !== undefined && {item.count}} + + ))} + + ); +} diff --git a/frontend/src/ui/FilterField.module.css b/frontend/src/ui/FilterField.module.css index a71f4cc7..059738da 100644 --- a/frontend/src/ui/FilterField.module.css +++ b/frontend/src/ui/FilterField.module.css @@ -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 { diff --git a/frontend/src/ui/FilterField.tsx b/frontend/src/ui/FilterField.tsx index 534a3911..dd062550 100644 --- a/frontend/src/ui/FilterField.tsx +++ b/frontend/src/ui/FilterField.tsx @@ -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 ( - + ); } diff --git a/frontend/src/ui/List.module.css b/frontend/src/ui/List.module.css index d834e073..55b9315d 100644 --- a/frontend/src/ui/List.module.css +++ b/frontend/src/ui/List.module.css @@ -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); } diff --git a/frontend/src/ui/List.tsx b/frontend/src/ui/List.tsx index 067a3310..676b70a4 100644 --- a/frontend/src/ui/List.tsx +++ b/frontend/src/ui/List.tsx @@ -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

{empty}

; + return ( - +

{empty}

} + selectionMode={onSelect ? 'single' : 'none'} + selectedKeys={[]} + onSelectionChange={(keys) => { + if (keys !== 'all') for (const key of keys) onSelect?.(String(key)); + }} > {(item) => ( diff --git a/frontend/src/ui/Locale.tsx b/frontend/src/ui/Locale.tsx index e4db7be0..395b305b 100644 --- a/frontend/src/ui/Locale.tsx +++ b/frontend/src/ui/Locale.tsx @@ -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 {children}; + return {children}; } diff --git a/frontend/src/ui/Modal.module.css b/frontend/src/ui/Modal.module.css new file mode 100644 index 00000000..2afe41d9 --- /dev/null +++ b/frontend/src/ui/Modal.module.css @@ -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); +} diff --git a/frontend/src/ui/Modal.tsx b/frontend/src/ui/Modal.tsx new file mode 100644 index 00000000..bb1104ce --- /dev/null +++ b/frontend/src/ui/Modal.tsx @@ -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 ( + { + if (!open) onClose(); + }} + isDismissable + > + + +
+ + {title} + + +
+
{children}
+ {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/frontend/src/ui/Table.module.css b/frontend/src/ui/Table.module.css new file mode 100644 index 00000000..8869bbd4 --- /dev/null +++ b/frontend/src/ui/Table.module.css @@ -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); +} diff --git a/frontend/src/ui/Table.tsx b/frontend/src/ui/Table.tsx new file mode 100644 index 00000000..3fe9396a --- /dev/null +++ b/frontend/src/ui/Table.tsx @@ -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 { + 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 { + label: string; + columns: TableColumn[]; + 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({ + label, + columns, + items, + sample, + empty, + dependencies = [], + selectedId, + onSelect, +}: Props) { + const frame = useRef(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 ( +
+ + { + if (keys !== 'all') for (const key of keys) onSelect?.(String(key)); + }} + > + + {(column) => ( + + {column.name} + + )} + + + {(item) => ( + + {(column) => ( + + {column.render(item)} + + )} + + )} + + + + {isEmpty &&

{empty}

} +
+ ); +} + +/** + * 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( + column: TableColumn, + size: Fitted | undefined, + stretch: boolean, +): Pick { + // 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( + frame: RefObject, + columns: TableColumn[], + /** 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 { + const [sizes, setSizes] = useState>({}); + // 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 = {}; + 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(lined: Lined[], sample: Item[]): Promise { + if (document.fonts === undefined) return; + const wanted = new Map>(); + const want = ({ font }: Face, text: string) => { + const glyphs = wanted.get(font) ?? new Set(); + 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 { + column: TableColumn; + 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(node: HTMLElement | null, columns: TableColumn[]): Lined[] | 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[] { + const found = new Map(); + 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(); + +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, b: Record) => { + 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) + ); +}; diff --git a/frontend/src/ui/Tabs.module.css b/frontend/src/ui/Tabs.module.css index 64e02610..8a604c79 100644 --- a/frontend/src/ui/Tabs.module.css +++ b/frontend/src/ui/Tabs.module.css @@ -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; +} diff --git a/frontend/src/ui/Tabs.tsx b/frontend/src/ui/Tabs.tsx index 5e106ce1..44dad15b 100644 --- a/frontend/src/ui/Tabs.tsx +++ b/frontend/src/ui/Tabs.tsx @@ -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 ( 0 && ( {(item) => ( - + onPin?.(item.id)} + > {item.label} {/* Крестик — не кнопка, и это не небрежность: у роли tab дети презентационные (ARIA), поэтому вложенный фокусируемый элемент выпадает из дерева доступности, @@ -80,14 +105,16 @@ export function Tabs({ label, items, selectedId, onSelect, look = 'tool', add, e )} {add && ( - + + + )}
{items.length === 0 &&
{empty}
} {items.map((item) => ( - + {item.content} ))} diff --git a/frontend/src/ui/TextField.module.css b/frontend/src/ui/TextField.module.css new file mode 100644 index 00000000..04ef2ccd --- /dev/null +++ b/frontend/src/ui/TextField.module.css @@ -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); +} diff --git a/frontend/src/ui/TextField.tsx b/frontend/src/ui/TextField.tsx new file mode 100644 index 00000000..79e43477 --- /dev/null +++ b/frontend/src/ui/TextField.tsx @@ -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 ( + + + + {/* slot="description" — не украшение: библиотека вешает `aria-describedby`, и без него + подсказку не слышит тот, кому она нужнее всех. */} + {hint && ( + + {hint} + + )} + + ); +} diff --git a/frontend/src/ui/Toggle.module.css b/frontend/src/ui/Toggle.module.css new file mode 100644 index 00000000..d618efbe --- /dev/null +++ b/frontend/src/ui/Toggle.module.css @@ -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; +} diff --git a/frontend/src/ui/Toggle.tsx b/frontend/src/ui/Toggle.tsx new file mode 100644 index 00000000..e63195cf --- /dev/null +++ b/frontend/src/ui/Toggle.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/ui/Tree.module.css b/frontend/src/ui/Tree.module.css index f5084027..5d41ad24 100644 --- a/frontend/src/ui/Tree.module.css +++ b/frontend/src/ui/Tree.module.css @@ -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); diff --git a/frontend/src/ui/Tree.tsx b/frontend/src/ui/Tree.tsx index 2648e1a4..d7ca8bfd 100644 --- a/frontend/src/ui/Tree.tsx +++ b/frontend/src/ui/Tree.tsx @@ -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 ( - - { - if (keys !== 'all') for (const key of keys) onSelect(String(key)); - }} - defaultExpandedKeys={defaultExpandedIds} - > - {renderNode} - - - ); -} - -// Отступ уровня даёт сама библиотека через data-level, поэтому вложенность не считается руками. -function renderNode(node: TreeNode) { - return ( +export function Tree({ + label, + items, + selectedId, + onSelect, + onActivate, + onPreload, + defaultExpandedIds, +}: Props) { + // Отступ уровня даёт сама библиотека через data-level, поэтому вложенность не считается руками. + const renderNode = (node: TreeNode) => ( {({ hasChildItems, isExpanded }) => ( @@ -79,4 +87,53 @@ function renderNode(node: TreeNode) { {node.children && {renderNode}} ); + + return ( + // ⚠ Двойной клик и Enter ловит ОБЁРТКА, а не строка, и это не стилистика: строки живут + // в КОЛЛЕКЦИИ библиотеки и пересобираются только при смене `items`, поэтому замыкание + // внутри строки застывает на первом рендере (поймано сценарием, а не рассуждением). + // Строка при этом берётся из САМОГО события — по `data-key`, который библиотека кладёт + // на строку: брать «текущий выбор» нельзя, двойной клик по книге или по пустому месту + // выбор не двигает и закрепил бы чужую вкладку. + // Enter ловится в фазе ПЕРЕХВАТА: `usePress` библиотеки останавливает всплытие + // синтетического keydown, и обычный обработчик на обёртке до него не доходит. +
{ + 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); + }} + > + + { + if (keys !== 'all') for (const key of keys) onSelect(String(key)); + }} + defaultExpandedKeys={defaultExpandedIds} + > + {renderNode} + + +
+ ); }