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

12 KiB
Raw Blame History

Веб-UI (htmx)

Конвенция: как мы пишем код веб-UI — частичная замена фрагментов, опрос живых обновлений, обработчики действий, деградация без JS, ошибки. Это правила оформления кода (How), а не спецификация поведения — что именно UI показывает и какие действия обязан поддерживать, живёт в спеке OpenSpec.

Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе. Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему расходиться; правила действуют с первой страницы, которую заведём.

Логирование запросов — logging.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); сырой 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).
  • Подмена всего фрагмента через outerHTML удаляет старый узел вместе с его опросчиком, и htmx заново размечает новый — двойного опроса нет при условии совпадения корневого id. Эфемерное состояние разметки подмену не переживает: то, что должно пережить тик (раскрытый <details>), помечается hx-preserve.
  • Частота — по цене тика, и она называется числом в таблице настроек ../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.
  • Шрифты и скрипты — со своего хоста, без внешних. Бинарник самодостаточен, внешних ресурсов времени выполнения нет.