Нарезка задач под целевое состояние: 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:
@@ -15,11 +15,14 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## Откуда взяты и что с расхождениями
|
||||
|
||||
Все пять записей перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
||||
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
||||
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
||||
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
|
||||
шаге и дублируется воркером, доменные ошибки проверяются приведением типа.
|
||||
|
||||
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
||||
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
||||
|
||||
Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно
|
||||
как **долг, а не как нарушение**: правила действуют на новый код, переписывание
|
||||
существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что
|
||||
@@ -36,10 +39,10 @@ UUID вместо ULID, время берётся `time.Now()` по месту,
|
||||
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
|
||||
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
|
||||
ULID, разбор на входной границе, естественные ключи у деталей.
|
||||
- [web-ui.md](web-ui.md) — веб-UI на htmx: партиал равен странице равен
|
||||
фрагменту, ветвление по `HX-Request`, деградация без JS, ошибка на htmx-пути
|
||||
как 200 плюс фрагмент, самозавершающийся опрос, вендоринг статики. **Записана
|
||||
наперёд: веб-UI ещё нет.**
|
||||
- [web-ui.md](web-ui.md) — веб-UI: что решено про приложение (SPA, установка на
|
||||
телефон, статика в бинарнике) и что ждёт выбора фреймворка. **Почти пуста:**
|
||||
редакция на htmx снята 2026-08-10 вместе со сменой решения, а новую писать не
|
||||
под что до разведки `spa-framework-choice`.
|
||||
|
||||
## Механизировано
|
||||
|
||||
|
||||
+33
-138
@@ -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`; писать их наперёд, не зная фреймворка, значит написать
|
||||
правила, которые придётся выбросить второй раз.
|
||||
|
||||
Reference in New Issue
Block a user