Канон документов, каталог задач и OpenSpec

docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

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

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

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
av
2026-08-10 21:19:07 +03:00
parent a4646c0930
commit 4d1c2bf44c
40 changed files with 3656 additions and 71 deletions
+146
View File
@@ -0,0 +1,146 @@
# Веб-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.
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
внешних ресурсов времени выполнения нет.