OpenSpec: влить дельты htmx-action-swap в спеки, архив change

Sync новых требований в openspec/specs/: web-ui («Действия обновляют
интерфейс на месте») и review («Петлевые действия ревью обновляют экран на
месте»). Change перемещён в changes/archive/2026-07-04-htmx-action-swap.
Конвенция web-ui.md актуализирована: сняты маркеры «будем» по реализованному.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 14:42:06 +03:00
co-authored by Claude Opus 4.8
parent 2f8e6e3576
commit 576fc4e6c0
9 changed files with 147 additions and 30 deletions
@@ -0,0 +1,93 @@
## 1. Извлечение переиспользуемых view-builder'ов
- [x] 1.1 Вынести сборку карточки списка в `buildCardView(d, now, live,
layoutSize)` — перенести обвязку из `handleIndex` (`httpapi.go:291-304`:
`liveFor`, `ratioText`, `sizeText`, `layoutSizes[id]`, `buildProgress`);
`handleIndex` теперь зовёт её в цикле. Билдер MUST выставлять
`IsDownloading`/`Progress` (для retry→downloading карточка обязана нести
`progress`-партиал с поллером) и `Ratio`/`Size`
- [x] 1.2 Вынести сборку страницы загрузки в `buildDownloadView(id, rd)` —
перенести инлайн-сборку из `handleDownload` (`download.go:86-142`);
`handleDownload` зовёт её. Добавить в view отдельное поле `ActionError`
- [x] 1.3 Добавить в view карточки отдельное поле `ActionError` (НЕ
переиспользовать `card-meta`/`.Error` — они несут `Note`/`error_msg`)
## 2. Партиалы и фрагменты (единый источник разметки)
- [x] 2.1 Выделить карточку в `partials/card.html` (`{{define "card"}}`) с
`id="card-{{.ID}}"` на корневом `<article>`; `index.html` — цикл через
`{{template "card" .}}`. Инвариант: корень `define` == элемент с целевым `id`
- [x] 2.2 Обернуть `<main>` страницы загрузки в `{{define "download_main"}}` с
`id="download-main"`; `download.html` включает его
- [x] 2.3 Обернуть тело ревью в `{{define "review_main"}}` с `id="review-main"`
(внутри остаётся `review_source_block`); `review.html` включает его
- [x] 2.4 В обеих карточка/`download_main` добавить разметку слота `ActionError`
(видим только при непустом значении), не конфликтуя с `Note`/error-баннером
- [x] 2.5 Проставить `hx-post`/`hx-target`/`hx-swap="outerHTML"` на свопимые
формы: карточка (`undo`/`relink`/`retry`/`cancel` → `#card-{id}`), страница
загрузки (те же → `#download-main`), петля ревью (`refine`/`rerecognize` →
`#review-main`). Выходы ревью (`apply`/`defer`/`cancel`) — обычные POST-формы
БЕЗ `hx-*` (намеренно: единственный маркер «это выход из ревью»)
- [x] 2.6 В формы общих действий (`undo`/`relink`/`retry`/`cancel`), доступных и
в списке, и на `/download/{id}`, добавить скрытое поле
`surface=list|download` — им обработчик выбирает фрагмент ответа
## 3. Авто-поллер recognizing на экране ревью
- [x] 3.1 Добавить fragment-эндпоинт `GET /fragments/downloads/{id}/review` →
`handleFragReview`, рендерит `review_main` по текущему состоянию (по образцу
`handleFragProgress`/`handleFragSeeding` в `live.go`)
- [x] 3.2 В `review_main` блок состояния `recognizing` (`review.html:33-38`)
оснастить поллером `hx-get="/fragments/downloads/{id}/review"
hx-trigger="every 2s" hx-target="#review-main" hx-swap="outerHTML"`; ручную
ссылку «Обновить» оставить как fallback без JS. Когда состояние выходит из
`recognizing`, `review_main` возвращается без поллера — опрос сам прекращается
## 4. Обработчики: ветка htmx → фрагмент
- [x] 4.1 Ввести помощник `cardAction` (по образцу `reviewBlockAction`):
доменный вызов; на `isHTMX` перечитать состояние, отрендерить `card` для
одной загрузки; на ошибке — тот же фрагмент с заполненным `ActionError` и
**HTTP 200** (иначе htmx не свопит); без htmx — прежний редирект
- [x] 4.2 Ввести помощник `downloadAction` аналогично, рендер `download_main`
(тоже 200 + `ActionError` на ошибке)
- [x] 4.3 Перевести `handleUndo`, `handleRelink`, `handleUIRetry` на выбор
помощника/фрагмента по полю `surface` (list→`cardAction`,
download→`downloadAction`); без htmx — прежний редирект
- [x] 4.4 Переписать единый `handleUICancel`: на `isHTMX` — своп по `surface`
(list→`card`, download→`download_main`); на не-htmx (в т.ч. форма выхода из
ревью и режим без JS) → редирект на `/`, как сейчас. Убрать деление «в
контексте ревью не трогать» — обработчик один
- [x] 4.5 Перевести петлевые `handleRefine`, `handleRerecognize` на рендер
`review_main` при `isHTMX` (200 + `.Error` на ошибке), сохранив
редирект-ветку на `/review/{id}` без htmx. Выходы ревью
(`handleApply`/`handleDefer`) не трогать
- [x] 4.6 Ошибки везде переводить существующими `userErr`/`classifyErr` (наружу
только нейтральный текст); секреты в логи не попадают — паритет сохранить
## 5. Тесты
- [x] 5.1 htmx-запрос (`HX-Request: true`) действия из списка (`surface=list`)
→ 200 + фрагмент `card` с новым состоянием, не редирект
- [x] 5.2 htmx-запрос действия со страницы (`surface=download`) → 200 +
`download_main`; без `HX-Request` → прежний 303-редирект
- [x] 5.3 Петля ревью: `rerecognize`/`refine` при `HX-Request` → 200 +
`review_main` в состоянии `recognizing` с поллером; без htmx → 303 на
`/review/{id}`
- [x] 5.4 `handleFragReview`: пока `recognizing` — фрагмент содержит поллер;
после перехода в `review` — фрагмент без поллера (опрос прекращается)
- [x] 5.5 Ошибка действия: htmx-ответ — **200 + сообщение в теле** (в
`ActionError`/`.Error`), состояние не меняется; выход ревью (`cancel` без
`hx-*`) — по-прежнему навигация
- [x] 5.6 retry из списка при htmx → карточка `downloading` с `progress`-поллером
## 6. Проверка и оформление
- [x] 6.1 `task lint` и `task test` зелёные
- [ ] 6.2 Ручная проверка (`task run`) — ОТЛОЖЕНА до окружения с
qBittorrent/LLM/конфигом. Рендер всех страниц и фрагментов и ветвление
htmx/redirect покрыты хендлер-тестами через реальные шаблоны
(`action_swap_test.go`); живой браузерный прогон (сохранение скролла, живой
своп) — за пользователем.
- [x] 6.3 Ревью кода (второй чекпоинт, сабагентом — критичных багов нет,
minor-замечания закрыты), sync дельт в `openspec/specs/`, архивирование change