Files
avandClaude Opus 4.8 576fc4e6c0 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>
2026-07-04 14:42:06 +03:00

171 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Веб-UI (htmx)
Конвенция: *как* мы пишем код веб-UI — частичный своп фрагментов, поллинг
живых обновлений, обработчики действий, деградация без JS, ошибки. Это правила
оформления кода (How), а не спецификация поведения — что именно UI показывает и
какие действия обязан поддерживать, живёт в OpenSpec-спеке `web-ui`
(`### Requirement` с `SHALL`).
Логирование запросов — [logging.md](logging.md) (HTTP-поля, навигационные GET на
`DEBUG`). Трансляция доменных ошибок наружу — [errors.md](errors.md) (приватный
канал = логи, публичный = сообщение + корреляционный ключ). Здесь — только
специфика htmx-транспорта, без дублирования.
## Стек и границы
htmx-first: `chi` + `html/template` (server-rendered) + htmx. Ничего сверх этого:
**без шага сборки, без Node/бандлера, без реактивных фреймворков**. htmx
вендорится и самохостится (`go:embed`, `/static/vendor/`), без CDN.
- Свой JS сведён к минимуму — `web/static/js/app.js` несёт только то, что серверу
знать не нужно (`copyHash` в буфер обмена). **Клиентского пересчёта доменного
состояния нет** — состояние считает сервер, клиент только свопит присланную
разметку.
- Alpine.js/SPA сознательно **не вводим**. Решение зафиксировано в
`openspec/changes/archive/2026-06-30-web-ui-design-port/design.md` (D3/D6):
Alpine добавим отдельным change только когда понадобится реактивный клиентский
виджет (ручная раскладка файл→серия), не раньше.
## Единый источник разметки: партиал = страница = фрагмент
Переиспользуемый кусок — это `{{define "name"}}` в `web/templates/partials/`. Тот
же `{{define}}` рендерится **и** инлайн на странице (`{{template "name" .}}`),
**и** как ответ-фрагмент того же обработчика (`s.render(w, "name", view)`).
Отдельного markup для фрагмента не заводим — иначе он дрейфует от страницы.
**Инвариант: корень `{{define}}` — это элемент с целевым `id`** (`#card-{id}`,
`#download-main`, `#review-main`, `#source-block`, `#dl-live-{id}`,
`#seeding-{id}`). `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
фрагмент не несёт тот же корневой `id`, следующее действие/поллер не найдёт
таргет. Разметку и `id` держим в одном партиале, чтобы страница и своп-ответ не
разъезжались.
Сборку view выносим в переиспользуемую функцию (`buildCardView`,
`buildDownloadView`, `buildReviewView`) и зовём её и на полной странице, и во
фрагменте — чтобы htmx-ветка не копипастила сборку.
## Обработчик действия: ветвление htmx / редирект
htmx-запрос определяем по заголовку — `HX-Request: true`:
```go
func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }
```
Обработчик действия зовёт доменную операцию **одинаково** в обеих ветках, а
дальше ветвится (эталон — `reviewBlockAction`):
```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
if !isHTMX(r) {
redirectReview(w, r, id, msg) // без htmx — прежний PRG-редирект (303)
return
}
rd, _ := s.deps.Reviewer.ReviewData(r.Context(), id) // перечитать актуальное состояние
view := buildReviewView(id, rd, "") // тем же view-builder'ом
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id) // ошибка → отдельное поле
}
s.render(w, "review_source_block", view) // фрагмент = тот же {{define}}
```
`s.render` (`render.go`) рендерит именованный шаблон **в буфер** и только затем
пишет ответ — при ошибке шаблона клиент не получит «полустраницу».
## Graceful degradation обязательна
Формы действий остаются обычными `<form method="post" action="...">`;
`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же форму.
Без JS всё работает через POST + редирект (PRG). Это инвариант web-ui «действия
работают без JavaScript» — не нарушать: `action` формы всегда рабочий фолбэк, а
не декорация.
Фильтр, поиск и пагинация списка — **серверные** (GET-параметры `f`/`q`/`page`/
`all`), тоже без JS. Клиентской фильтрации нет намеренно.
## Ошибки на htmx-пути: HTTP 200 + фрагмент
htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**. Поэтому при ошибке
действия обработчик отвечает **200 с фрагментом**, несущим сообщение (эталон —
`BlockError` в `reviewBlockAction`). Доменную ошибку на htmx-пути **не**
транслируем в HTTP-статус (в отличие от REST API и no-JS редиректа с `?err=`).
- Сообщение — нейтральный текст публичного канала через `userErr`/`classifyErr`
(см. [errors.md](errors.md)); сырой `err.Error()` наружу не идёт.
- Ошибку кладём в **отдельное поле** под ошибку действия (`ActionError` в
карточке/`download_main`, `BlockError` в блоке источника), не перегружая
доменные поля (`Note`/`error_msg`/`.Error`): у `target_missing` `Note` непуст и
перекрыл бы сообщение.
- **При ошибке активное состояние не меняем** — перечитанный view показывает
прежний выбор плюс сообщение.
## Живой поллинг
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"` (эталон —
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`):
```html
{{define "progress"}}<div id="dl-live-{{.ID}}"
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
...
</div>{{end}}
```
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active`
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading`
для прогресса), а не qBittorrent.
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
условии совпадения корневого `id`** (см. инвариант выше).
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа
доставки (поллинг сейчас, путь к SSE оставлен изолированным).
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
см. [logging.md](logging.md)).
## Своп сохраняет контекст; выход — навигация
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные фильтр/
поиск/пагинацию (они в query). Действие **не должно уводить** пользователя со
страницы, если предмет остаётся на ней (выбор источника, уточнение) — своп на
месте.
Действие, после которого предмет **покидает** страницу (`apply`/`defer`/`cancel`
на ревью — загрузка уходит с экрана), остаётся **обычной POST-формой без `hx-*`**
→ полная навигация/редирект. Маркер «это выход» — форма без htmx-атрибутов; так
не нужен `HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
промежуточное состояние — `recognizing` у `rerecognize`/`refine`, работу
доделывает воркер), своп отдаёт **промежуточное** состояние, а не мнимый
результат; готовый итог догоняем самозавершающимся поллером (`handleFragReview`,
опрос `recognizing` до `review`). Не обещаем в UI мгновенный итог async-операции.
## Различение поверхности одного действия
Если один роут действия зовут с разных страниц и своп-ответ должен быть разным
фрагментом (карточка списка vs `download_main`), различаем **явным скрытым полем
формы** `surface=list|download`, а не эвристикой по `HX-Target`/`Referer` — поле
самодокументируемо и не зависит от резолва таргета.
## Статика, вендоринг, кэш
- Ассеты встроены `go:embed` (`web/web.go`: `templates static`), отдаются под
`/static/` с длинным иммутабельным кэшем (`staticHandler`:
`Cache-Control: public, max-age=31536000, immutable`).
- Меняемые ассеты (css/js) версионируются через `?v=<assetVersion>` — короткий
sha256 их содержимого (`assetVersion`), URL строит FuncMap-хелпер
`{{asset "css/jellybit.css"}}`. Свежий деплой не отдаёт устаревший файл.
- Вендор (htmx 2.0.4, шрифты IBM Plex) адресуется по **неизменному имени файла**
и версионировать через `?v=` не нужен. В git его **не коммитим** (`.gitignore`);
`task assets` идемпотентно добывает его в `web/static/vendor/` по манифесту
`web/assets.manifest` (строки `<путь> <url> <sha256>`, проверка sha256).
`task build`/`task run` зависят от `task assets`.
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен,
внешних ресурсов времени выполнения нет.