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>
12 KiB
Веб-UI (htmx)
Конвенция: как мы пишем код веб-UI — частичный своп фрагментов, поллинг
живых обновлений, обработчики действий, деградация без JS, ошибки. Это правила
оформления кода (How), а не спецификация поведения — что именно UI показывает и
какие действия обязан поддерживать, живёт в OpenSpec-спеке web-ui
(### Requirement с SHALL).
Логирование запросов — logging.md (HTTP-поля, навигационные GET на
DEBUG). Трансляция доменных ошибок наружу — 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:
func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }
Обработчик действия зовёт доменную операцию одинаково в обеих ветках, а
дальше ветвится (эталон — reviewBlockAction):
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); сыройerr.Error()наружу не идёт. - Ошибку кладём в отдельное поле под ошибку действия (
ActionErrorв карточке/download_main,BlockErrorв блоке источника), не перегружая доменные поля (Note/error_msg/.Error): уtarget_missingNoteнепуст и перекрыл бы сообщение. - При ошибке активное состояние не меняем — перечитанный view показывает прежний выбор плюс сообщение.
Живой поллинг
Паттерн живого обновления: фрагмент-эндпоинт под /fragments/... + в разметке
hx-get + hx-trigger="every Ns" + hx-swap="outerHTML" (эталон —
progress/seeding, handleFragProgress/handleFragSeeding):
{{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-своп всего фрагмента удаляет старый узел вместе с его поллером и htmxprocess-инициализирует новый — двойного опроса нет при условии совпадения корневогоid(см. инвариант выше).- Данные тика — из in-memory снимка воркера (
LiveStatus.Live(infohash)), без БД/сети на каждый тик; узкий контрактLiveStatusне зависит от способа доставки (поллинг сейчас, путь к SSE оставлен изолированным). - Инвариант: браузер не опрашивает qBittorrent напрямую — только свой сервер,
который читает снимок. Поллинг статуса UI логируем на
DEBUG(рутинно-частое, см. 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, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.