# Веб-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 обязательна Формы действий остаются обычными `
`; `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"}}
...
{{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=` — короткий 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` (строки `<путь> `, проверка sha256). `task build`/`task run` зависят от `task assets`. - Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.