## 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"}}` (обёртка вокруг содержимого `
` в `download.html`). Страница включает его, обработчик рендерит его же как св swap-ответ. - Ревью: для петлевых действий рендерим тело ревью. Переиспользуем/выделяем `{{define "review_main"}}` (содержимое `
` в `review.html`, уже включающего `review_source_block`). Каждый блок получает стабильный `id` для `hx-target`: `id="card-{{.ID}}"` на `
`, `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).