Files
jellybit/openspec/specs/web-ui/spec.md
T
avandClaude Opus 4.8 322bd8aa5b Заархивировал review-source-selection: дельта web-ui влита в спеки (openspec)
Влил 3 ADDED (единый список источников, ручное добавление по id/URL,
предпросмотр полей до фиксации) и 2 MODIFIED (превью для каждого источника;
матч ссылкой в списке источников) требования в openspec/specs/web-ui.
Change перенесён в changes/archive. Убрал реализованный пункт из беклога,
перецелил ссылки на review-ux.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:34:10 +03:00

365 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# web-ui Specification
## Purpose
Презентационный слой веб-интерфейса: встроенная (`go:embed`) отдача статики и
self-hosted шрифтов, единая дизайн-система (`jellybit.css`, тёмная тема по
настройке ОС), рендеринг страниц (список загрузок, ревью, просмотр) с бейджами
состояний и клиентскими взаимодействиями без сборки (копирование infohash,
спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`,
а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`,
команды — `worker`, чтение — `store`).
## Requirements
### Requirement: Встроенная отдача статики
Сервис SHALL отдавать статические ассеты (CSS, клиентский JS, вендорные
библиотеки, шрифты) из встроенной (`go:embed`) файловой системы под префиксом
`/static/`, без внешних зависимостей времени выполнения. Бинарь остаётся
единым и самодостаточным.
#### Scenario: Запрос встроенного ассета
- **WHEN** клиент запрашивает `GET /static/css/jellybit.css`
- **THEN** сервис отвечает `200` с телом из встроенной FS и корректным
`Content-Type`
#### Scenario: Кэширование статики
- **WHEN** сервис отдаёт ответ на запрос под `/static/`
- **THEN** ответ содержит заголовок `Cache-Control`, разрешающий кэширование
ассета браузером
#### Scenario: Несуществующий ассет
- **WHEN** клиент запрашивает несуществующий путь под `/static/`
- **THEN** сервис отвечает `404` и не раскрывает структуру файловой системы
### Requirement: Шрифты без внешних сетей
Веб-UI SHALL подключать шрифты (IBM Plex Sans, IBM Plex Mono) только из
локально встроенных файлов через `@font-face`. Страницы MUST NOT обращаться к
сторонним хостам (Google Fonts CDN и т. п.) для загрузки шрифтов или стилей.
#### Scenario: Нет внешних ссылок на шрифты
- **WHEN** браузер открывает любую страницу веб-UI
- **THEN** все используемые шрифты загружаются из-под `/static/` того же
origin, без обращений к внешним доменам
### Requirement: Единая дизайн-система
Все страницы веб-UI SHALL использовать единственный CSS-файл `jellybit.css` с
дизайн-токенами; страницы MUST NOT содержать инлайн-`<style>` или хардкод
цветов вне токенов. Тема (светлая/тёмная) SHALL определяться настройкой ОС
(`color-scheme` + `prefers-color-scheme`), без отдельного переключателя.
#### Scenario: Тёмная тема по настройке ОС
- **WHEN** в ОS пользователя выбрана тёмная тема и открывается страница
- **THEN** интерфейс отображается в тёмной палитре из токенов, без действий
пользователя
#### Scenario: Нет инлайн-стилей
- **WHEN** рендерится любая страница веб-UI
- **THEN** оформление берётся из подключённого `jellybit.css`, в разметке нет
блоков `<style>` с хардкодом цветов
### Requirement: Бейдж статуса загрузки
Веб-UI SHALL отображать состояние каждой загрузки бейджем
`<span class="badge st-{STATE}">` с человекочитаемой русской подписью; класс
определяет цвет группы. Маппинг SHALL покрывать все состояния домена
(`downloading`, `completed`, `recognizing`, `linking`, `review`, `deferred`,
`done`, `stuck`, `target_missing`, `failed`, `orphaned`, `cancelled`,
`reverted`, `deleted`).
#### Scenario: Состояние отрисовано бейджем
- **WHEN** загрузка находится в состоянии `review`
- **THEN** в списке у неё бейдж с классом `st-review` и подписью «на ревью»
#### Scenario: Полнота покрытия
- **WHEN** загрузка находится в любом из состояний домена
- **THEN** для него существует класс бейджа и русская подпись (неизвестных
состояний без оформления нет)
### Requirement: Страницы веб-UI
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
добавления, **серверными фильтром по группе состояний, поиском и постраничной
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`)
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
задач — секцией живой статистики раздачи. Карточки активных (downloading)
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого
обновления прогресса и наполнение секции раздачи определяются capability
`live-status`.
#### Scenario: Просмотр одной загрузки
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
историей
#### Scenario: Прогресс активной загрузки в списке
- **WHEN** в списке есть загрузка в состоянии `downloading`
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
и ETA)
#### Scenario: Удалённые скрыты по умолчанию
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
всё» не включён
- **THEN** они не отображаются, но доступны при включённом переключателе
#### Scenario: Пагинация списка
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
страницу, и клиент запрашивает `GET /?page=N`
- **THEN** возвращается N-я страница результатов и элементы навигации по
страницам, сохраняющие текущие фильтр и поисковый запрос
#### Scenario: Серверный фильтр и поиск
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
строкой поиска (`GET /?f=review&q=дюна`)
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
совпадению строки в названии/infohash/контексте), отфильтрованные на стороне
БД, а не на клиенте
### Requirement: Порядок списка загрузок
Список загрузок SHALL сортироваться по времени добавления торрента в источник
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
неизвестно, для сортировки SHALL использоваться время создания загрузки в
jellybit (`created_at`). Порядок MUST быть согласован между страницами
пагинации.
#### Scenario: Сортировка по времени добавления в источник
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
в qBittorrent
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
#### Scenario: Фолбек порядка без времени источника
- **WHEN** у загрузки нет сохранённого времени добавления в источник
- **THEN** для её позиции в списке используется время создания загрузки в
jellybit
### Requirement: Заголовок загрузки из имени раздачи
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
несколько строк сырым magnet.
#### Scenario: Заголовок из имени раздачи
- **WHEN** у загрузки сохранено отображаемое имя раздачи
- **THEN** карточка и страница показывают это имя заголовком
#### Scenario: Фолбек до распознавания и без имени
- **WHEN** отображаемого имени нет, но есть распознанное название
- **THEN** заголовком служит распознанное название
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
строки сырой источник, а не многострочный magnet
### Requirement: Матч с записью метабазы ссылкой
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
распознавания) и в едином списке источников совпадения экрана ревью (у
активного источника-кандидата). Ссылка SHALL открываться в новой вкладке с
`rel="noopener"`. Рядом со ссылкой SHALL быть видны провайдер, идентификатор
записи и (при наличии) год.
#### Scenario: Матч виден ссылкой на странице просмотра
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
запись с провайдером и id
#### Scenario: URL записи неизвестен
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
записи построить нельзя
- **THEN** матч показывается текстом (провайдер и id) без ссылки, строка списка
не ломается
### Requirement: Блок информации о торренте
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
#### Scenario: Полный источник в отдельном блоке
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
торренте», а заголовок страницы остаётся человекочитаемым именем
### Requirement: Действия соответствуют состоянию
Каждая страница SHALL показывать только действия, допустимые в текущем
состоянии загрузки, и каждое действие-кнопка SHALL отправлять форму с полями,
имена которых совпадают с ожидаемыми обработчиком (`internal/httpapi`).
#### Scenario: Набор действий по состоянию
- **WHEN** загрузка в состоянии `done`
- **THEN** доступно действие отката (undo), но недоступны «применить»/«позже»
#### Scenario: Имена полей формы
- **WHEN** пользователь отправляет форму действия (например, «уточнить»)
- **THEN** поля формы имеют `name`, которые обработчик читает без
переименования, и действие исполняется
### Requirement: Превью раскладки через единую логику именования
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
каждого источника в списке** (нейронка, кандидат базы, добавленный вручную) —
эфемерно на сервере, без записи сохранённого матча. Показанные для источника
пути MUST совпадать с теми, что создались бы при выборе этого источника и
применении.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для источника
- **THEN** эти пути идентичны тем, что создаст применение при выборе этого
источника (те же правила имён, спецвыпусков, мультифайла, запрещённых
символов, тега провайдера и коллизий)
#### Scenario: Переключение источника не тянет чужие поля
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
источник без собственных названия/года (нейронка или ручной кандидат)
- **WHEN** строится превью и затем выполняется применение выбранного источника
- **THEN** и превью, и применение используют название/год этого источника
(из плана распознавания), без унаследованных от прежнего кандидата
### Requirement: Клиентские взаимодействия без сборки
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
фреймворков: копирование infohash (vanilla JS) и раскрытие контекста нативным
`<details>`. Все действия над загрузкой SHALL выполняться через формы/htmx
(раундтрип на сервер), без клиентского пересчёта доменного состояния.
#### Scenario: Копирование infohash
- **WHEN** пользователь нажимает кнопку копирования рядом с infohash
- **THEN** значение infohash копируется в буфер обмена без перезагрузки
страницы
#### Scenario: Раскрытие контекста без JS
- **WHEN** пользователь раскрывает спойлер переданного контекста
- **THEN** контекст показывается нативным `<details>`, без скриптов
### Requirement: Единый список источников совпадения на ревью
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Экран SHALL позволять как операции над этим списком: выбрать
кандидата базы, переключиться на другого кандидата и снять матч с базой
обратно на нейронку («без базы»). Смена активного источника SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского
пересчёта доменного состояния. Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
несколькими кандидатами метабаз
- **WHEN** пользователь открывает `GET /review/{id}`
- **THEN** источники показаны единым списком, где строка «распознано
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Переключение между кандидатами
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь выбирает другого кандидата из списка
- **THEN** активным становится выбранный кандидат, прочие — неактивны
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь выбирает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
от прежнего кандидата название/год
### Requirement: Ручное добавление источника по id или URL
Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить
источник вручную — по идентификатору записи метабазы или, где применимо, по
её URL. Ввод SHALL разбираться и валидироваться в пару
`(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые
провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в
списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим
источником новая строка NOT создаётся, а выбирается существующая.
Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный
источник.
#### Scenario: Добавление кандидата по URL TMDB
- **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов
- **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление
- **THEN** из URL извлекаются провайдер и id, источник добавляется в список
выбираемой строкой
#### Scenario: Дубль id выбирает существующую строку
- **GIVEN** в списке уже есть кандидат с данным `provider:id`
- **WHEN** пользователь добавляет вручную тот же `provider:id`
- **THEN** новая строка не создаётся, активным становится существующий
кандидат
#### Scenario: Некорректный ввод отклонён
- **WHEN** пользователь вводит нераспознаваемый id/URL
- **THEN** экран показывает сообщение об ошибке и не меняет текущий активный
источник
### Requirement: Предпросмотр полей источника до фиксации выбора
Экран ревью SHALL показывать для рассматриваемого источника (нейронка,
кандидат базы или добавленный вручную) **поля** результата — тип, название,
год, с зарезервированным местом под режиссёра. Показ полей источника
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
сохранённый матч меняется только явным выбором источника, а раскладка —
только действием «Применить». Совпадение целевых путей предпросмотра с
результатом применения регулируется требованием «Превью раскладки через
единую логику именования».
#### Scenario: Предпросмотр полей без фиксации выбора
- **GIVEN** список источников на экране ревью
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
- **THEN** показаны поля результата (тип, название, год) для этого источника
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается предпросмотр полей источника
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
пустым (или прочерком), не ломая вёрстку