docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью. Конвенции перенесены из jellybit; места, где код им не следует, помечены строкой «Расхождение» как объявленный долг. tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и многопользовательский режим), два направления (все форматы, долгие записи) и пять задач в беклоге. openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет. CLAUDE.md переписан по форме канона: инварианты с severity, семантика гейта, запреты с путями. Taskfile получил task gate.
12 KiB
Веб-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.
- Шрифты и скрипты — со своего хоста, без внешних. Бинарник самодостаточен, внешних ресурсов времени выполнения нет.