Веб-UI: htmx-своп действий на месте вместо PRG-редиректа

Мутирующие действия больше не уводят со страницы: undo/relink/retry/cancel
в списке свопят карточку (#card-{id}), на /download/{id} — #download-main;
петля ревью (refine/rerecognize) свопит #review-main и допалливает
recognizing до готового плана (fragment /fragments/downloads/{id}/review,
every 2s). Выходы ревью (apply/defer/cancel) остаются навигацией. Без htmx —
прежний PRG-редирект (деградация). Ошибка действия на htmx-пути — HTTP 200
с сообщением в фрагменте (ActionError), иначе htmx не свопит DOM.

Разметка вынесена в партиалы card/download_main/review_main (корень = элемент
с целевым id), различение поверхности — скрытым полем surface=list|download.
Извлечены buildCardView/buildDownloadView. handleSetProvider переведён на
reviewBlockAction (консистентность source-действий).

Реализация change htmx-action-swap (OpenSpec).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 14:37:51 +03:00
co-authored by Claude Opus 4.8
parent 5d704059ee
commit 2f8e6e3576
16 changed files with 1140 additions and 248 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-04
+202
View File
@@ -0,0 +1,202 @@
## Context
Действия над загрузкой в веб-UI сейчас — POST-формы с PRG-редиректом
(`internal/httpapi`). Обработчики после доменного вызова зовут `redirectErr` /
`redirectReview` / `http.Redirect(..., "/", ...)`. Из-за этого действие всегда
уводит пользователя со страницы: откат из списка и со страницы `/download/{id}`
уходит на `/`, перераспознавание перезагружает `/review/{id}` с прыжком
скролла.
В коде уже есть ровно нужный паттерн для одного случая — выбор источника на
ревью: `reviewBlockAction` (`review.go:379`) проверяет `isHTMX(r)`
(`review.go:370`, заголовок `HX-Request`), и на htmx-запрос перечитывает
состояние и рендерит партиал `review_source_block` через `s.render`
(`render.go:78`, `ExecuteTemplate` по имени), а без htmx деградирует до
`redirectReview`. Задача — обобщить этот приём на остальные действия и на две
другие поверхности (карточка списка, страница загрузки).
Стек фиксирован: htmx-first, без сборки и клиентских фреймворков (решение
`2026-06-30-web-ui-design-port`, D3/D6). htmx уже вендорится и подключён на всех
страницах.
## Goals / Non-Goals
**Goals:**
- Действие не уводит с текущей поверхности: список → карточка обновляется на
месте; `/download/{id}` → страница обновляется на месте; петля ревью
(`rerecognize`/`refine`) → тело ревью обновляется на месте.
- Никакого сброса контекста списка (фильтр/поиск/страница/скролл) и прыжков
скролла.
- Graceful degradation: без htmx — прежний PRG-редирект, доменные вызовы не
меняются.
- Единый источник разметки: фрагмент для свопа и инлайновый рендер страницы —
один и тот же `{{define}}`-блок, без дубля markup.
**Non-Goals:**
- Не вводим Alpine.js/SPA и клиентский пересчёт доменного состояния.
- Не трогаем `live-status` (поллинг телеметрии) — он ортогонален.
- Не добавляем rerecognize отдельной кнопкой на `/download/{id}` (ревью —
часть «страницы загрузки», см. proposal).
- Не переводим на своп выходы из ревью (`apply`/`defer`/`cancel`) — они уводят
загрузку с экрана.
## Decisions
### D1. Ветвление в обработчике: htmx → фрагмент, иначе → редирект
Каждый затрагиваемый обработчик после доменного вызова ветвится по `isHTMX(r)`:
на htmx перечитывает актуальное состояние тем же view-builder'ом, что и полная
страница, и рендерит соответствующий фрагмент через `s.render`; иначе —
существующий редирект. Доменный вызов (`Reviewer.*`, `worker`/`ingest`) не
меняется. Обобщаем помощник по образцу `reviewBlockAction`: вводим тонкие
обёртки `cardAction` (список) и `downloadAction` (страница загрузки),
аналогичные существующему `reviewAction`/`reviewBlockAction`.
**Переиспользуемых builder'ов для карточки и страницы сейчас нет** — сборка
карточки размазана инлайн по `handleIndex` (`httpapi.go:291-304`: `liveFor`,
`ratioText`, `sizeText`, `layoutSizes`, `buildProgress`), сборка страницы — по
`handleDownload` (`download.go:86-142`). Чтобы «reuse без дубля» не превратился
в копипаст, сперва извлекаем `buildCardView(...)` и `buildDownloadView(id, rd)`
и переиспользуем их и на полной странице, и в фрагменте. Для `retry`
(→`downloading`) билдер карточки обязан выставить `IsDownloading`/`Progress`,
иначе свопнутая карточка потеряет прогресс-поллер.
_Альтернатива:_ отвечать всегда фрагментом и полагаться только на htmx —
отвергнуто: ломает работу без JS, противоречит инварианту web-ui «действия
работают без JavaScript».
### D2. Партиалы как единый источник разметки
Выделяем переиспользуемые `{{define}}`-блоки, чтобы один markup рендерился и
инлайн на странице, и как ответ-фрагмент:
- `partials/card.html``{{define "card"}}` — карточка списка целиком
(сейчас инлайн в `index.html:55-88`). В `index.html` цикл вызывает
`{{template "card" .}}`. Обработчик действия рендерит `card` для одной
загрузки.
- Фрагмент главной области страницы загрузки: `{{define "download_main"}}`
(обёртка вокруг содержимого `<main>` в `download.html`). Страница включает
его, обработчик рендерит его же как св swap-ответ.
- Ревью: для петлевых действий рендерим тело ревью. Переиспользуем/выделяем
`{{define "review_main"}}` (содержимое `<main>` в `review.html`, уже
включающего `review_source_block`).
Каждый блок получает стабильный `id` для `hx-target`: `id="card-{{.ID}}"` на
`<article>`, `id="download-main"` и `id="review-main"` на обёртках.
### D3. Разметка форм: hx-post + hx-target + outerHTML
Формы затрагиваемых действий получают `hx-post="<тот же action>"`,
`hx-target="#<фрагмент>"`, `hx-swap="outerHTML"`. `action` формы сохраняется —
это и есть fallback без htmx. Цели:
- Список: `hx-target="#card-{{.ID}}"`, своп карточки.
- Страница загрузки: `hx-target="#download-main"`.
- Петля ревью (`refine`, `rerecognize`): `hx-target="#review-main"`.
- Выходы ревью (`apply`/`defer`/`cancel`) остаются **обычными** POST-формами
без `hx-*` → полная навигация (редирект), как сейчас. Так не нужен
`HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
`hx-swap="outerHTML"` возвращает прокрутку не наверх, а сохраняет позицию —
именно то, что требуется против «прыжка скролла».
**Различение поверхности.** Действия `undo`/`relink`/`retry`/`cancel` — единые
роуты (`httpapi.go:117-118,131-132`), приходят и со списка, и со страницы
загрузки, но фрагмент ответа разный (`card` vs `download_main`). Различаем
**скрытым полем `surface=list|download`** в форме — оно самодокументируемо и не
зависит от резолва таргета (в отличие от заголовка `HX-Target`). Обработчик
читает `surface` и зовёт `cardAction`/`downloadAction`.
### D4. Ошибка действия — фрагмент с HTTP 200, отдельное поле ActionError
htmx по умолчанию НЕ свопит DOM на ответы 4xx/5xx (`responseHandling`, см.
вендорный htmx). Поэтому на ошибке доменного вызова обработчик MUST отвечать
**HTTP 200 с фрагментом**, несущим сообщение об ошибке (по образцу `BlockError`
в `reviewBlockAction`, `review.go:405-408`), а не транслировать доменную ошибку
в статус. Для карточки и `download_main` вводим **отдельное поле `ActionError`**
и явную ветку разметки — не переиспользуем `card-meta`/`.Error`, потому что они
несут `Note`/`error_msg` (для `target_missing` `Note` непуст и перекрыл бы
сообщение). На экране ревью `review_main` использует существующий `.Error`.
Текст — нейтральный через `userErr`/`classifyErr`; при ошибке активное состояние
не меняется. Без htmx — прежний редирект с `?err=`.
_Замечание по деградации страницы загрузки:_ `handleDownload` сейчас не читает
`?err=` из query, поэтому без htmx действие со страницы при ошибке уводит на
список (`/?err=`), как и раньше. Это осознанно оставляем — no-JS путь не
регрессирует; на месте ошибка показывается только на htmx-пути через
`ActionError`.
### D6. Авто-поллер recognizing на экране ревью
`rerecognize`/`refine` асинхронны: переводят загрузку в `recognizing`
(`worker/review.go:374,399`), фактическое распознавание доделывает цикл
воркера. Поэтому своп петлевого действия отдаёт `review_main` в состоянии
`recognizing` (заглушка `review.html:33-38`), а не сразу «новый план». Чтобы
пользователь увидел готовый план без ручного refresh, блок `recognizing`
оснащаем поллером `hx-get="/fragments/downloads/{id}/review"
hx-trigger="every 2s" hx-target="#review-main" hx-swap="outerHTML"` (по образцу
существующих `progress`/`seeding`-фрагментов в `live.go`). Новый эндпоинт
`handleFragReview` рендерит `review_main` по текущему состоянию: пока
`recognizing` — с поллером; как только состояние стало `review` — фрагмент без
поллера, опрос сам прекращается. Ручная ссылка «Обновить» остаётся fallback'ом
без JS.
_Граница scope:_ формально это тот же приём, что и `live-status` (htmx-поллинг
UI-фрагмента), но здесь опрос ведёт браузер по своему же серверу и не читает
телеметрию qBittorrent — инвариант live-status «браузер не опрашивает
qBittorrent напрямую» не затрагивается.
### D5. Состояние карточки после свопа vs текущий фильтр
После свопа карточка показывает новое состояние, даже если оно уже не подходит
под активный фильтр (например, фильтр `review`, а `undo``reverted`). Карточка
остаётся на месте до следующей полной загрузки списка. Это осознанный
компромисс в пользу «остаться в контексте»: не переупорядочиваем и не убираем
карточку на клиенте (клиентского пересчёта домена нет — инвариант web-ui).
## Risks / Trade-offs
- **Дрейф разметки фрагмент vs страница** → устранён D2: единый `{{define}}`,
включаемый и в страницу, и в swap-ответ; отдельного markup для фрагмента нет.
- **Свопнутая карточка не соответствует фильтру** (D5) → приемлемо; полная
перезагрузка/смена фильтра приводит список в согласованность. Документируем
поведение в спеке.
- **relink→recognizing на карточке/странице загрузки**: после свопа
показывается `recognizing`, но поллинга для этого состояния в списке/на
странице нет (только `downloading`/`seeding`) → там состояние обновится при
следующем открытии/перезагрузке. Не регресс относительно текущего поведения.
На экране ревью этот случай закрыт авто-поллером (D6).
- **retry→downloading**: единственный своп-случай, где новая карточка обязана
нести живой прогресс-поллер → билдер карточки (D1) должен выставить
`IsDownloading`/`Progress`; покрываем тестом.
- **Своп корня с потерей id**: ответный фрагмент обязан нести тот же корневой
`id` (`#card-{id}`/`#download-main`/`#review-main`), иначе следующий своп не
найдёт таргет. Инвариант «корень `{{define}}` == элемент с целевым `id`»
фиксируем в задачах 2.1-2.3.
- **hx-target ссылается на отсутствующий элемент** (рассинхрон id) → покрываем
тестом обработчика (ветка htmx возвращает ожидаемый фрагмент) и ручной
проверкой; id проставляются в тех же партиалах.
- **Вложенные поллеры при свопе всей карточки/main**: `outerHTML` удаляет старый
узел с его поллером и htmx `process`-инициализирует поллеры нового фрагмента —
двойного опроса нет. Условие корректности — тот же корневой `id` (см. выше).
- **Двойной сабмит** → снижен: своп заменяет кнопки на актуальный набор; htmx
по умолчанию не шлёт повторно во время запроса.
- **Доступность/фокус после свупа** → минорно; действия крупные, фокус-ловушек
нет. Не адресуем в этом change.
## Migration Plan
Миграции данных нет. Изменения — только шаблоны (`web/templates`) и обработчики
(`internal/httpapi`). Фича — прогрессивное улучшение: ветка без htmx сохраняет
прежний PRG-поток, поэтому откат — простой revert коммита. Развёртывание —
обычный бинарь (статика встроена `go:embed`).
## Open Questions
Закрыты на ревью дизайна:
- Различение поверхности для общих действий — решено скрытым полем
`surface=list|download` (D3), не `HX-Target`.
- Слот ошибки в карточке — решено отдельным полем `ActionError` и явной веткой
разметки (D4), а не переиспользованием `card-meta`/`.Error`.
- Поведение петли ревью при async-распознавании — решено авто-поллером
`recognizing` на экране ревью (D6).
- Деградация download-действий без JS при ошибке — уходит на список (`/?err=`),
как сейчас; на месте ошибка только на htmx-пути (D4).
@@ -0,0 +1,73 @@
## Why
Сейчас действия над загрузкой (откат, привязать заново, распознать заново,
уточнить, применить и т.д.) — это POST-формы с PRG-редиректом: после действия
браузер уходит на другую страницу и теряет контекст. Откат со страницы
загрузки `/download/{id}` и из карточки списка одинаково выкидывает на `/`, а
перераспознавание на `/review/{id}` перезагружает страницу с прыжком скролла
наверх. Пользователь хочет оставаться там, где действовал: действие со списка —
остаёшься в списке, со страницы загрузки/ревью — остаёшься на ней.
## What Changes
- Мутирующие UI-действия выполняются через **htmx-swap на месте** вместо
server-side PRG-редиректа: сервер отвечает обновлённым HTML-фрагментом той
области, которую затронуло действие, htmx подменяет её в DOM без навигации.
Паттерн уже применяется в `reviewBlockAction` (выбор источника) — расширяем
его на остальные действия.
- **Карточка в списке** (`/`): действия `undo`, `relink`, `retry`, `cancel`
подменяют карточку (`<article class="card">`) на месте — обновлённое
состояние, бейдж и набор кнопок. Фильтр/поиск/пагинация/прокрутка не
сбрасываются.
- **Страница загрузки** (`/download/{id}`): те же действия обновляют содержимое
страницы на месте (без перехода и без прыжка скролла), отражая новое
состояние.
- **Страница ревью** (`/review/{id}`): **петлевые** действия распознавания —
`rerecognize`, `refine` (и уже работающий выбор источника) — обновляют экран
на месте (без перезагрузки и прыжка скролла). Так как они асинхронны (переводят
загрузку в `recognizing`), своп отдаёт состояние `recognizing`, а экран сам
**допалливает** готовый план htmx-фрагментом (`GET /fragments/downloads/{id}/review`,
по образцу `progress`/`seeding`) и автоматически сменяется на план по
завершении — без ручного обновления. **Выходы** из ревью (`apply` → done,
`defer` → deferred, `cancel` → cancelled) уводят с экрана (загрузка покидает
ревью), поэтому остаются навигацией/редиректом. Отдельная кнопка
перераспознавания на `/download/{id}` НЕ добавляется — ревью считается частью
«страницы загрузки», действие остаётся в ревью-потоке.
- **Деградация без JS сохраняется**: формы остаются обычными POST; при
отсутствии htmx (нет заголовка `HX-Request`) сервер отвечает прежним
PRG-редиректом, поведение не ломается.
- **Ошибки действий** показываются на месте (в подменённом фрагменте), а не
только через `?err=` после редиректа.
Новых зависимостей нет: Alpine.js/SPA не вводятся, стек остаётся htmx-first
(см. решение `2026-06-30-web-ui-design-port`).
## Capabilities
### New Capabilities
<!-- Нет новых capability — меняется поведение существующих. -->
### Modified Capabilities
- `web-ui`: действия над карточкой/страницей загрузки выполняются htmx-swap'ом
фрагмента на месте (без навигации и сброса контекста списка), с graceful
degradation на PRG-редирект без htmx.
- `review`: петлевые действия ревью (`rerecognize`, `refine`) обновляют экран
на месте htmx-свопом, не перезагружая страницу и не сбрасывая прокрутку;
выходы из ревью (`apply`/`defer`/`cancel`) остаются навигацией.
## Impact
- **Код**: `internal/httpapi` — обработчики действий (`review.go`,
`httpapi.go`): вместо `redirect*` отвечать фрагментом (HTTP 200) при
`HX-Request`, сохранив редирект-ветку; извлечь `buildCardView`/
`buildDownloadView`; новый fragment-эндпоинт `handleFragReview` для
авто-поллинга recognizing (`live.go`).
- **Шаблоны** (`web/templates`): выделить переиспользуемые фрагменты —
карточка списка (в партиал), «главная область» страницы загрузки, тело
ревью; проставить `hx-post`/`hx-target`/`hx-swap` на формы действий, скрытое
поле `surface`, поллер на блок `recognizing`.
- **Зависимости**: без изменений (htmx уже вендорится).
- **Тесты**: обработчики действий — проверка ветвления `HX-Request`
фрагмент vs редирект.
- **Вне scope**: живой поллинг телеметрии (`live-status`) не трогаем;
клиентских фреймворков не добавляем.
@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Петлевые действия ревью обновляют экран на месте
Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).
Пока загрузка в `recognizing`, экран ревью SHALL сам обновляться поллингом
htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по завершении
распознавания SHALL автоматически смениться на готовый план (список источников,
инфо и предпросмотр активного источника), без ручного обновления страницы. Как
только состояние вышло из `recognizing`, фрагмент SHALL возвращаться без
поллера, и опрос прекращается. Без htmx экран SHALL деградировать до ручной
ссылки «Обновить».
Выходы из ревью, после которых загрузка покидает `review`**Применить**
(`apply` → раскладка/`done`), **Позже** (`defer``deferred`) и **Отклонить**
(`cancel``cancelled`), — НЕ обязаны свопить экран на месте и MAY уводить с
экрана ревью навигацией (редирект/`HX-Redirect`), поскольку загрузка перестаёт
быть предметом этого экрана.
Поведение петлевых действий MUST деградировать без htmx: без заголовка
`HX-Request` обработчик SHALL исполнять то же доменное действие и отвечать
редиректом на `/review/{id}`, как раньше.
#### Scenario: Перераспознавание свопит экран в состояние recognizing
- **GIVEN** загрузка в `review`, экран ревью открыт
- **WHEN** пользователь нажимает «Распознать заново» или «Уточнить» с подсказкой
(htmx активен)
- **THEN** тело экрана ревью обновляется на месте в состояние `recognizing` с
индикацией «идёт распознавание», без полной перезагрузки и без прыжка
прокрутки наверх
- **AND** накопленные подсказки и ручные override сохраняются
#### Scenario: Экран сам обновляется до готового плана
- **GIVEN** экран ревью показывает состояние `recognizing` после петлевого
действия
- **WHEN** воркер завершает распознавание и загрузка снова в `review`
- **THEN** экран автоматически (поллингом фрагмента) сменяется на готовый план
(источники, инфо, предпросмотр), без ручного обновления
- **AND** после выхода из `recognizing` фрагмент возвращается без поллера и опрос
прекращается
#### Scenario: Выход из ревью уводит с экрана
- **GIVEN** загрузка в `review` с готовым превью
- **WHEN** пользователь нажимает «Применить», «Позже» или «Отклонить»
- **THEN** загрузка покидает `review` (соответственно `done`/`deferred`/
`cancelled`), а интерфейс уводит пользователя с экрана ревью навигацией
#### Scenario: Деградация петлевого действия без htmx
- **WHEN** «Распознать заново» или «Уточнить» приходит POST-запросом без
заголовка `HX-Request`
- **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на
`/review/{id}`, поведение без JavaScript не ломается
@@ -0,0 +1,71 @@
## ADDED Requirements
### Requirement: Действия обновляют интерфейс на месте
Мутирующие действия над загрузкой в списке (`/`) и на странице `/download/{id}` SHALL выполняться htmx-запросом и обновлять затронутую область HTML на месте (partial swap), без навигации на другую страницу и без сброса контекста списка (фильтр, поиск, страница пагинации, позиция прокрутки).
Сервер SHALL отвечать на такой запрос HTML-фрагментом обновлённой области, а не
редиректом.
Область свопа SHALL соответствовать поверхности действия: в списке — карточка
загрузки (`<article class="card">`) целиком, отражающая новое состояние, бейдж
и допустимый набор действий; на странице `/download/{id}` — содержимое
страницы, отражающее новое состояние загрузки. После свопа набор показанных
действий MUST соответствовать новому состоянию (см. «Действия соответствуют
состоянию»).
Поведение MUST деградировать без htmx: если запрос действия пришёл без признака
htmx (нет заголовка `HX-Request`), обработчик SHALL отвечать прежним
PRG-редиректом, и действие исполняется тем же доменным вызовом. Формы действий
остаются обычными POST-формами.
Ошибка действия (доменная или валидации) SHALL показываться на месте — в
подменённом фрагменте той же области, — а не только через параметр `?err=`
после редиректа; при ошибке активное состояние загрузки не меняется молча.
Ответ на htmx-запрос действия SHALL иметь статус `200` даже при ошибке действия
(иначе htmx не подменит фрагмент): сообщение об ошибке несёт сам фрагмент.
После свопа карточка SHALL оставаться на своём месте в списке, даже если её
новое состояние уже не подходит под активный фильтр; согласованность списка с
фильтром восстанавливается при следующей полной загрузке. Клиентского
переупорядочивания или пересчёта доменного состояния не выполняется.
#### Scenario: Откат из карточки списка обновляет карточку на месте
- **GIVEN** в списке есть загрузка в состоянии `done` с действием отката
- **WHEN** пользователь нажимает «Откатить» (htmx активен)
- **THEN** карточка этой загрузки подменяется на месте на её новое состояние
(`reverted`) с соответствующим бейджем и набором действий
- **AND** список не перезагружается: фильтр, поиск, страница и позиция прокрутки
сохраняются
#### Scenario: Действие со страницы загрузки оставляет на странице
- **GIVEN** открыта страница `GET /download/{id}` загрузки в состоянии `done`
- **WHEN** пользователь нажимает «Откатить» или «Привязать заново» (htmx активен)
- **THEN** содержимое страницы обновляется на месте под новое состояние
загрузки, без перехода на список и без прыжка прокрутки наверх
#### Scenario: Деградация без htmx — прежний редирект
- **WHEN** действие над загрузкой приходит POST-запросом без заголовка
`HX-Request` (htmx недоступен)
- **THEN** обработчик исполняет то же доменное действие и отвечает
PRG-редиректом, как раньше; поведение без JavaScript не ломается
#### Scenario: Ошибка действия показана на месте
- **GIVEN** пользователь запускает действие через htmx
- **WHEN** доменный вызов возвращает ошибку (например, состояние уже изменилось)
- **THEN** ответ имеет статус `200`, а сообщение об ошибке показывается в
подменённом фрагменте той же области, а не только на отдельной странице после
редиректа
- **AND** активное состояние загрузки не меняется
#### Scenario: Свопнутая карточка остаётся вне фильтра
- **GIVEN** список отфильтрован по группе состояний (например, `review`) и в нём
есть карточка загрузки
- **WHEN** действие через htmx переводит загрузку в состояние вне этого фильтра
(например, `cancelled`)
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
следующей полной загрузки списка, без клиентского переупорядочивания
@@ -0,0 +1,92 @@
## 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`): откат из списка — остаёшься в списке
(фильтр/скролл целы); откат/relink со страницы загрузки — остаёшься на ней;
rerecognize/refine — экран ревью свопится в recognizing и сам обновляется до
готового плана без тыканья; выключенный JS — прежний редирект-поток работает
- [ ] 6.3 Ревью кода (второй чекпоинт), затем sync дельт в `openspec/specs/` и
архивирование change