Andrey Epifancev fd1799ac4a docs(openspec): архив web-app (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:20:58 +04:00

196 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Context
Хаб (`main` 3931a88) отдаёт по `GET /` и `GET /links` страницу доски связей `src/jira_hub/web/links.html` (~1200 строк,
из них ~900 — JS без зависимостей). Разделы скрипта: справочники и константы (`EPICS`, `KIND_LABELS`, `KIND`, `PRIO`,
`LINK_TYPES`, геометрия), состояние и хранилище (`MODEL`, фильтры `S`, выборка `VIS`, раскладка `rel/pos`, `localStorage`
`graph-<имя>…` с `migrateStore`), разбор (`fromJiraCsv`, `fromJson`, `fromHub`), выборка и анализ (`computeVisible`,
`criticalPath`, `findProblems`), раскладка и отрисовка (`render`, `relayout`, `renderLane`, `renderCard`, `startDrag`,
`alignEdges`, `drawEdges`, `orthoPath`, колонки дат), панель (`makeMulti`, легенда, проблемы, таблица), хаб
(`hubFetch`, `loadHub`, `refreshFromJira`, `watchBatch`, `resumeWatch`), экспорт/импорт JSON (`arm-ka-links/1`).
Финальное ревью `viewer-live` отложило M-1…M-7 (jira-hub#20): гонки поздних ответов и опроса, бесконечный опрос,
перезаписываемые сообщения, форма входа, непокрытая ветка `mirror.board(own=False)`.
API хаба для интерфейсов уже есть и не меняется: `GET /board` (сырой снимок зеркала), `GET /issues` (фильтры `sprint`,
`assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`; `limit`/`offset`, без общего
количества), `GET /issues/{key}` (поля, `links_out`, `links_in`, `note`, 10 последних `commands`), `PUT /issues/{key}/note`
(поля `fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`; 422 на неизвестные), `GET /settings`
(без токенов; `kind_labels`, `link_types`, `status_labels`), `GET /agent`, `POST /refresh`, `GET /batches/{id}`.
HTTP — `http.server`, маршруты `ROUTES` с областью токена; не-JSON ответ — `Raw(content_type, bytes)`.
На Mac mini стоят Node 24 и npm 11; хаб работает под launchd (`uv run --project <основной checkout> jira-hub serve`).
## Goals / Non-Goals
**Goals:** веб-приложение хаба, в которое доска перенесена без потери поведения и раскладок; страница задач зеркала
с карточкой и правкой заметок; закрыть #20; код фронта, который владелец может читать, а агент — развивать (`viewer-edit`).
**Non-Goals:** запись в Jira из интерфейса, страницы очереди/агента/настроек, изменения API, изменения логики доски
(кроме «Открыть карточку»), доступ из сети.
## Decisions
1. **Отдельный npm-проект `web/` в корне репозитория.** `web/package.json`, `web/vite.config.ts`, `web/tsconfig.json`,
исходники `web/src/`, тесты рядом с кодом (`*.test.ts(x)`), фикстуры `web/src/**/__fixtures__/`. Python-пакет не
меняется. Менеджер пакетов — npm, `package-lock.json` в git, установка `npm ci`.
2. **Стек.** React 19, TypeScript (strict), Vite, React Router (`createBrowserRouter`, `basename: "/app"`),
TanStack Query (загрузка, кэш, опрос), Vitest + Testing Library (jsdom), ESLint (typescript-eslint, react-hooks),
Playwright для e2e. Стили — обычный CSS: переменные и классы `links.html` переносятся в `web/src/styles/`, у страниц —
свои файлы CSS. UI-кита и библиотеки графов нет. Версии в `package.json` — точные, без `^`.
3. **Приложение под `/app/`.** Пути API (`/issues`, `/board`, …) заняты, от них зависят агент и MCP; префикс разводит
адреса приложения и API без изменения API. Vite: `base: "/app/"`, `build.outDir: "../src/jira_hub/web/app"`,
`emptyOutDir: true`. Маршруты приложения: `/app/board`, `/app/issues`, `/app/issues/:key`; `/app` и `/app/` →
`/app/board`; неизвестный путь внутри приложения — страница «не найдено» со ссылками на разделы.
4. **Раздача в хабе** (`api.py`, без области токена — в сборке нет данных):
- `GET /app/assets/<путь>` — файл из `src/jira_hub/web/app/assets/`; путь проверяется: после `resolve()` он SHALL
лежать внутри каталога `assets`, иначе 404; `Content-Type` по расширению (`.js` → `text/javascript; charset=utf-8`,
`.css` → `text/css; charset=utf-8`, `.svg` → `image/svg+xml`, `.png`, `.ico`, `.woff2`, `.map` → `application/json`;
прочее — `application/octet-stream`); `Cache-Control: public, max-age=31536000, immutable` (имена файлов содержат хэш);
- прочие `GET /app`, `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`;
- нет `index.html` (сборка не выполнена) — 503 `text/html` с текстом «Веб-приложение не собрано: выполните
`make web` в каталоге jira-hub и перезапустите хаб» — на любой `/app…`;
- `GET /` и `GET /links` — 302 `Location: /app/board`;
- `PAGE` и `links.html` удаляются. Файлы читаются с диска на каждый запрос (как сейчас `links.html`): новая сборка
видна без перезапуска.
5. **Сборка не в git, выкатка с `make web`.** `src/jira_hub/web/app/` — в `.gitignore`. Новый `Makefile` в корне:
`make web` = `cd web && npm ci && npm run build`; `make check` = `uv run pytest` + `cd web && npm run lint && npm test`.
Выкатка: `git pull` → `make web` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`. Pytest не зависит от
сборки: тесты раздачи подкладывают свой каталог сборки (параметр `Hub`/путь, подменяемый в тесте).
6. **Разработка.** `npm run dev` — Vite на `127.0.0.1:5173` с `base: "/app/"` и прокси всех путей API на хаб
(`JIRA_HUB_URL`, по умолчанию `http://127.0.0.1:8791` — тестовый хаб); прокси перечисляет префиксы API явно
(`/board`, `/issues`, `/settings`, `/agent`, `/refresh`, `/batches`, `/commands`, `/sync-runs`).
7. **Один клиент API `web/src/api/client.ts`.** `fetch` того же хоста с `Authorization: Bearer <токен>`,
`Accept: application/json`, `cache: "no-store"`. Результат — данные или типизированная ошибка:
`AuthError` (401), `HubDownError` (сеть или 5xx/502/503), `ApiError {status, error, detail}` (прочие 4xx).
Типы ответов — `web/src/api/types.ts` по спеке hub-api. Запросы — хуки TanStack Query в `web/src/api/queries.ts`
(`useBoard`, `useSettings`, `useIssues`, `useIssue`, `useAgent`, `useBatch`) и мутации (`useRefresh`, `useSaveNote`).
8. **Вход.** Токен — `localStorage["jira-hub-token"]` (тот же ключ, что у `links.html`) и копия в памяти на случай
закрытого хранилища. Нет токена или любой запрос ответил `AuthError` → экран входа поверх приложения: панели и
страницы не показываются, запросы не идут (`enabled: false`), поле токена не трогается фоновыми событиями (M-6).
После ввода — проверка `GET /settings`; при успехе токен сохраняется, кэш запросов сбрасывается. «Сменить токен» в
оболочке очищает токен и показывает экран входа. Загрузки файла CSV/JSON с экрана входа, как у `links.html`, нет:
разбору нужны словари из `/settings`, а приложение отдаёт сам хаб, так что без хаба его не открыть; файл загружается
на доске после входа.
9. **Оболочка.** Верхняя панель: название «Хаб Jira», ссылки «Доска», «Задачи», «Сменить токен». Граница ошибок
(`ErrorBoundary`) на уровне страницы: неожиданное исключение отрисовки → сообщение и кнопка «Перезагрузить», панель
остаётся.
10. **Доска: модули без React** (`web/src/board/`):
- `constants.ts` — `EPICS` (запасной словарь имён эпиков для CSV), `KIND`, `PRIO`, `JIRA_URL`, `RELATED`, служебные
дорожки, геометрия; `KIND_LABELS` и `LINK_TYPES` в коде не задаются — приходят параметром `dicts` из `/settings`
(`kind_labels`, `link_types`; у `link_types` — обратный словарь «имя Jira → логический тип»);
- `model/` — `fromHub(snap, dicts)`, `fromCsv(text, fileName, dicts)`, `fromJson(text, fileName)` → `BoardModel`;
`util.ts` — `sortableDate`, `shortName`, `plural`, `fmtDate`, `notStarted`, `unassigned`;
- `analysis/` — `computeVisible(model, filters)`, `criticalPath`, `findProblems`, `emptyReason`;
- `layout/` — раскладка дорожек и карточек (`relayout`, `autoHeight` через переданную функцию измерения,
`clampToCol`), колонки дат, `alignEdges`, `orthoPath`, `edgePath`;
- `storage.ts` — ключи `graph-<имя>…`, `migrateStore`, чтение/запись раскладки, дорожек, фильтров, темы; все
обращения к `localStorage` в `try/catch`, как сейчас.
Алгоритмы переносятся построчно; переименование и упрощение — только без изменения результата на golden-фикстурах.
11. **Доска: состояние и компоненты.** Модель и источник — в `useBoardSource` (раздел 12); фильтры, раскладка и
переключатели — `useReducer` в `BoardPage` с сохранением в `storage.ts`. Компоненты: `Toolbar` (источник, «Перечитать»,
«Обновить из Jira», «файл…», статистика, «Проблемы», меню «⋯» без «Сменить токен»), `FilterBar` + `MultiSelect`,
`Legend`, `Board` (контейнер с двумя SVG: `Edges`, `Guides`), `Lane`, `Card`, `DatesBar`, `ProblemsDrawer`,
`LinkTable`, `ExportBox`, `StatusLine`. Перетаскивание и растяжка — pointer-события на `Card`, во время движения
положение пишется в состояние раскладки, сохранение — по `pointerup` (как сейчас). Размер карточки «по содержимому»
измеряется через `ref` на DOM карточки.
12. **Доска: источник данных и обновление (закрывает M-1…M-5).** `useBoardSource` хранит явный источник:
`{kind: "hub"}` или `{kind: "file", model, fileName}`. Данные хаба (`useBoard`) показываются только при источнике
`hub`; загрузка CSV/JSON-снимка переключает источник на `file`, поздний ответ хаба его не затирает (M-1); «Перечитать»
возвращает источник `hub` и перезапрашивает `/board`. Импорт JSON в режиме хаба применяет раскладку, дорожки и
фильтры и перечитывает хаб (как сейчас). «Обновить из Jira»: `GET /agent` (офлайн → предупреждение, пакет всё равно
ставится) → мутация `POST /refresh {full: true, author: "viewer"}` → `useBatch(id)` с `refetchInterval` 3 с до
терминального статуса пакета → `invalidate(/board)`. Кнопка выключена, пока мутация в пути или есть активный пакет
(M-2); опрос пакета — один запрос по ключу `["batch", id]`, второго опроса не бывает. Незавершённый пакет `viewer`
ищется (`GET /batches`) при каждой успешной загрузке `/board`, а не только при первой (M-3). Опрос останавливается
на 404 сразу и после 5 ошибок `HubDownError` подряд — с сообщением в строке состояния и снова включённой кнопкой (M-4).
`StatusLine` показывает одно состояние из перечня: «загрузка», «данные хаба на <время>», «из файла <имя>»,
«пакет #N выполняется», «пакет #N выполнен», «пакет #N: ошибка <текст>», «хаб не отвечает — показаны прежние данные»,
«агент офлайн — пакет поставлен»; ошибки разбора файла — отдельным текстом «файл не разобран: <причина>», не «хаб не
отвечает» (M-5).
13. **Доска: «Открыть карточку».** В заголовке карточки (не у заглушек) рядом с ключом — значок-ссылка «ⓘ» на
`/app/issues/{key}` (переход внутри приложения). Ключ по-прежнему открывает задачу в Jira, перетаскивание и выделение
текста не меняются.
14. **Задачи: список `/app/issues`.** Таблица: ключ, тема, тип, статус Jira, исполнитель, спринт, срок (`planned_end`,
иначе `duedate`), эпик (имя из снимка `/board`, иначе ключ), вид работы, статус по факту, «сосед». Фильтры — параметры
`GET /issues`, состояние фильтров — в query-строке адреса (`useSearchParams`); поиск `q` применяется с задержкой 300 мс.
Варианты выпадающих списков (спринты, исполнители, статусы Jira, эпики, метки, виды работы, статусы по факту) —
из `useBoard()`, без новых маршрутов. Порции по 50 (`useInfiniteQuery`, `offset`), кнопка «Показать ещё» видна,
пока последняя порция полная (50 строк). Ключ — ссылка на карточку.
15. **Задачи: карточка `/app/issues/:key`.** Поля задачи, «открыть в Jira», описание — текст с `white-space: pre-wrap`
без разбора разметки Jira; связи «исходящие»/«входящие» с подписью типа (логический тип из `link_types`, иначе имя
Jira), ключ — ссылка на карточку; 10 последних команд: id, пакет, тип, статус, время, ошибка. 404 → «задачи нет в
зеркале» со ссылкой на список.
16. **Задачи: заметки.** Форма в карточке: «вид работы» — выпадающий список из значений `kind_labels` (плюс текущее
значение, если его там нет, и «—» = пусто); «статус по факту» — поле с подсказками (`datalist`) из ключей
`status_labels`; «сделано», «осталось», «заметка владельца» — многострочные поля. «Сохранить» активна при изменениях
и отправляет только изменённые поля (`PUT /issues/{key}/note`, `updated_by: "web"`); пустое поле отправляется как
`null`. Успех → обновлённая заметка в кэше карточки и списка, «сохранено <время>, <кто>». 422 → текст `detail` у
формы, введённое не теряется. Уход со страницы с несохранёнными правками → подтверждение (`useBlocker` и
`beforeunload`).
17. **Golden-эталоны доски из старой страницы.** Скрипт `web/scripts/golden-from-links.mjs` (разовый, в git): читает
`links.html` из git-ревизии 3931a88, исполняет его `<script>` в `node:vm` с минимальной заглушкой DOM, вызывает
`fromHub`/`fromJiraCsv`/`fromJson`, `computeVisible` при наборах фильтров, `criticalPath`, `findProblems`, `orthoPath`
и пишет `<name>.expected.json` рядом с фикстурами. Фикстуры — синтетические (`<name>.board.json`, `<name>.csv`,
`<name>.layout.json`): эпики, соседи, заглушки «нет в зеркале», все три типа связей и неизвестный тип, несколько
спринтов, задачи без исполнителя и срока, передачи между людьми, цепочка для критического пути. Тесты Vitest
сравнивают выход TS-модулей с эталоном побайтно после `JSON.stringify` с сортировкой ключей.
18. **Проверки.** Vitest: golden-модули доски, `client.ts` (401/сеть/4xx), `storage.ts` (ключи, миграция, закрытое
хранилище), `useBoardSource` (M-1…M-5 сценариями с подменённым `fetch` и таймерами), «Задачи» (фильтры в адресе,
«Показать ещё», заметка шлёт только изменённое, 422, подтверждение ухода). Pytest: раздача `/app…` (типы, кэш,
`index.html` на произвольный путь, 503 без сборки, обход каталога → 404, редиректы `/`/`/links`, без токена), тест
`mirror.board` внутри открытой транзакции (M-7). Playwright e2e на тестовом хабе `:8791`
(`JIRA_HUB_HOME` во временном каталоге, зеркало заполнено `POST /agent/state` из golden-фикстуры): вход и неверный
токен, доска (перетаскивание, растяжка, стрелки, «по датам», «Обновить из Jira» до конца пакета с учебным агентом,
«Открыть карточку»), задачи (фильтр → карточка → заметка). До удаления `links.html` — скриншоты старой и новой доски
на одной фикстуре и одной раскладке, расхождения разбираются вручную. `tsc --noEmit` и ESLint — в `npm run lint`.
## Risks / Trade-offs
- [Переписывание ~900 строк может изменить поведение доски незаметно] → golden-эталоны старой страницы на модулях
логики, e2e и сверка скриншотов на отрисовке; перенос построчный, улучшения — только без изменения результата.
- [Перетаскивание через состояние React может тормозить на ~200 карточках] → перерисовывается только двигаемая
карточка и стрелки (`memo` на `Card`, раскладка — в отдельном контексте); если e2e покажет задержку — положение во время
движения через `ref`/`transform`, в состояние — по `pointerup`.
- [Сборка не в git: после `git pull` без `make web` хаб отдаёт старую сборку или 503] → 503 с явной подсказкой; шаг
выкатки в README и Migration Plan.
- [Зависимости npm — поверхность цепочки поставок] → точные версии, `package-lock.json`, `npm ci`; зависимостей
времени выполнения четыре (react, react-dom, react-router, @tanstack/react-query).
- [Словари из `/settings` пусты на чистой базе] → доска без `link_types` не покажет связей; экран показывает
«в настройках хаба нет link_types/kind_labels» вместо пустой доски.
- [Токен в `localStorage` доступен скриптам страницы] → сторонних скриптов нет, сборка своя; доступ только с Mac mini.
## Migration Plan
1. Merge → в основном checkout `git pull` → `make web` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`.
2. Открыть `http://127.0.0.1:8790/` → редирект на `/app/board`; вход уже есть (тот же ключ токена), раскладка — та же
(те же ключи `graph-…`).
3. Проверить: доска совпадает с прежней, «Обновить из Jira» проходит, «Задачи» → карточка → заметка сохраняется.
4. Откат: предыдущий `main` и перезапуск — `/` снова отдаёт `links.html`; раскладки в `localStorage` не меняются
новым приложением по формату, старая страница их читает.
## Open Questions
Нет.
## Decisions made without discussion
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-1 (design #13) | 2026-10-14 | Ответ владельца «да» на вопрос «добавить переход с карточки доски в карточку задачи или оставить на viewer-edit» понят как «добавить»; переход — отдельный значок «ⓘ», щелчок по ключу по-прежнему открывает Jira | вопрос и одобрение раздела пришли одним «да»; значок не меняет существующее поведение доски | убрать значок — одна правка компонента |
| R-2 (design #1) | 2026-10-14 | Менеджер пакетов — npm с `package-lock.json` и `npm ci` | npm уже стоит на Mac mini, ничего доставлять не нужно | переход на pnpm — замена lock-файла и команд в Makefile |
| R-3 (design #4) | 2026-10-14 | Файлы `assets` кэшируются навсегда (`immutable`), `index.html` — `no-cache` | имена файлов сборки содержат хэш; новая сборка видна без перезапуска и без сброса кэша | при ошибке — старый интерфейс до сброса кэша браузера |
| R-4 (design #17) | 2026-10-14 | Фикстуры доски синтетические, не снимок живого зеркала | в зеркале данные Jira банка, в личном репозитории их держать не надо | синтетика может не покрыть редкий случай живых данных — его ловит сверка на живом хабе при выкатке |
| R-5 (design #6) | 2026-10-14 | Прокси разработки перечисляет префиксы API явно, по умолчанию на тестовый хаб `:8791` | разработка не трогает живой хаб; явный список не перехватывает пути приложения | новый маршрут API надо дописать в прокси |
| R-6 (design #16) | 2026-10-14 | `updated_by` заметок из приложения — `"web"`; пустое поле отправляется как `null` | у хаба один клиентский токен, пользователя он не различает; `null` очищает поле так же, как MCP | автор в истории заметок неинформативен — до появления пользователей |
| R-7 (design #14) | 2026-10-14 | Поиск `q` в списке задач — с задержкой 300 мс | без задержки запрос на каждую букву | неудобство ввода — меняется одна константа |
| R-8 (design #4) | 2026-10-14 | Каталог сборки читается с диска на каждый запрос, без кэша в памяти хаба | так же работает `links.html`; `make web` без перезапуска хаба | лишнее чтение файла на запрос — несущественно при одном пользователе |
| R-9 (design #8) | 2026-10-14 | Экран входа не предлагает загрузку CSV/JSON без хаба; файл — только на доске после входа | разбор файла требует словарей из `/settings`; приложение отдаёт хаб, сценария «без хаба» нет | посмотреть CSV без токена нельзя — можно кэшировать словари в `localStorage` позже |
| R-10 (design #11) | 2026-10-14 | Двойной щелчок по уголку карточки меряет высоту по содержимому при новой ширине (CW в пределах колонки), а не при прежней, как `links.html` (побочный эффект порядка вызовов в строке 750) | намерение «размер по содержимому» — высота под ширину, которую ставит то же действие; golden-тесты этот путь не покрывают | на карточке с ручной шириной высота после двойного щелчка может отличаться от старой страницы на строку заголовка |
| R-11 (design #12) | 2026-10-14 | «Строка состояния» доски — два элемента, как у `links.html`: подпись источника (`label`: «Хаб · данные на …», «… (из файла)», на месте `fileName`) и одно текущее событие (`status`: загрузка, пакет, ошибки — на месте `hubState`); событие пишет один автомат состояний, перезаписи сообщений (M-5) нет | так выглядела прежняя доска, а M-5 — про гонку сообщений, не про число элементов; формулировка требования «Источник данных доски» уточнена | если нужна буквально одна строка — объединить два элемента в `Toolbar` |
| R-12 (design #18) | 2026-10-14 | Строка `Co-Authored-By` в коммитах исполнения — имя модели реального автора (haiku/sonnet), а не «Claude Opus 5.5» из плана; амендов нет | коммиты делают субагенты haiku/sonnet, системная инструкция атрибуции требует их имя | неверная подпись в трейлере коммитов. Пересмотрено основной сессией: с Task 5 трейлер — по плану (Opus 5.5); коммиты Tasks 1–4 не переписываются |
| R-13 (design #19) | 2026-10-14 | `fromJson` сам нормализует копию фильтров: при `mode === "linked"` ставит `neighbors = true`, как `applyJsonSettings` старой страницы (golden-эталон `old-board` снят после этой мутации); обработка `mode === "all"` остаётся за Task 9 | эталон нельзя править руками, а побочный эффект старой страницы — часть наблюдаемого поведения импорта | если нормализация лишняя — убрать две строки в `fromJson` и перегенерировать эталон |
| R-14 (design #20) | 2026-10-14 | В golden-тесте раскладки `dateLabels` сравниваются только при `byDate`; эталон не правится и генератор Task 5 не переделывается | в сценарии `saved-folded` (byDate=false) эталон хранит 7 подписей дат от предыдущего сценария: заглушка DOM генератора не очищает полосу дат — артефакт генератора, не поведение страницы | при `byDate=false` приложение, возможно, показывает лишние подписи — это ловит компонентный тест холста (Task 11) и сверка в Task 15 |
| R-15 (design #21) | 2026-10-14 | Ожидания теста `BoardCanvas` из плана выправлены по golden-эталону (4 дорожки, 9 карточек, 6 стрелок; заглушка 9500 и «Нет в зеркале» видны только при who=Иванов+neighbors; «Ниже» у Гудвилла даёт to=1); код компонентов — как в плане | эталон получен запуском настоящей `links.html@3931a88`, он — источник истины о поведении; цифры в плане были написаны по памяти | если эталон не отражает желаемого поведения — менять фикстуры и генератор, а не тест |
| R-16 (design #22) | 2026-10-14 | В `board.css` слой измерения получает `height:auto` (`.measure .card{position:static;height:auto}`), в отличие от CSS из плана | у `.card` фиксированные 120px; оригинал ставил `height:auto` перед измерением (links.html ~826), без этого авто-высота карточек не работает (I-1 ревью Task 11) | неверная раскладка высот; проверка реальной геометрии — e2e Task 15 |
| R-17 (design #23) | 2026-10-14 | Исполнитель Task 12 исправил в рамках задачи два дефекта прежних задач: `useBoardSource` отдаёт стабильную `emptyModel` через `useMemo` (иначе `useBoardPrefs` сбрасывает выбор при каждой новой модели → «Too many re-renders»), `shell/theme.ts readTheme` возвращает значение по умолчанию при пустом хранилище (иначе «Тема: undefined») | без правок страница доски не работала; отдельная задача ради двух строк — лишний круг | бесконечный рендер или «undefined» в меню темы; правки проверяются ревью Task 12 |
| R-18 (design #24) | 2026-10-14 | `setPointerCapture` в `BoardCanvas` ставится на нажатом элементе (`t`), а не на карточке (коммит d6fe046) — двойной щелчок по уголку `.rsz` снова сбрасывает размер | в Chromium capture на карточке отправляет `dblclick` на карточку; дефект воспроизведён и в старой `/links` (ширина 240→320→320), то есть унаследован; без правки e2e «растяжка…» недостижим | поведение перетаскивания/растяжки отличается от `links.html` в крайнем случае; откат — revert d6fe046 |