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:
av
2026-07-04 14:42:06 +03:00
co-authored by Claude Opus 4.8
parent 2f8e6e3576
commit 576fc4e6c0
9 changed files with 147 additions and 30 deletions
+14 -24
View File
@@ -11,14 +11,6 @@
канал = логи, публичный = сообщение + корреляционный ключ). Здесь — только
специфика htmx-транспорта, без дублирования.
> Статус. Полностью по этой конвенции сейчас сделан только своп блока источника
> на ревью (`reviewBlockAction` + партиал `review_source_block`) и поллинг
> прогресса/раздачи. Общий раскат свопа на карточку списка, страницу загрузки и
> петлю ревью (`buildCardView`/`buildDownloadView`, `surface`, `ActionError`,
> `handleFragReview`) проектируется и внедряется в change
> `openspec/changes/htmx-action-swap/` — там те же решения подробно. Ниже —
> **целевой** подход; где он ещё не в коде, помечено «будем».
## Стек и границы
htmx-first: `chi` + `html/template` (server-rendered) + htmx. Ничего сверх этого:
@@ -41,18 +33,16 @@ htmx-first: `chi` + `html/template` (server-rendered) + htmx. Ничего св
**и** как ответ-фрагмент того же обработчика (`s.render(w, "name", view)`).
Отдельного markup для фрагмента не заводим — иначе он дрейфует от страницы.
**Инвариант: корень `{{define}}` — это элемент с целевым `id`** (`#source-block`,
`#dl-live-{id}`, `#seeding-{id}`; будем — `#card-{id}`, `#download-main`,
`#review-main`). `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
**Инвариант: корень `{{define}}` — это элемент с целевым `id`** (`#card-{id}`,
`#download-main`, `#review-main`, `#source-block`, `#dl-live-{id}`,
`#seeding-{id}`). `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
фрагмент не несёт тот же корневой `id`, следующее действие/поллер не найдёт
таргет. Разметку и `id` держим в одном партиале, чтобы страница и своп-ответ не
разъезжались.
Сборку view выносим в переиспользуемую функцию (`buildReviewView`; будем —
`buildCardView`, `buildDownloadView`) и зовём её и на полной странице, и во
фрагменте — чтобы htmx-ветка не копипастила сборку. Часть сборки сейчас ещё
инлайновая (`handleIndex` — карточка, `handleDownload` — страница); извлечение
билдеров — направление рефакторинга в `htmx-action-swap`, не свершившийся факт.
Сборку view выносим в переиспользуемую функцию (`buildCardView`,
`buildDownloadView`, `buildReviewView`) и зовём её и на полной странице, и во
фрагменте — чтобы htmx-ветка не копипастила сборку.
## Обработчик действия: ветвление htmx / редирект
@@ -103,9 +93,10 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
- Сообщение — нейтральный текст публичного канала через `userErr`/`classifyErr`
(см. [errors.md](errors.md)); сырой `err.Error()` наружу не идёт.
- Ошибку кладём в **отдельное поле** под ошибку действия (`BlockError`; будем —
`ActionError`), не перегружая доменные поля (`Note`/`error_msg`/`.Error`): у
`target_missing` `Note` непуст и перекрыл бы сообщение.
- Ошибку кладём в **отдельное поле** под ошибку действия (`ActionError` в
карточке/`download_main`, `BlockError` в блоке источника), не перегружая
доменные поля (`Note`/`error_msg`/`.Error`): у `target_missing` `Note` непуст и
перекрыл бы сообщение.
- **При ошибке активное состояние не меняем** — перечитанный view показывает
прежний выбор плюс сообщение.
@@ -152,16 +143,15 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
промежуточное состояние — `recognizing` у `rerecognize`/`refine`, работу
доделывает воркер), своп отдаёт **промежуточное** состояние, а не мнимый
результат; готовый итог догоняем самозавершающимся поллером (будем —
`handleFragReview`, опрос `recognizing` до `review`). Не обещаем в UI мгновенный
итог async-операции.
результат; готовый итог догоняем самозавершающимся поллером (`handleFragReview`,
опрос `recognizing` до `review`). Не обещаем в UI мгновенный итог async-операции.
## Различение поверхности одного действия
Если один роут действия зовут с разных страниц и своп-ответ должен быть разным
фрагментом (карточка списка vs `download_main`), различаем **явным скрытым полем
формы** `surface=list|download` (будем), а не эвристикой по `HX-Target`/`Referer`
— поле самодокументируемо и не зависит от резолва таргета.
формы** `surface=list|download`, а не эвристикой по `HX-Target`/`Referer` — поле
самодокументируемо и не зависит от резолва таргета.
## Статика, вендоринг, кэш
@@ -84,9 +84,10 @@
## 6. Проверка и оформление
- [x] 6.1 `task lint` и `task test` зелёные
- [ ] 6.2 Ручная проверка (`task run`): откат из списка — остаёшься в списке
(фильтр/скролл целы); откат/relink со страницы загрузки — остаёшься на ней;
rerecognize/refine — экран ревью свопится в recognizing и сам обновляется до
готового плана без тыканья; выключенный JS — прежний редирект-поток работает
- [ ] 6.3 Ревью кода (второй чекпоинт), затем sync дельт в `openspec/specs/` и
архивирование change
- [ ] 6.2 Ручная проверка (`task run`) — ОТЛОЖЕНА до окружения с
qBittorrent/LLM/конфигом. Рендер всех страниц и фрагментов и ветвление
htmx/redirect покрыты хендлер-тестами через реальные шаблоны
(`action_swap_test.go`); живой браузерный прогон (сохранение скролла, живой
своп) — за пользователем.
- [x] 6.3 Ревью кода (второй чекпоинт, сабагентом — критичных багов нет,
minor-замечания закрыты), sync дельт в `openspec/specs/`, архивирование change
+56
View File
@@ -200,3 +200,59 @@ movie↔series: тип показывается read-only, а его корре
- **WHEN** пользователь в Telegram выбирает «В вебе»
- **THEN** бот даёт deep-link на страницу ревью той же загрузки
### Requirement: Петлевые действия ревью обновляют экран на месте
Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»).
Пока загрузка в `recognizing`, экран ревью SHALL сам обновляться поллингом
htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по завершении
распознавания SHALL автоматически смениться на готовый план (список источников,
инфо и предпросмотр активного источника), без ручного обновления страницы. Как
только состояние вышло из `recognizing`, фрагмент SHALL возвращаться без
поллера, и опрос прекращается. Без htmx экран SHALL деградировать до ручной
ссылки «Обновить».
Выходы из ревью, после которых загрузка покидает `review`**Применить**
(`apply` → раскладка/`done`), **Позже** (`defer``deferred`) и **Отклонить**
(`cancel``cancelled`), — НЕ обязаны свопить экран на месте и MAY уводить с
экрана ревью навигацией (редирект/`HX-Redirect`), поскольку загрузка перестаёт
быть предметом этого экрана.
Поведение петлевых действий MUST деградировать без htmx: без заголовка
`HX-Request` обработчик SHALL исполнять то же доменное действие и отвечать
редиректом на `/review/{id}`, как раньше.
#### Scenario: Перераспознавание свопит экран в состояние recognizing
- **GIVEN** загрузка в `review`, экран ревью открыт
- **WHEN** пользователь нажимает «Распознать заново» или «Уточнить» с подсказкой
(htmx активен)
- **THEN** тело экрана ревью обновляется на месте в состояние `recognizing` с
индикацией «идёт распознавание», без полной перезагрузки и без прыжка
прокрутки наверх
- **AND** накопленные подсказки и ручные override сохраняются
#### Scenario: Экран сам обновляется до готового плана
- **GIVEN** экран ревью показывает состояние `recognizing` после петлевого
действия
- **WHEN** воркер завершает распознавание и загрузка снова в `review`
- **THEN** экран автоматически (поллингом фрагмента) сменяется на готовый план
(источники, инфо, предпросмотр), без ручного обновления
- **AND** после выхода из `recognizing` фрагмент возвращается без поллера и опрос
прекращается
#### Scenario: Выход из ревью уводит с экрана
- **GIVEN** загрузка в `review` с готовым превью
- **WHEN** пользователь нажимает «Применить», «Позже» или «Отклонить»
- **THEN** загрузка покидает `review` (соответственно `done`/`deferred`/
`cancelled`), а интерфейс уводит пользователя с экрана ревью навигацией
#### Scenario: Деградация петлевого действия без htmx
- **WHEN** «Распознать заново» или «Уточнить» приходит POST-запросом без
заголовка `HX-Request`
- **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на
`/review/{id}`, поведение без JavaScript не ломается
+70
View File
@@ -362,3 +362,73 @@ SHALL выполняться только на рендеринге, не зат
- **WHEN** `[general].timezone` содержит нераспознаваемое значение
- **THEN** приложение завершается с ошибкой конфигурации на старте
### Requirement: Действия обновляют интерфейс на месте
Мутирующие действия над загрузкой в списке (`/`) и на странице `/download/{id}` SHALL выполняться htmx-запросом и обновлять затронутую область HTML на месте (partial swap), без навигации на другую страницу и без сброса контекста списка (фильтр, поиск, страница пагинации, позиция прокрутки).
Сервер SHALL отвечать на такой запрос HTML-фрагментом обновлённой области, а не
редиректом.
Область свопа SHALL соответствовать поверхности действия: в списке — карточка
загрузки (`<article class="card">`) целиком, отражающая новое состояние, бейдж
и допустимый набор действий; на странице `/download/{id}` — содержимое
страницы, отражающее новое состояние загрузки. После свопа набор показанных
действий MUST соответствовать новому состоянию (см. «Действия соответствуют
состоянию»).
Поведение MUST деградировать без htmx: если запрос действия пришёл без признака
htmx (нет заголовка `HX-Request`), обработчик SHALL отвечать прежним
PRG-редиректом, и действие исполняется тем же доменным вызовом. Формы действий
остаются обычными POST-формами.
Ошибка действия (доменная или валидации) SHALL показываться на месте — в
подменённом фрагменте той же области, — а не только через параметр `?err=`
после редиректа; при ошибке активное состояние загрузки не меняется молча.
Ответ на htmx-запрос действия SHALL иметь статус `200` даже при ошибке действия
(иначе htmx не подменит фрагмент): сообщение об ошибке несёт сам фрагмент.
После свопа карточка SHALL оставаться на своём месте в списке, даже если её
новое состояние уже не подходит под активный фильтр; согласованность списка с
фильтром восстанавливается при следующей полной загрузке. Клиентского
переупорядочивания или пересчёта доменного состояния не выполняется.
#### Scenario: Откат из карточки списка обновляет карточку на месте
- **GIVEN** в списке есть загрузка в состоянии `done` с действием отката
- **WHEN** пользователь нажимает «Откатить» (htmx активен)
- **THEN** карточка этой загрузки подменяется на месте на её новое состояние
(`reverted`) с соответствующим бейджем и набором действий
- **AND** список не перезагружается: фильтр, поиск, страница и позиция прокрутки
сохраняются
#### Scenario: Действие со страницы загрузки оставляет на странице
- **GIVEN** открыта страница `GET /download/{id}` загрузки в состоянии `done`
- **WHEN** пользователь нажимает «Откатить» или «Привязать заново» (htmx активен)
- **THEN** содержимое страницы обновляется на месте под новое состояние
загрузки, без перехода на список и без прыжка прокрутки наверх
#### Scenario: Деградация без htmx — прежний редирект
- **WHEN** действие над загрузкой приходит POST-запросом без заголовка
`HX-Request` (htmx недоступен)
- **THEN** обработчик исполняет то же доменное действие и отвечает
PRG-редиректом, как раньше; поведение без JavaScript не ломается
#### Scenario: Ошибка действия показана на месте
- **GIVEN** пользователь запускает действие через htmx
- **WHEN** доменный вызов возвращает ошибку (например, состояние уже изменилось)
- **THEN** ответ имеет статус `200`, а сообщение об ошибке показывается в
подменённом фрагменте той же области, а не только на отдельной странице после
редиректа
- **AND** активное состояние загрузки не меняется
#### Scenario: Свопнутая карточка остаётся вне фильтра
- **GIVEN** список отфильтрован по группе состояний (например, `review`) и в нём
есть карточка загрузки
- **WHEN** действие через htmx переводит загрузку в состояние вне этого фильтра
(например, `cancelled`)
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
следующей полной загрузки списка, без клиентского переупорядочивания