Files
jellybit/openspec/changes/htmx-action-swap/design.md
T
avandClaude Opus 4.8 2f8e6e3576 Веб-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>
2026-07-04 14:37:51 +03:00

203 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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).