Files
avandClaude Opus 4.8 576fc4e6c0 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>
2026-07-04 14:42:06 +03:00

17 KiB
Raw Permalink Blame History

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, а undoreverted). Карточка остаётся на месте до следующей полной загрузки списка. Это осознанный компромисс в пользу «остаться в контексте»: не переупорядочиваем и не убираем карточку на клиенте (клиентского пересчёта домена нет — инвариант 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).