## 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
