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