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>
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user