Files
jellybit/docs/conventions/web-ui.md
T
avandClaude Opus 4.8 576fc4e6c0 OpenSpec: влить дельты htmx-action-swap в спеки, архив change
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>
2026-07-04 14:42:06 +03:00

12 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" (эталон — 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-своп всего фрагмента удаляет старый узел вместе с его поллером и htmx process-инициализирует новый — двойного опроса нет при условии совпадения корневого 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, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.