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>
17 KiB
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удаляет старый узел с его поллером и htmxprocess-инициализирует поллеры нового фрагмента — двойного опроса нет. Условие корректности — тот же корневой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).