From 576fc4e6c00ba82343793769e3985fab121d9316 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 4 Jul 2026 14:42:06 +0300 Subject: [PATCH] =?UTF-8?q?OpenSpec:=20=D0=B2=D0=BB=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D1=8B=20htmx-action-swap=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8,=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D0=B2=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/conventions/web-ui.md | 38 ++++------ .../.openspec.yaml | 0 .../2026-07-04-htmx-action-swap}/design.md | 0 .../2026-07-04-htmx-action-swap}/proposal.md | 0 .../specs/review/spec.md | 0 .../specs/web-ui/spec.md | 0 .../2026-07-04-htmx-action-swap}/tasks.md | 13 ++-- openspec/specs/review/spec.md | 56 +++++++++++++++ openspec/specs/web-ui/spec.md | 70 +++++++++++++++++++ 9 files changed, 147 insertions(+), 30 deletions(-) rename openspec/changes/{htmx-action-swap => archive/2026-07-04-htmx-action-swap}/.openspec.yaml (100%) rename openspec/changes/{htmx-action-swap => archive/2026-07-04-htmx-action-swap}/design.md (100%) rename openspec/changes/{htmx-action-swap => archive/2026-07-04-htmx-action-swap}/proposal.md (100%) rename openspec/changes/{htmx-action-swap => archive/2026-07-04-htmx-action-swap}/specs/review/spec.md (100%) rename openspec/changes/{htmx-action-swap => archive/2026-07-04-htmx-action-swap}/specs/web-ui/spec.md (100%) rename openspec/changes/{htmx-action-swap => archive/2026-07-04-htmx-action-swap}/tasks.md (90%) diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index 97faa21..3e678d6 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -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` — поле +самодокументируемо и не зависит от резолва таргета. ## Статика, вендоринг, кэш diff --git a/openspec/changes/htmx-action-swap/.openspec.yaml b/openspec/changes/archive/2026-07-04-htmx-action-swap/.openspec.yaml similarity index 100% rename from openspec/changes/htmx-action-swap/.openspec.yaml rename to openspec/changes/archive/2026-07-04-htmx-action-swap/.openspec.yaml diff --git a/openspec/changes/htmx-action-swap/design.md b/openspec/changes/archive/2026-07-04-htmx-action-swap/design.md similarity index 100% rename from openspec/changes/htmx-action-swap/design.md rename to openspec/changes/archive/2026-07-04-htmx-action-swap/design.md diff --git a/openspec/changes/htmx-action-swap/proposal.md b/openspec/changes/archive/2026-07-04-htmx-action-swap/proposal.md similarity index 100% rename from openspec/changes/htmx-action-swap/proposal.md rename to openspec/changes/archive/2026-07-04-htmx-action-swap/proposal.md diff --git a/openspec/changes/htmx-action-swap/specs/review/spec.md b/openspec/changes/archive/2026-07-04-htmx-action-swap/specs/review/spec.md similarity index 100% rename from openspec/changes/htmx-action-swap/specs/review/spec.md rename to openspec/changes/archive/2026-07-04-htmx-action-swap/specs/review/spec.md diff --git a/openspec/changes/htmx-action-swap/specs/web-ui/spec.md b/openspec/changes/archive/2026-07-04-htmx-action-swap/specs/web-ui/spec.md similarity index 100% rename from openspec/changes/htmx-action-swap/specs/web-ui/spec.md rename to openspec/changes/archive/2026-07-04-htmx-action-swap/specs/web-ui/spec.md diff --git a/openspec/changes/htmx-action-swap/tasks.md b/openspec/changes/archive/2026-07-04-htmx-action-swap/tasks.md similarity index 90% rename from openspec/changes/htmx-action-swap/tasks.md rename to openspec/changes/archive/2026-07-04-htmx-action-swap/tasks.md index 7ae040d..921fc3b 100644 --- a/openspec/changes/htmx-action-swap/tasks.md +++ b/openspec/changes/archive/2026-07-04-htmx-action-swap/tasks.md @@ -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 diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index 629a188..bc1b42e 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -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 не ломается + diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index e22a771..9865403 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -362,3 +362,73 @@ SHALL выполняться только на рендеринге, не зат - **WHEN** `[general].timezone` содержит нераспознаваемое значение - **THEN** приложение завершается с ошибкой конфигурации на старте +### Requirement: Действия обновляют интерфейс на месте + +Мутирующие действия над загрузкой в списке (`/`) и на странице `/download/{id}` SHALL выполняться htmx-запросом и обновлять затронутую область HTML на месте (partial swap), без навигации на другую страницу и без сброса контекста списка (фильтр, поиск, страница пагинации, позиция прокрутки). +Сервер SHALL отвечать на такой запрос HTML-фрагментом обновлённой области, а не +редиректом. + +Область свопа SHALL соответствовать поверхности действия: в списке — карточка +загрузки (`
`) целиком, отражающая новое состояние, бейдж +и допустимый набор действий; на странице `/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** карточка подменяется на месте новым состоянием и остаётся видимой до + следующей полной загрузки списка, без клиентского переупорядочивания +