Конвенции: веб-UI на htmx (свопы, поллинг, деградация, ошибки)
Кросс-каттинг правила работы с интерфейсом на htmx: единый партиал = страница = фрагмент (+инвариант «корень define = элемент с целевым id»), ветвление обработчика по isHTMX, обязательная деградация без JS, ошибка на htmx-пути = HTTP 200 + фрагмент, самозавершающийся поллинг живых обновлений, своп сохраняет контекст / выход = навигация, различение поверхности полем surface, вендоринг/кэш статики. Блок «Статус» разделяет уже сделанное (reviewBlockAction, поллинг progress/seeding) и проектируемое в change htmx-action-swap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,3 +19,6 @@
|
|||||||
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через
|
- [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через
|
||||||
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах,
|
`internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах,
|
||||||
естественные ключи у деталей.
|
естественные ключи у деталей.
|
||||||
|
- [web-ui.md](web-ui.md) — веб-UI на htmx: единый партиал = страница = фрагмент,
|
||||||
|
ветвление `isHTMX`, деградация без JS, ошибка = 200 + фрагмент, самозавершающийся
|
||||||
|
поллинг, вендоринг/кэш статики.
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Веб-UI (htmx)
|
||||||
|
|
||||||
|
Конвенция: *как* мы пишем код веб-UI — частичный своп фрагментов, поллинг
|
||||||
|
живых обновлений, обработчики действий, деградация без JS, ошибки. Это правила
|
||||||
|
оформления кода (How), а не спецификация поведения — что именно UI показывает и
|
||||||
|
какие действия обязан поддерживать, живёт в OpenSpec-спеке `web-ui`
|
||||||
|
(`### Requirement` с `SHALL`).
|
||||||
|
|
||||||
|
Логирование запросов — [logging.md](logging.md) (HTTP-поля, навигационные GET на
|
||||||
|
`DEBUG`). Трансляция доменных ошибок наружу — [errors.md](errors.md) (приватный
|
||||||
|
канал = логи, публичный = сообщение + корреляционный ключ). Здесь — только
|
||||||
|
специфика htmx-транспорта, без дублирования.
|
||||||
|
|
||||||
|
> Статус. Полностью по этой конвенции сейчас сделан только своп блока источника
|
||||||
|
> на ревью (`reviewBlockAction` + партиал `review_source_block`) и поллинг
|
||||||
|
> прогресса/раздачи. Общий раскат свопа на карточку списка, страницу загрузки и
|
||||||
|
> петлю ревью (`buildCardView`/`buildDownloadView`, `surface`, `ActionError`,
|
||||||
|
> `handleFragReview`) проектируется и внедряется в change
|
||||||
|
> `openspec/changes/htmx-action-swap/` — там те же решения подробно. Ниже —
|
||||||
|
> **целевой** подход; где он ещё не в коде, помечено «будем».
|
||||||
|
|
||||||
|
## Стек и границы
|
||||||
|
|
||||||
|
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`** (`#source-block`,
|
||||||
|
`#dl-live-{id}`, `#seeding-{id}`; будем — `#card-{id}`, `#download-main`,
|
||||||
|
`#review-main`). `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
|
||||||
|
фрагмент не несёт тот же корневой `id`, следующее действие/поллер не найдёт
|
||||||
|
таргет. Разметку и `id` держим в одном партиале, чтобы страница и своп-ответ не
|
||||||
|
разъезжались.
|
||||||
|
|
||||||
|
Сборку view выносим в переиспользуемую функцию (`buildReviewView`; будем —
|
||||||
|
`buildCardView`, `buildDownloadView`) и зовём её и на полной странице, и во
|
||||||
|
фрагменте — чтобы htmx-ветка не копипастила сборку. Часть сборки сейчас ещё
|
||||||
|
инлайновая (`handleIndex` — карточка, `handleDownload` — страница); извлечение
|
||||||
|
билдеров — направление рефакторинга в `htmx-action-swap`, не свершившийся факт.
|
||||||
|
|
||||||
|
## Обработчик действия: ветвление 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()` наружу не идёт.
|
||||||
|
- Ошибку кладём в **отдельное поле** под ошибку действия (`BlockError`; будем —
|
||||||
|
`ActionError`), не перегружая доменные поля (`Note`/`error_msg`/`.Error`): у
|
||||||
|
`target_missing` `Note` непуст и перекрыл бы сообщение.
|
||||||
|
- **При ошибке активное состояние не меняем** — перечитанный view показывает
|
||||||
|
прежний выбор плюс сообщение.
|
||||||
|
|
||||||
|
## Живой поллинг
|
||||||
|
|
||||||
|
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
|
||||||
|
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"` (эталон —
|
||||||
|
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{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](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**, без внешних хостов: бинарь самодостаточен,
|
||||||
|
внешних ресурсов времени выполнения нет.
|
||||||
Reference in New Issue
Block a user