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

192 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Веб-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 обязательна
Формы действий остаются обычными `<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](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`):
```html
{{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](logging.md)).
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
разметки своп не переживает: то, что должно пережить тик (раскрытый
`<details>`), помечается `hx-preserve`.
- **Частота — по цене тика, и она названа числом в
[database.md](../database.md).** Поверхность с живыми цифрами качания
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из 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=<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**, без внешних хостов: бинарь самодостаточен,
внешних ресурсов времени выполнения нет.