Files
jellybit/docs/conventions/web-ui.md
T
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00

15 KiB
Raw Blame History

Веб-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_missing Note непуст и перекрыл бы сообщение.
  • При ошибке активное состояние не меняем — перечитанный view показывает прежний выбор плюс сообщение.

Живой поллинг

Паттерн живого обновления: фрагмент-эндпоинт под /fragments/... + в разметке hx-get + hx-trigger="every Ns" + hx-swap="outerHTML". Эталон — карточка списка (card, handleFragCard):

{{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).
  • outerHTML-своп всего фрагмента удаляет старый узел вместе с его поллером и htmx process-инициализирует новый — двойного опроса нет при условии совпадения корневого id (см. инвариант выше). Эфемерное состояние разметки своп не переживает: то, что должно пережить тик (раскрытый <details>), помечается hx-preserve.
  • Частота — по цене тика, и она названа числом в database.md. Поверхность с живыми цифрами качания обновляется чаще (pollFast, вровень с частотой опроса qBittorrent — быстрее источника опрашивать бессмысленно), прочие наблюдаемые — реже (pollSlow).
  • Тик ходит в БД, и это цена решения. Живые цифры берутся из 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, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.