## Context `main` d38fac8: веб-приложение хаба (`web/`, React 19 + TS + Vite, TanStack Query, React Router) под `/app/`, спека `openspec/specs/web-app/`, хаб раздаёт сборку (`h_app` в `api.py`). Отложенные находки change `web-app` собраны в jira-hub#29 (46 пунктов, маркеры сохраняются из PR #27). Устройство, на которое опираются правки: - `api/client.ts` — единственный HTTP-клиент: `call()` берёт токен через `getToken()`, 401 → `AuthError`, сеть и 5xx → `HubDownError`, прочие 4xx → `ApiError {status, error, detail}`. Токен очищается `clearToken("bad")` (из `useBoardSource.report` и глобального обработчика ошибок `queryClient`), `AuthGate` показывает `LoginScreen`. - `board/useBoardSource.ts` — источник доски: `source` (hub | file), `event` (одна строка состояния), `active` (опрашиваемый пакет); подхват незавершённого пакета `viewer` по каждому `board.dataUpdatedAt` через `GET /batches`. Состояние локально для `BoardPage` и пропадает при уходе со страницы; снимок хаба — в кэше TanStack Query, раскладка и фильтры — в `localStorage`. - `GET /batches` отдаёт пакеты без команд (`queue.list_batches`); `GET /batches/{id}` — с `commands[]` (`type`). - `issues/IssuesPage.tsx` — фильтры в query-строке, варианты — из `filterOptions(board, settings)`. - `index.html` подключает IBM Plex Sans/Mono с fonts.googleapis.com; `npm audit` — 1 high в `source-map-js@1.2.1` (транзитивная dev-зависимость), исправление — 1.2.2 (2026-09-30). ## Goals / Non-Goals **Goals:** закрыть jira-hub#29 целиком: каждый пункт — `fixed ` или `rejected <причина>`; поведение доски на тех же данных совпадает с текущим, кроме пунктов группы A; `make check` и e2e зелёные, e2e устойчив к повторам. **Non-Goals:** см. proposal.md. ## Decisions 1. **Прокрутка доски (M-3).** При размонтировании `BoardPage` запоминает `window.scrollX/scrollY` (и `scrollLeft` горизонтальной обёртки холста, если она прокручивается сама) в переменной модуля; при монтировании, после первой отрисовки холста с данными, восстанавливает. Перезагрузка страницы открывает доску сверху, как сейчас. Состояние источника не поднимается выше страницы — rejected часть M-3 (решение владельца). 2. **Строка состояния при идущем пакете (T10.3).** `reload()` и `loadFile()` не сбрасывают событие `batch`, пока `active !== null`; сообщения о файле (`file-error`, `json-applied`) по-прежнему заменяют его (одно событие, M-5 #20), а следующий ответ опроса возвращает строку пакета. 3. **Ошибка хаба при снимке из файла (T10.4).** Событие `hub-down` получает признак `fromFile`; текст — «хаб не отвечает — на доске снимок из файла». Он ставится, когда снимок хаба не загрузился при показанном файле (перечитывание после завершения пакета) и когда JSON-снимок загружен без данных хаба; `hasData` в последнем случае — есть ли в модели задачи. «Перечитать» по-прежнему возвращает источник «хаб». 4. **Подхват пакета (T10.6, T10.1).** Кандидат из `GET /batches` (автор `viewer`, `queued`/`running`) проверяется запросом `GET /batches/{id}`: подхватывается, только если первая команда — `refresh`. Подхват не запускается по обновлению снимка, которое вызвал сам `finish()` (флаг «снимок после своего пакета»), — без лишнего `GET /batches` (T10.1). 5. **Токен в ошибке (T3.5).** `call()` кладёт в `AuthError` токен, с которым ушёл запрос; `clearToken` вызывается только если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется. 6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «хаб отверг запрос: »; `HubDownError` — прежний текст «хаб не отвечает». 7. **Фильтр вне вариантов (T13.1).** Если значение фильтра из адреса не найдено в `opts[name]`, оно добавляется первым вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке. 8. **Карточка задачи (O-10, O-11).** Ссылки страниц задач — `color: var(--accent)` из темы (как в списке); пустой `commands` — строка «команд по задаче не было» вместо пустой таблицы. 9. **NUL в пути (T1.1).** `h_app` отвечает 404 `not_found`, если в декодированном пути есть `\x00` или разрешение пути падает с `ValueError`/`OSError`; остальное поведение `h_app` не меняется. 10. **Шрифты (T2.2).** `@fontsource/ibm-plex-sans` (400, 500, 600) и `@fontsource/ibm-plex-mono` (500), импорт CSS в `main.tsx`; ссылка на Google Fonts из `index.html` удаляется. Файлы `woff2` попадают в `assets/`, хаб уже отдаёт `font/woff2`. Версии — точные, `npm install --before=2026-10-01 --save-exact`. 11. **source-map-js (O-1).** `overrides: {"source-map-js": "1.2.2"}` в `package.json`, проверка `npm audit` (0 high) и сборка; если 1.2.2 не закрывает уязвимость — O-1 rejected с текстом аудита. 12. **Эталоны (T5.1).** `layout.clamp` — число строк заголовка при ручной высоте карточки; «unset» — у карточек с автоматической высотой. В `scenarios.json` добавляется сценарий `saved-short` с сохранённой раскладкой четырёх карточек разной высоты (120, 136, 200, 320), эталоны перегенерируются `npm run golden` из `links.html@3931a88`; прежние сценарии и фикстура `hub-basic.board.json` не меняются. 13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа в `web/e2e/hub.ts` и `playwright.config.ts` берёт её же; `scripts/test-hub.sh` перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e проходит 10 раз подряд. 14. **Состав плана.** 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель, хранилище, эталоны; окружение и e2e; документация. Исполнение — SDD-контроллер (sonnet), задачи с готовым кодом — haiku, интеграция — sonnet, финальное ревью ветки — opus. ## Пункты #29 и их судьба | Маркер | Что делаем | Задача плана | |---|---|---| | T1.1 | 404 на NUL в пути (решение 9) | хаб | | T1.4 | тест Content-Type для svg/png/ico/woff2/map | хаб | | T16.1 | убрать дубль `import urllib.*` | хаб | | T3.2 | 4xx на входе — текст ошибки хаба (решение 6) | клиент и вход | | T3.3 | содержательный тест 401 и тесты `queryClient`/`queries` | клиент и вход | | T3.4 | `token.test` проверяет `removeItem` при закрытом хранилище | клиент и вход | | T3.5 | токен в `AuthError` (решение 5) | клиент и вход | | T3.6 | интеграционный тест AuthError → LoginScreen | клиент и вход | | T10.1 | без лишнего `GET /batches` (решение 4) | источник доски | | T10.3 | строка пакета не стирается (решение 2) | источник доски | | T10.4 | «на доске снимок из файла» (решение 3) | источник доски | | T10.5 | тест «поздний ответ» проверяет, что ответ хаба пришёл после файла | источник доски | | T10.6 | подхват только `refresh` (решение 4) | источник доски | | M-3 | прокрутка (решение 1); хранение источника — rejected | холст и страница доски | | T11.1 | стабильные пропсы `Card`, `memo` срабатывает | холст и страница доски | | T11.2 | отмена `requestAnimationFrame` в `DatesBar` | холст и страница доски | | T11.3 | тест перетаскивания дорожки | холст и страница доски | | T11.4 | точные проверки в тестах I-2/I-3 | холст и страница доски | | T12.1 | `onOpen()` вне updater `setOpen` | холст и страница доски | | T12.2 | `writeFlag`/`writeHidden` вне updater | холст и страница доски | | T12.3 | зависимости `useLayoutEffect`, сброс `--head-h` | холст и страница доски | | T12.4 | MultiSelect: `role="group"` с флажками вместо `listbox` без `option` | холст и страница доски | | T13.1 | значение фильтра вне вариантов (решение 7) | задачи и карточка | | O-10 | ссылки цветом темы (решение 8) | задачи и карточка | | O-11 | «команд по задаче не было» (решение 8) | задачи и карточка | | T14.2 | ожидания через `waitFor`/`expect.poll` вместо пауз 20 мс | задачи и карточка | | T14.3 | уникальный id `datalist` (`useId`) | задачи и карточка | | T2.1 | `canon.ts` — сравнение по кодам символов | модель, хранилище, эталоны | | T5.1 | сценарий `saved-short` для `layout.clamp` (решение 12) | модель, хранилище, эталоны | | T6.3 | прямые тесты `util.ts` | модель, хранилище, эталоны | | T7.1 | `initialWho` считает `whoItems` один раз | модель, хранилище, эталоны | | T9.1 | `colWidths()` пропускает только конечные числа | модель, хранилище, эталоны | | T9.3 | тесты `toggleLane`/`moveLane`/`resetLayout`/`exportFileName`/`mode=all`, закрытое хранилище | модель, хранилище, эталоны | | T9.4 | порядок импортов `fromJson.ts` | модель, хранилище, эталоны | | T2.2 | шрифты из `@fontsource` (решение 10) | окружение и e2e | | O-1 | `overrides` source-map-js (решение 11) | окружение и e2e | | T4.1 | тест «Задачи и обратно» с обратным переходом | окружение и e2e | | T4.2 | тест ссылок страницы «не найдено» | окружение и e2e | | T4.3 | убрать `App.Layout`, тест берёт `Layout` | окружение и e2e | | T4.4 | один импорт из `@testing-library/react` | окружение и e2e | | T4.5 | очистка атрибутов в `theme.test` | окружение и e2e | | T15.1 | высота карточек в geometry сверяется с эталоном, не с формулой | окружение и e2e | | T15.2 | растяжка через `expect.poll`, без литерала 240 | окружение и e2e | | T15.4 | порт — одна константа (решение 13) | окружение и e2e | | T15.6 | проверка свободного порта (решение 13) | окружение и e2e | | M-5 | rejected: хаб запускается из checkout, wheel не используется | — | ## Risks / Trade-offs - Перегенерация эталонов (T5.1) может показать расхождение порта с `links.html` в `layout.clamp` → это дефект порта, чинится в коде, эталон не подгоняется. - Стабилизация пропсов `Card` (T11.1) затрагивает перетаскивание → e2e board/geometry обязательны в задаче. - Восстановление прокрутки до отрисовки высот даст прыжок → восстанавливать после первой отрисовки с данными. ## Decisions made without discussion Нумерация R-n — заново в пределах этой фичи. - **R-1 (решение 1), 15.10.2026.** Прокрутка хранится в переменной модуля, не в `sessionStorage`: переживает переходы внутри вкладки, но не перезагрузку. Обоснование: владелец согласился на «помнить, пока открыта вкладка» в смысле переходов; перезагрузка — новая сессия доски. Цена ошибки: если нужна и после F5 — заменить на `sessionStorage`, одна строка. - **R-2 (решение 4), 15.10.2026.** Тип пакета определяется дополнительным `GET /batches/{id}` по кандидату, а не изменением `GET /batches` в хабе. Обоснование: API хаба в этом change не меняется (Non-Goals web-app). Цена: один лишний запрос при найденном кандидате. - **R-3 (решение 5), 15.10.2026.** Запоздавший 401 со старым токеном молча игнорируется. Обоснование: новый токен уже проверен `GET /settings`. Цена: если новый токен тоже станет неверным, экран входа появится на следующем запросе. - **R-4 (решение 7), 15.10.2026.** Значение фильтра вне вариантов подписывается «(нет на доске)». Цена — текст подписи. - **R-5 (решение 11), 15.10.2026.** O-1 закрывается `overrides`, а не обновлением прямых зависимостей. Обоснование: уязвимость только в транзитивной dev-зависимости; обновление vite/vitest шире задачи. Цена: `overrides` надо снять, когда прямые зависимости подтянут 1.2.2 сами. - **R-6 (решение 12), 15.10.2026.** T5.1 закрывается новым сценарием `saved-short` с ручными высотами, а не новыми задачами в фикстуре: обрезку заголовка в генераторе определяет только ручная высота (заглушка DOM меряет все карточки одинаково). Цена: длинные темы на реальной вёрстке проверяет e2e, не эталон. - **R-7 (M-5), 15.10.2026.** M-5 rejected — показано владельцу в сводке понимания до утверждения разделов, возражений не было.