Нарезка задач под целевое состояние: PWA, вход, хранилище

Роадмап: web-access переименована под приложение, которое ставится на
телефон; заведена цель ready-notification — уведомление о готовности
без открытого приложения.

Беклог: десять задач. Многопользовательская цепочка (oidc-login,
record-ownership, telegram-account-link), веб (json-api-for-spa,
spa-skeleton, upload-and-status-screen, records-list-screen,
installable-pwa), уведомления через apprise и ntfy, разведка выбора
фреймворка. Очередь: долги, хранилище, вход, приложение.

Решение сменилось с htmx на SPA, поэтому conventions/web-ui.md снята
целиком и оставлена честной строкой до итога разведки. Открытые вопросы
архитектуры, границы паспорта и триггеры метки ревью приведены в
соответствие.
This commit is contained in:
av
2026-08-10 21:36:54 +03:00
parent 4d1c2bf44c
commit 115b3796e8
19 changed files with 536 additions and 160 deletions
+33 -138
View File
@@ -1,146 +1,41 @@
# Веб-UI (htmx)
# Веб-UI
Конвенция: *как* мы пишем код веб-UI — частичная замена фрагментов, опрос живых
обновлений, обработчики действий, деградация без JS, ошибки. Это правила
оформления кода (How), а не спецификация поведения — что именно UI показывает и
какие действия обязан поддерживать, живёт в спеке OpenSpec.
Конвенция: *как* мы пишем код приложения. Это правила оформления кода (How), а не
спецификация поведения — что именно приложение показывает и какие действия
обязано поддерживать, живёт в спеке OpenSpec.
**Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе.**
Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему
расходиться; правила действуют с первой страницы, которую заведём.
**Фреймворк не выбран, и до выбора эта конвенция пуста.** Здесь стоит только то,
что решено и от фреймворка не зависит; всё остальное появится по итогу разведки
`spa-framework-choice`, которая же заведёт запись в `research/` и ADR.
Прежняя редакция описывала htmx с прямым запретом на шаг сборки и реактивные
фреймворки. Решение сменилось на SPA 2026-08-10, и та редакция снята целиком:
частичная подмена фрагментов, ветвление по `HX-Request` и деградация без JS к
SPA не относятся ни одним пунктом.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md). Здесь — только особенности htmx-транспорта, без
повторения.
наружу — [errors.md](errors.md).
## Стек и границы
## Что решено
htmx-first: `gin` плюс `html/template` (рендер на сервере) плюс htmx. Ничего
сверх этого: **без шага сборки, без Node и сборщика, без реактивных
фреймворков**. htmx вендорится и раздаётся с нашего же хоста (`go:embed`,
`/static/vendor/`), без CDN.
- **Приложение — SPA**, а не страницы, отрисованные сервером. Сервер отдаёт
контракт данных, разметку собирает клиент.
- **Приложение ставится на телефон** и запускается с ярлыка: манифест, иконки,
service worker.
- **Статика вшивается в бинарник** через `go:embed` и раздаётся им же. Внешнего
веб-сервера под статику не заводим, бинарник остаётся самодостаточным.
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
выполнения нет.
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
цели [web-access](../../tasks/items/web-access.md). Без сети приложение
показывает состояние, а не пустой экран.
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель
[ready-notification](../../tasks/items/ready-notification.md).
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом.
- Свой 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.
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
внешних ресурсов времени выполнения нет.
Фреймворк, форма сборки, способ хранения состояния на клиенте, правила
разбиения на компоненты, обращение к API и показ ошибок. Всё это — предмет
`spa-framework-choice`; писать их наперёд, не зная фреймворка, значит написать
правила, которые придётся выбросить второй раз.