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:
+14
-24
@@ -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` — поле
|
||||
самодокументируемо и не зависит от резолва таргета.
|
||||
|
||||
## Статика, вендоринг, кэш
|
||||
|
||||
|
||||
+7
-6
@@ -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
|
||||
@@ -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 не ломается
|
||||
|
||||
|
||||
@@ -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** карточка подменяется на месте новым состоянием и остаётся видимой до
|
||||
следующей полной загрузки списка, без клиентского переупорядочивания
|
||||
|
||||
|
||||
Reference in New Issue
Block a user