Files
transcriber/docs/conventions/web-ui.md
T
av 4d1c2bf44c Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
2026-08-10 21:19:07 +03:00

147 lines
12 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.
**Взято из проекта 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 обязательна
Формы действий остаются обычными `<form method="post" action="...">`, а
`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`**. Эфемерное состояние разметки подмену не
переживает: то, что должно пережить тик (раскрытый `<details>`), помечается
`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.
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
внешних ресурсов времени выполнения нет.