# Веб-UI (htmx) Конвенция: *как* мы пишем код веб-UI — частичная замена фрагментов, опрос живых обновлений, обработчики действий, деградация без JS, ошибки. Это правила оформления кода (How), а не спецификация поведения — что именно UI показывает и какие действия обязан поддерживать, живёт в спеке OpenSpec. **Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе.** Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему расходиться; правила действуют с первой страницы, которую заведём. Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок наружу — [errors.md](errors.md). Здесь — только особенности htmx-транспорта, без повторения. ## Стек и границы htmx-first: `gin` плюс `html/template` (рендер на сервере) плюс htmx. Ничего сверх этого: **без шага сборки, без Node и сборщика, без реактивных фреймворков**. htmx вендорится и раздаётся с нашего же хоста (`go:embed`, `/static/vendor/`), без CDN. - Свой JS сведён к минимуму: только то, чего серверу знать не нужно. **Клиентского пересчёта доменного состояния нет** — состояние считает сервер, клиент лишь подменяет присланную разметку. - Alpine.js и SPA сознательно **не вводим**. Понадобится реактивный клиентский виджет — вводим отдельным изменением и записываем решение, не раньше. ## Единый источник разметки: партиал равен странице равен фрагменту Переиспользуемый кусок — это `{{define "name"}}` в каталоге партиалов. Тот же `{{define}}` рендерится **и** внутри страницы (`{{template "name" .}}`), **и** как ответ-фрагмент того же обработчика. Отдельной разметки под фрагмент не заводим — иначе она разъедется со страницей. **Инвариант: корень `{{define}}` — это элемент с целевым `id`**, например `#job-{id}`. `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не несёт тот же корневой `id`, следующее действие или опрос не найдёт цель. Разметку и `id` держим в одном партиале. Сборку данных для шаблона выносим в отдельную функцию и зовём её и на полной странице, и во фрагменте — чтобы htmx-ветка не копировала сборку. ## Обработчик действия: ветвление htmx и редирект htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик действия зовёт доменную операцию **одинаково** в обеих ветках, а дальше ветвится: без htmx — привычный редирект после POST (303); с htmx — перечитать актуальное состояние, собрать данные тем же сборщиком и отдать фрагмент. Рендер именованного шаблона идёт **в буфер** и только затем пишется в ответ: при ошибке шаблона клиент не получит полстраницы. ## Деградация без JS обязательна Формы действий остаются обычными `
`, а `hx-post`, `hx-target` и `hx-swap` лишь **накладываются сверху** на ту же форму. Без JS всё работает через POST и редирект. Атрибут `action` — рабочий запасной путь, а не украшение. Фильтр, поиск и разбиение списка на страницы — **серверные**, параметрами запроса, тоже без JS. Клиентской фильтрации нет намеренно. ## Ошибки на htmx-пути: HTTP 200 и фрагмент htmx по умолчанию **не подменяет DOM на ответы 4xx и 5xx**. Поэтому при ошибке действия обработчик отвечает **200 с фрагментом**, несущим сообщение. Доменную ошибку на htmx-пути **не** транслируем в HTTP-статус — в отличие от API и от пути без JS. - Сообщение — нейтральный текст публичного канала (см. [errors.md](errors.md)); сырой `err.Error()` наружу не идёт. - Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая доменные поля: непустое доменное поле перекрыло бы сообщение. - **При ошибке активное состояние не меняем** — перечитанные данные показывают прежний выбор плюс сообщение. ## Живой опрос Приём живого обновления: эндпоинт фрагмента плюс в разметке `hx-get`, `hx-trigger="every Ns"` и `hx-swap="outerHTML"`. Прямой предмет опроса в transcriber — карточка задачи, пока та не дошла до `done` или `failed`. - **Один опросчик на обновляемый корень.** Опрашивает себя корень поверхности, а вложенные живые области своего `hx-get` **не несут**: подмена корня уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга. - **Опросчик самозавершается.** Опрос идёт, пока предмет может измениться без участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx больше не опрашивает. Для задачи это значит: `created`, `converted` и `transcribe` наблюдаемы, `done` и `failed` — нет. - **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает `200` и фрагментом с объяснением **без `hx-*`**: htmx не подменяет DOM на `4xx` и `5xx`, поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос — бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он собой заменяет. - **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный повтор; `ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)). - **Подмена всего фрагмента через `outerHTML`** удаляет старый узел вместе с его опросчиком, и htmx заново размечает новый — двойного опроса нет **при условии совпадения корневого `id`**. Эфемерное состояние разметки подмену не переживает: то, что должно пережить тик (раскрытый `
`), помечается `hx-preserve`. - **Частота — по цене тика, и она называется числом** в таблице настроек [../database.md](../database.md) в тот же момент, когда заводится первый опрашиваемый экран; сегодня такой настройки нет. Опрашивать чаще, чем меняется источник, бессмысленно: задачу двигает воркер с шагом в секунду. - **Тик ходит в БД, и это цена решения.** Читать состояние задачи дешевле, чем держать снимок в памяти, но каждый открытый браузер добавляет запросов. ## Подмена сохраняет контекст; выход — навигация `hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные фильтр, поиск и страницу (они в параметрах запроса). Действие **не должно уводить** пользователя со страницы, если предмет остаётся на ней. Действие, после которого предмет **покидает** страницу (удаление записи), остаётся **обычной POST-формой без `hx-*`**, то есть полной навигацией. Признак «это выход» — форма без htmx-атрибутов; так не нужен `HX-Redirect`, а «уйти с экрана» выражено самой навигацией. **Асинхронные действия.** Доменное действие асинхронно почти всегда: загрузка записи только заводит задачу, работу доделывают воркеры. Подмена отдаёт **промежуточное** состояние, а не мнимый результат; готовый итог догоняем самозавершающимся опросом. Мгновенный итог в UI не обещаем. ## Различение поверхности одного действия Один и тот же роут действия, вызванный с разных страниц, отдаёт разные фрагменты. Различаем **явным скрытым полем формы** `surface=list|detail`, а не догадкой по `HX-Target` или `Referer`: поле самодокументируемо и не зависит от разрешения цели. ## Статика, вендоринг, кэш - Ресурсы встроены через `go:embed`, отдаются под `/static/` с длинным неизменяемым кэшем (`Cache-Control: public, max-age=31536000, immutable`). - Меняемые ресурсы (css, js) версионируются параметром `?v=<версия>` — коротким sha256 их содержимого, URL строит помощник шаблона. Свежая выкладка не отдаёт устаревший файл. - Вендор (htmx, шрифты) адресуется по **неизменному имени файла**, и параметр версии ему не нужен. В git его **не коммитим**; задача сборки идемпотентно добывает его по манифесту со сверкой sha256. - Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен, внешних ресурсов времени выполнения нет.