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` — поле
самодокументируемо и не зависит от резолва таргета.
## Статика, вендоринг, кэш