Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)

Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 08:12:28 +03:00
co-authored by Claude Opus 4.8
parent 2ed9c9020f
commit 1f4267a046
17 changed files with 942 additions and 157 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-04
@@ -0,0 +1,177 @@
## Context
Экран ревью (`web/templates/review.html` + `internal/httpapi/review.go`) сейчас
состоит из трёх секций:
- **Догадка** — тип (переключатель movie↔series, POST `/type`), название, год;
- **Источник совпадения** — единый список вариантов (`.Sources`); у каждого
не-активного варианта — нативный `<details>` с предпросмотром раскладки, и
отдельная кнопка «выбрать» (POST `/candidate` или `/nobase`); ниже — форма
ручного добавления (POST `/source`);
- **Раскладка** — та же таблица предпросмотра, но для активного источника.
Все действия — обычные `<form method=post>` c PRG-редиректом (`reviewAction`
`redirectReview`, `303`). htmx подключён (`review.html:8`), но на странице ревью
не используется; на других страницах он уже применяется для фрагментов
(`hx-get .../progress`, `every 3s`).
Домен уже отдаёт всё нужное: `worker.ReviewData` строит `Sources []SourceOption`
(нейронка + кандидаты), у каждого — эффективные поля и эфемерный предпросмотр;
верхнеуровневые поля `reviewView` (`Title/Year/Files/...`) уже соответствуют
**активному** источнику. Инвариант «превью == применённое» обеспечивается тем,
что выбор источника пишет те же пины, что показаны в превью.
## Goals / Non-Goals
**Goals:**
- Слить три секции в один блок: список вариантов (радио) → инфо о выбранном →
предпросмотр раскладки выбранного.
- Выбор варианта — одним кликом/тапом по строке; инфо и предпросмотр
обновляются немедленно, без полной перезагрузки (htmx частичный своп блока).
- Тип показывать read-only; убрать переключатель типа с веб-экрана.
- Сохранить: ручное добавление источника, инвариант «превью == применённое»,
тонкость транспорта (доменная логика в `worker` не трогается).
**Non-Goals:**
- Менять доменный слой `internal/worker/review.go` (выбор источника, построение
плана/превью остаются как есть).
- Менять команду `SetType` в домене и её доступность в Telegram (убираем только
веб-контрол).
- Клиентский рефреймворк/сборка. Оптимизация «не считать превью для не-активных
источников» — отдельная будущая задача, не входит сюда.
## Decisions
### Решение 1: htmx частичный своп единого блока (не полная перезагрузка)
Выделяем единый блок в партиал `web/templates/partials/review_source_block.html`
с корневым контейнером `id="source-block"`. Партиал рендерится из того же
`reviewView`: список радио из `.Sources`, инфо — из верхнеуровневых полей
активного источника (`.Title/.OriginalTitle/.Year/.IsSeries/.SeasonSummary`),
предпросмотр — из `.Files`.
Радиокнопка варианта несёт htmx-атрибуты: `hx-trigger="change"`,
`hx-target="#source-block"`, `hx-swap="outerHTML"` и `hx-post` на эндпоинт
выбора. Клик по строке (label оборачивает кликабельную зону строки) переключает
радио → `change` → POST → сервер сохраняет выбор и возвращает **свежий партиал
блока** → htmx подменяет блок. Инфо и предпросмотр в новом партиале уже
относятся к новому активному источнику.
Все радио вариантов имеют **общий `name`** для взаимной эксклюзивности;
кандидатские несут `value`=`candidate_id` и постят на `/candidate`, нейронка —
пустое `value` и постит на `/nobase` (тот `candidate_id` игнорирует).
**Внешняя ссылка «запись ↗»** у кандидата (открывается в новой вкладке) НЕ
должна попадать в кликабельную зону label — иначе клик по ссылке заодно
переключит источник. Выносим ссылку из `<label>` (или гасим всплытие клика),
чтобы «перейти к записи» и «выбрать источник» не конфликтовали.
**Почему так, а не клиентское переключение:** сохранение выбора — доменная
операция (пишет override/пины), поэтому нужен раундтрип; после него активный
источник и его превью пересчитываются на сервере единой логикой (инвариант
«превью == применённое» держится сам собой). Чистый клиентский свитч потребовал
бы дублировать превью-логику и рассинхронизировался бы с применением.
**Альтернатива (отклонено):** заранее рендерить инфо+превью всех источников и
показывать активный через CSS/JS без запроса. Отклонено: выбор не сохранялся бы,
«Применить» не знал бы что применять, и вернулась бы рассинхронизация
превью/применения.
### Решение 2: эндпоинты выбора становятся htmx-aware, без новых роутов
Переиспользуем существующие POST-эндпоинты `/candidate`, `/nobase`, `/source`.
Радио кандидата постит на `/candidate` (поле `candidate_id` = value радио), радио
нейронки — на `/nobase`, форма ручного добавления — на `/source` (тоже
`hx-post`, target = `#source-block`).
Хендлеры (`handleChooseCandidate`, `handleNoBase`, `handleAddSource`) после
успешной доменной операции определяют htmx-запрос по заголовку `HX-Request` и:
- при htmx — перечитывают `ReviewData`, рендерят **партиал блока** (`200`);
- без htmx (фолбек) — как сейчас, PRG-редирект на `/review/{id}`.
Это **новый паттерн** для проекта: существующие живые партиалы (`progress`,
`seeding`) работают через отдельные GET-роуты `/fragments/...` с htmx-поллингом,
а не через ветвление одного POST-эндпоинта по `HX-Request` — так что чтение
`r.Header.Get("HX-Request")` вводится здесь впервые. Сам механизм рендера одного
партиала уже есть: `server.render(w, "<name>", data)` вызывает
`ExecuteTemplate` по имени define (как `render(w, "progress", …)`), никаких
правок в `render` не нужно.
Ошибку (напр. невалидный ручной ввод) на htmx-пути рендерим тем же партиалом с
баннером ошибки **внутри блока** и **без смены активного источника**
(перечитанный `ReviewData` отражает прежний матч). Чтобы не задваивать баннер с
уже существующим верхним `?err=` (его показывают PRG-редиректы других действий —
apply/defer/cancel), ошибку блока держим в **отдельном поле** view (напр.
`BlockError`), которое рендерит только партиал; верхний `.Error` остаётся для
полностраничного `?err=`. Общий помощник — по образцу `reviewAction`, но с
ветвлением htmx/redirect (напр. `reviewBlockAction`).
**Почему не новый единый роут `/select`:** минимизируем изменения и
переиспользуем валидацию и доменные вызовы; семантика «кандидат» vs «без базы»
уже разведена по эндпоинтам.
### Решение 3: тип — read-only, веб-контрол `/type` убираем
Из шаблона убираем форму переключения типа; тип показываем текстом
(`фильм`/`сериал`) в инфо-части. Роут `/type` и `handleSetType` в `httpapi`
удаляем (веб — единственный их потребитель; Telegram вызывает `worker.SetType`
напрямую, доменный метод остаётся). Перед удалением — убедиться grep'ом, что на
`/type`/`handleSetType` в `httpapi` больше никто не ссылается.
Корректировать тип пользователь по-прежнему может через «Уточнить» (мягкая
подсказка «это сериал»), что согласовано в модифицированном требовании «Команды
ревью и их эффекты».
### Решение 4: инфо-часть — состав полей
Инфо-часть выбранного источника: тип (read-only), название, ориг. название, год,
для сериала — **сводка сезонов**, плюс зарезервированное место под режиссёра
(пустой прочерк).
Сезон в плане задан **на каждом файле** (`recognize.PlanFile.Season *int`), а не
на плане целиком — одна раздача может быть многосезонным паком. Поэтому в
инфо-части показываем компактную сводку по различным сезонам эпизодных файлов
эффективного плана (`rd.Plan`):
- один сезон → «Сезон 2»;
- несколько подряд → «Сезоны 1–3» (диапазон), с разрывами → список «Сезоны 1,
3, 4»;
- только спецвыпуски (`Season == nil`/0) → «Спецвыпуски».
Сводку собираем в транспорте из `rd.Plan.Files` по файлам с ролью `episode`
(игнор-файлы `applyOverrides` уже пометил ролью `ignore` — они выпадают из
фильтра). `PlanFile.Season``*int`, где и `nil`, и `*0` трактуются как
спецвыпуск. Добавляем в `reviewView` строковое поле `SeasonSummary string`
(пусто для фильма). Номер сезона построчно и так виден в предпросмотре раскладки
(`.../Season 02/...`); сводка — это верхнеуровневая подпись-страховка «что за
сезоны в раздаче», обычный случай — один сезон.
## Risks / Trade-offs
- **[Выбор одним кликом требует htmx/JS — нет чистого no-JS фолбека выбора]** →
htmx всегда загружен, инструмент однопользовательский домашний; прочие
действия (Применить/Уточнить/Позже/Отклонить) остаются обычными формами и
работают без JS; хендлеры сохраняют redirect-фолбек, так что без htmx выбор
деградирует до перезагрузки, а не ломается (radio без submit-кнопки, впрочем,
без JS не отправится — это осознанный компромисс UI-мелочи).
- **[Случайный клик меняет сохранённый матч]** → эффект не разрушительный
(хардлинки только по «Применить»), возврат — один клик по другому варианту.
- **[Панель действий вне свопаемого блока может рассинхрониться]** (`Применить`
зависит от `HasLinks`) → на практике план/превью есть всегда, когда есть
активный источник, поэтому набор действий при переключении источников не
меняется; если понадобится — обновляем панель через `hx-swap-oob` из того же
партиала.
- **[Мобильный тап]** → строка-вариант должна иметь крупную кликабельную зону
(label оборачивает всю строку), проверить на узком экране.
## Migration Plan
Чистая замена рендера страницы ревью; данные/БД не затрагиваются, миграций нет.
Откат — возврат шаблона и хендлеров. Деплой — обычная пересборка бинаря.
## Open Questions
- Нет — открытые вопросы закрыты (сезон показываем сводкой, см. Решение 4).
@@ -0,0 +1,58 @@
## Why
Экран ревью разбит на три секции («Догадка», «Источник совпадения»,
«Раскладка»), которые дублируют друг друга: у каждого варианта в списке уже есть
свой `<details>`-предпросмотр раскладки, а внизу та же раскладка повторяется для
активного источника. Выбор варианта требует лишнего действия — раскрыть
предпросмотр, затем нажать отдельную кнопку «выбрать». По сути это один шаг —
«выбрать источник и увидеть его параметры и раскладку», — растянутый на три
блока и два клика.
## What Changes
- Три секции ревью (`Догадка`, `Источник совпадения`, `Раскладка`)
объединяются в **единый блок выбора источника** из трёх частей:
1. список вариантов-«провайдеров» (нейронка + записи метабаз) с
радиокнопками;
2. инфо о выбранном варианте (название, ориг. название, год, тип
фильм/сериал, для сериала — сезон);
3. предпросмотр раскладки выбранного варианта.
- **Клик/тап по варианту сразу выбирает и сохраняет его** (как сейчас кнопка
«выбрать»): отдельной кнопки «выбрать» больше нет. Инфо и предпросмотр
обновляются немедленно через htmx-swap блока, без полной перезагрузки
страницы.
- **BREAKING (UI)**: переключатель типа `фильм/сериал` убирается из экрана
ревью — тип показывается read-only в инфо-части выбранного варианта.
Корректировка типа при необходимости остаётся доступной через «Уточнить»
(мягкая подсказка).
- Форма ручного добавления источника (`TMDB`/`TVDB`/`IMDb` по id или URL)
сохраняется — под списком вариантов; добавленный источник появляется как
выбираемая строка.
- Инвариант «предпросмотр == применённое» сохраняется: показанная раскладка
выбранного источника идентична тому, что создаст «Применить».
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `review`: смена активного источника — одним кликом по строке варианта (а не
отдельной кнопкой), с немедленным обновлением инфо и предпросмотра;
предпросмотр полей и раскладки показывается для выбранного (активного)
источника; команда «Тип» убирается с экрана (тип read-only).
- `web-ui`: экран ревью рендерит единый блок выбора источника; предпросмотр
раскладки строится для выбранного источника и обновляется частичным
htmx-свопом блока при смене выбора.
## Impact
- `internal/httpapi/review.go` — view-модели (`reviewView`, `sourceView`),
`handleReview`; хендлер выбора источника возвращает партиал блока (htmx),
а не только redirect; удаление/отключение UI-ветки `handleSetType`.
- `web/templates/review.html` + новый партиал блока источника (для htmx-свопа).
- `web/static/css/jellybit.css` — стили объединённого блока.
- Доменный слой `internal/worker/review.go` не меняется (тот же выбор
источника и построение предпросмотра); меняется только транспорт/шаблон.
@@ -0,0 +1,132 @@
## MODIFIED Requirements
### Requirement: Команды ревью и их эффекты
Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по
эффективному плану), **Уточнить** (добавить подсказку → перераспознать),
**Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**,
**Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные
ссылки → `reverted`) и **Привязать заново** (из
`reverted`/`cancelled`/`target_missing` → перераспознавание с ручным
подтверждением). Экран ревью MUST NOT содержать команду переключения типа
movie↔series: тип показывается read-only, а его корректировка выполняется
мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL
сериализоваться worker'ом под единой блокировкой; применяется последняя валидная
команда. Команды, которым нужен источник, SHALL проверять его наличие синхронно
перед действием.
#### Scenario: Применение создаёт раскладку
- **GIVEN** загрузка в `review` с эффективным планом
- **WHEN** пользователь выбирает «Применить»
- **THEN** создаются хардлинки по плану, задача переходит к раскладке
#### Scenario: Отклонить и привязать заново
- **GIVEN** загрузка в `review`
- **WHEN** пользователь «Отклонить», затем «Привязать заново»
- **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным
подтверждением (авто-раскладка не делается)
#### Scenario: Тип не переключается кнопкой
- **GIVEN** загрузка в `review` с распознанным типом
- **WHEN** пользователь открывает экран ревью
- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет
- **AND** тип показан read-only в инфо-части выбранного источника
### Requirement: Единый список источников совпадения на ревью
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
Ровно один источник в списке SHALL быть отмечен активным (эффективный
матч). Активный источник SHALL выбираться **одним кликом/тапом по строке
варианта** (радиокнопка), без отдельной кнопки подтверждения выбора. Выбор
источника SHALL сохранять его как эффективный матч (persist) и SHALL
выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта
доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL
немедленно обновляться под выбранный источник (частичный своп блока, без полной
перезагрузки страницы). Экран SHALL позволять операции над этим списком:
выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы
обратно на нейронку («без базы»). Список источников SHALL показываться только
при наличии плана распознавания.
#### Scenario: Нейронка — строка в общем списке
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
несколькими кандидатами метабаз
- **WHEN** пользователь открывает `GET /review/{id}`
- **THEN** источники показаны единым списком, где строка «распознано
нейронкой» стоит наравне с кандидатами баз
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
#### Scenario: Выбор кандидата одним кликом
- **GIVEN** на экране ревью выбран один кандидат метабазы
- **WHEN** пользователь кликает/тапает строку другого кандидата
- **THEN** выбранный кандидат сохраняется активным, прочие — неактивны, без
отдельного нажатия кнопки «выбрать»
- **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного
кандидата без полной перезагрузки страницы
#### Scenario: Снятие матча в пользу нейронки
- **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo»
- **WHEN** пользователь кликает строку «распознано нейронкой»
- **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег
папки провайдера не проставляется
- **AND** поля источника — из распознавания нейронкой, без унаследованных
от прежнего кандидата название/год
## ADDED Requirements
### Requirement: Инфо и предпросмотр выбранного источника
В едином блоке выбора источника экран ревью SHALL показывать для **выбранного
(активного)** источника две части: **инфо** — тип (read-only, movie/series),
название, оригинальное название, год, для сериала — сводку сезонов (один сезон,
диапазон/список для многосезонного пака или «Спецвыпуски»), с
зарезервированным местом под режиссёра; и **предпросмотр раскладки** — целевые
пути хардлинков этого источника. Обе части SHALL относиться именно к активному
источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и
предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным
действием «Применить». Совпадение целевых путей предпросмотра с результатом
применения регулируется требованием «Превью раскладки через единую логику
именования» (`web-ui`).
#### Scenario: Инфо и предпросмотр относятся к активному источнику
- **GIVEN** в списке активен кандидат метабазы
- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки
- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для
сериала) именно этого источника и предпросмотр его целевых путей
#### Scenario: Просмотр блока не создаёт раскладку
- **GIVEN** экран ревью с показанным блоком выбора источника
- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая
«Применить»
- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не
меняются
#### Scenario: Зарезервированное место под режиссёра
- **GIVEN** режиссёр из метабазы пока не загружается
- **WHEN** отображается инфо-часть выбранного источника
- **THEN** в ней присутствует место под режиссёра, показанное пустым (или
прочерком), не ломая вёрстку
## REMOVED Requirements
### Requirement: Предпросмотр полей источника до фиксации выбора
**Reason**: Модель «раскрыть предпросмотр не-активного источника, затем нажать
«выбрать»» заменяется на «клик по варианту = выбор». Предпросмотр полей и
раскладки теперь показывается только для выбранного (активного) источника и
обновляется при смене выбора.
**Migration**: Поведение перенесено в требования «Единый список источников
совпадения на ревью» (выбор одним кликом, немедленное обновление) и «Инфо и
предпросмотр выбранного источника» (что именно показывается и что показ не
создаёт хардлинки).
@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: Превью раскладки через единую логику именования
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
выбранного (активного) источника** — эфемерно на сервере, без записи
сохранённого матча самим показом. При смене выбранного источника превью SHALL
пересчитываться под него и обновляться частичным свопом блока. Показанные для
источника пути MUST совпадать с теми, что создались бы при применении этого
источника.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для выбранного
источника
- **THEN** эти пути идентичны тем, что создаст применение этого источника (те же
правила имён, спецвыпусков, мультифайла, запрещённых символов, тега провайдера
и коллизий)
#### Scenario: Смена источника пересчитывает превью
- **GIVEN** на экране ревью показан предпросмотр раскладки активного источника
- **WHEN** пользователь выбирает другой источник в списке
- **THEN** превью пересчитывается под выбранный источник и обновляется без
полной перезагрузки страницы
#### Scenario: Переключение источника не тянет чужие поля
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
источник без собственных названия/года (нейронка или ручной кандидат)
- **WHEN** строится превью и затем выполняется применение выбранного источника
- **THEN** и превью, и применение используют название/год этого источника
(из плана распознавания), без унаследованных от прежнего кандидата
@@ -0,0 +1,70 @@
## 1. Транспорт: htmx-aware выбор источника
- [x] 1.1 Вынести построение `reviewView` из `handleReview` в общий хелпер
(напр. `buildReviewView(id, rd, errMsg)`), чтобы им пользовались и
`handleReview`, и `reviewBlockAction` (без дублирования логики сборки view).
- [x] 1.2 Добавить в `reviewView` поле `SeasonSummary string` (пусто для
фильма); собирать сводку по различным сезонам файлов `rd.Plan` с ролью
`episode` («Сезон 2» / «Сезоны 1–3» / «Сезоны 1, 3, 4» / «Спецвыпуски»),
учитывая что `Season == nil`/`0` — спецвыпуск.
- [x] 1.3 Добавить в `reviewView` отдельное поле `BlockError string` для ошибки
выбора внутри блока (не путать с верхним `.Error` из `?err=`).
- [x] 1.4 Ввести помощник `reviewBlockAction` (по образцу `reviewAction`): на
`r.Header.Get("HX-Request")` после успешной операции перечитать `ReviewData`,
отрендерить партиал блока (`200`); без htmx — прежний PRG-редирект. При ошибке
на htmx-пути — рендер партиала с `BlockError`, активный источник не меняется.
- [x] 1.5 Перевести `handleChooseCandidate`, `handleNoBase`, `handleAddSource`
на `reviewBlockAction`.
- [x] 1.6 Удалить веб-роут `POST /ui/downloads/{id}/type` и `handleSetType` из
`internal/httpapi`; grep'ом убедиться, что на них больше нет ссылок (Telegram
зовёт `worker.SetType` напрямую через свой интерфейс — доменный метод
оставить).
## 2. Шаблоны: единый блок выбора источника
- [x] 2.1 Создать партиал `web/templates/partials/review_source_block.html` с
корневым контейнером `id="source-block"`: (а) список вариантов из `.Sources`
радиокнопками, label оборачивает всю строку; (б) инфо-часть выбранного
источника (тип read-only, название, ориг. название, год, сезон для сериала,
место под режиссёра); (в) предпросмотр раскладки из `.Files`
(`{{template "layout_widget" .Files}}`).
- [x] 2.2 На радио вариантов повесить `hx-post` (кандидат → `/candidate` с
`candidate_id`; нейронка → `/nobase`), `hx-trigger="change"`,
`hx-target="#source-block"`, `hx-swap="outerHTML"`. Активный вариант отмечен
выбранным радио.
- [x] 2.3 Форму ручного добавления источника (`/source`) перенести под список
внутрь партиала; сделать её `hx-post` с тем же `hx-target`/`hx-swap`.
- [x] 2.4 На радио вариантов задать общий `name` (взаимная эксклюзивность):
кандидат — `value`=`candidate_id`, `hx-post` `/candidate`; нейронка — пустое
`value`, `hx-post` `/nobase`. Внешнюю ссылку «запись ↗» вынести из
кликабельной зоны label (или гасить всплытие), чтобы клик по ней не
переключал источник.
- [x] 2.5 В `review.html` заменить три секции (Догадка/Источник/Раскладка) на
`{{template "review_source_block" .}}`, сохранив обёртку `{{if .HasPlan}}`;
убрать per-source `<details>` и кнопки «выбрать», убрать форму переключения
типа.
- [x] 2.6 Баннер ошибки выбора рендерить внутри блока из `.BlockError`, чтобы
htmx-своп его показывал/очищал; верхний `.Error` (`?err=`) не трогать.
## 3. Стили
- [x] 3.1 Стили единого блока в `web/static/css/jellybit.css` (без инлайн-стилей
и хардкода цветов вне токенов): крупная кликабельная строка-вариант,
разделение частей блока (список → инфо → предпросмотр), корректный вид на
узком экране (мобильный тап).
## 4. Проверка
- [x] 4.0 Обновить тесты `internal/httpapi/httpapi_test.go`: ассерты на удаляемую
разметку («Источник совпадения», кнопка «выбрать») и тест `POST /type`
(`rv.typed`) — переписать под новый блок / удалить; добавить проверку
htmx-свопа выбора (ответ-партиал по `HX-Request`).
- [x] 4.1 `task build` / `task lint` / `task test` — зелёные.
- [x] 4.2 Прогнать вручную (или через verify): открыть ревью, кликнуть по
разным вариантам — инфо и предпросмотр обновляются без перезагрузки; выбор
сохраняется (перезагрузка страницы показывает тот же активный источник);
ручное добавление источника работает через htmx; «Применить» создаёт
раскладку, совпадающую с показанным предпросмотром; тип показан read-only.
- [x] 4.3 Проверить деградацию/ошибки: невалидный ручной ввод показывает ошибку
в блоке и не меняет активный источник.
- [x] 4.4 `openspec validate review-unified-source-block --strict` — без ошибок.