# Веб-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 обязательна Формы действий остаются обычными `