Веб-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>
This commit is contained in:
av
2026-07-04 14:37:51 +03:00
co-authored by Claude Opus 4.8
parent 5d704059ee
commit 2f8e6e3576
16 changed files with 1140 additions and 248 deletions
@@ -0,0 +1,92 @@
## 1. Извлечение переиспользуемых view-builder'ов
- [x] 1.1 Вынести сборку карточки списка в `buildCardView(d, now, live,
layoutSize)` — перенести обвязку из `handleIndex` (`httpapi.go:291-304`:
`liveFor`, `ratioText`, `sizeText`, `layoutSizes[id]`, `buildProgress`);
`handleIndex` теперь зовёт её в цикле. Билдер MUST выставлять
`IsDownloading`/`Progress` (для retry→downloading карточка обязана нести
`progress`-партиал с поллером) и `Ratio`/`Size`
- [x] 1.2 Вынести сборку страницы загрузки в `buildDownloadView(id, rd)` —
перенести инлайн-сборку из `handleDownload` (`download.go:86-142`);
`handleDownload` зовёт её. Добавить в view отдельное поле `ActionError`
- [x] 1.3 Добавить в view карточки отдельное поле `ActionError` (НЕ
переиспользовать `card-meta`/`.Error` — они несут `Note`/`error_msg`)
## 2. Партиалы и фрагменты (единый источник разметки)
- [x] 2.1 Выделить карточку в `partials/card.html` (`{{define "card"}}`) с
`id="card-{{.ID}}"` на корневом `<article>`; `index.html` — цикл через
`{{template "card" .}}`. Инвариант: корень `define` == элемент с целевым `id`
- [x] 2.2 Обернуть `<main>` страницы загрузки в `{{define "download_main"}}` с
`id="download-main"`; `download.html` включает его
- [x] 2.3 Обернуть тело ревью в `{{define "review_main"}}` с `id="review-main"`
(внутри остаётся `review_source_block`); `review.html` включает его
- [x] 2.4 В обеих карточка/`download_main` добавить разметку слота `ActionError`
(видим только при непустом значении), не конфликтуя с `Note`/error-баннером
- [x] 2.5 Проставить `hx-post`/`hx-target`/`hx-swap="outerHTML"` на свопимые
формы: карточка (`undo`/`relink`/`retry`/`cancel` → `#card-{id}`), страница
загрузки (те же → `#download-main`), петля ревью (`refine`/`rerecognize` →
`#review-main`). Выходы ревью (`apply`/`defer`/`cancel`) — обычные POST-формы
БЕЗ `hx-*` (намеренно: единственный маркер «это выход из ревью»)
- [x] 2.6 В формы общих действий (`undo`/`relink`/`retry`/`cancel`), доступных и
в списке, и на `/download/{id}`, добавить скрытое поле
`surface=list|download` — им обработчик выбирает фрагмент ответа
## 3. Авто-поллер recognizing на экране ревью
- [x] 3.1 Добавить fragment-эндпоинт `GET /fragments/downloads/{id}/review` →
`handleFragReview`, рендерит `review_main` по текущему состоянию (по образцу
`handleFragProgress`/`handleFragSeeding` в `live.go`)
- [x] 3.2 В `review_main` блок состояния `recognizing` (`review.html:33-38`)
оснастить поллером `hx-get="/fragments/downloads/{id}/review"
hx-trigger="every 2s" hx-target="#review-main" hx-swap="outerHTML"`; ручную
ссылку «Обновить» оставить как fallback без JS. Когда состояние выходит из
`recognizing`, `review_main` возвращается без поллера — опрос сам прекращается
## 4. Обработчики: ветка htmx → фрагмент
- [x] 4.1 Ввести помощник `cardAction` (по образцу `reviewBlockAction`):
доменный вызов; на `isHTMX` перечитать состояние, отрендерить `card` для
одной загрузки; на ошибке — тот же фрагмент с заполненным `ActionError` и
**HTTP 200** (иначе htmx не свопит); без htmx — прежний редирект
- [x] 4.2 Ввести помощник `downloadAction` аналогично, рендер `download_main`
(тоже 200 + `ActionError` на ошибке)
- [x] 4.3 Перевести `handleUndo`, `handleRelink`, `handleUIRetry` на выбор
помощника/фрагмента по полю `surface` (list→`cardAction`,
download→`downloadAction`); без htmx — прежний редирект
- [x] 4.4 Переписать единый `handleUICancel`: на `isHTMX` — своп по `surface`
(list→`card`, download→`download_main`); на не-htmx (в т.ч. форма выхода из
ревью и режим без JS) → редирект на `/`, как сейчас. Убрать деление «в
контексте ревью не трогать» — обработчик один
- [x] 4.5 Перевести петлевые `handleRefine`, `handleRerecognize` на рендер
`review_main` при `isHTMX` (200 + `.Error` на ошибке), сохранив
редирект-ветку на `/review/{id}` без htmx. Выходы ревью
(`handleApply`/`handleDefer`) не трогать
- [x] 4.6 Ошибки везде переводить существующими `userErr`/`classifyErr` (наружу
только нейтральный текст); секреты в логи не попадают — паритет сохранить
## 5. Тесты
- [x] 5.1 htmx-запрос (`HX-Request: true`) действия из списка (`surface=list`)
→ 200 + фрагмент `card` с новым состоянием, не редирект
- [x] 5.2 htmx-запрос действия со страницы (`surface=download`) → 200 +
`download_main`; без `HX-Request` → прежний 303-редирект
- [x] 5.3 Петля ревью: `rerecognize`/`refine` при `HX-Request` → 200 +
`review_main` в состоянии `recognizing` с поллером; без htmx → 303 на
`/review/{id}`
- [x] 5.4 `handleFragReview`: пока `recognizing` — фрагмент содержит поллер;
после перехода в `review` — фрагмент без поллера (опрос прекращается)
- [x] 5.5 Ошибка действия: htmx-ответ — **200 + сообщение в теле** (в
`ActionError`/`.Error`), состояние не меняется; выход ревью (`cancel` без
`hx-*`) — по-прежнему навигация
- [x] 5.6 retry из списка при htmx → карточка `downloading` с `progress`-поллером
## 6. Проверка и оформление
- [x] 6.1 `task lint` и `task test` зелёные
- [ ] 6.2 Ручная проверка (`task run`): откат из списка — остаёшься в списке
(фильтр/скролл целы); откат/relink со страницы загрузки — остаёшься на ней;
rerecognize/refine — экран ревью свопится в recognizing и сам обновляется до
готового плана без тыканья; выключенный JS — прежний редирект-поток работает
- [ ] 6.3 Ревью кода (второй чекпоинт), затем sync дельт в `openspec/specs/` и
архивирование change