From b524485775344610f41d319773e4a4a379b30a58 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 4 Jul 2026 14:17:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86?= =?UTF-8?q?=D0=B8=D0=B8:=20=D0=B2=D0=B5=D0=B1-UI=20=D0=BD=D0=B0=20htmx=20(?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=BF=D1=8B,=20=D0=BF=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3,=20=D0=B4=D0=B5=D0=B3=D1=80=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кросс-каттинг правила работы с интерфейсом на 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) --- docs/conventions/README.md | 3 + docs/conventions/web-ui.md | 180 +++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 docs/conventions/web-ui.md diff --git a/docs/conventions/README.md b/docs/conventions/README.md index c83c64a..26a5819 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -19,3 +19,6 @@ - [database.md](database.md) — БД и идентификаторы: TEXT ULID PK через `internal/ident` (без AUTOINCREMENT), lowercase + нормализация на границах, естественные ключи у деталей. +- [web-ui.md](web-ui.md) — веб-UI на htmx: единый партиал = страница = фрагмент, + ветвление `isHTMX`, деградация без JS, ошибка = 200 + фрагмент, самозавершающийся + поллинг, вендоринг/кэш статики. diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md new file mode 100644 index 0000000..97faa21 --- /dev/null +++ b/docs/conventions/web-ui.md @@ -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 обязательна + +Формы действий остаются обычными `
`; +`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"}}
+ ... +
{{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=` — короткий + 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` (строки `<путь> `, проверка sha256). + `task build`/`task run` зависят от `task assets`. +- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен, + внешних ресурсов времени выполнения нет.