# Веб-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"`. Эталон — карточка
списка (`card`, `handleFragCard`):

```html
{{define "card"}}<article class="card" id="card-{{.ID}}"
  {{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
                   hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
  ...
</article>{{end}}
```

- **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
  (карточка списка, главная область страницы), а вложенные живые регионы —
  прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
  уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
  Живые цифры приезжают вместе с корнем.
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
  участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
  больше не опрашивает. Условие определяется store-состоянием
  (`State.IsObservable()`), а не qBittorrent.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
  `200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
  поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
  бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
  собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
  `ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
  поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
  условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
  разметки своп не переживает: то, что должно пережить тик (раскрытый
  `<details>`), помечается `hx-preserve`.
- **Частота — по цене тика, и она названа числом в
  [database.md](../database.md).** Поверхность с живыми цифрами качания
  обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
  источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из 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**, без внешних хостов: бинарь самодостаточен,
  внешних ресурсов времени выполнения нет.
