diff --git a/docs/specs/402-confirm-outside-main-branch.md b/docs/specs/402-confirm-outside-main-branch.md new file mode 100755 index 00000000..71f6e6a7 --- /dev/null +++ b/docs/specs/402-confirm-outside-main-branch.md @@ -0,0 +1,167 @@ +# ТЗ #402 — Подтверждение опасного действия живёт вне зависимости от ветки render() + +- Issue: https://github.com/Matysh/houseplan-card/issues/402 +- Приоритет: P1, bug (регресс против поведения до #32); полный трек — класс A +- Ревизия: 1 (2026-08-31) + +## Сценарий + +Хозяин ставит карточку в первый раз: пространств ещё нет, идёт онбординг. В +списке сохранённых на сервере планов лежит чужой, ошибочно загруженный файл — +он жмёт корзину. Ничего не происходит. Ни диалога, ни удаления, ни сообщения: +кнопка мертва, и понять почему нельзя. До #32 здесь спрашивал браузерный +`confirm()`, и удаление работало. + +## Что человек увидит до и после + +**До**: в состоянии «нет ни одного пространства» (а также при недоступном +`fixed_floor` и при потерянном пространстве) любое опасное действие молча не +делает ничего; если подтверждение было открыто до перехода в такое состояние — +диалог исчезает сам, а действие остаётся ни выполненным, ни отменённым. +**После**: подтверждение спрашивается и в этих состояниях; если карточка вообще +ничего не рисует, действие отклоняется явно, а не подвисает. + +## Проблема и контракт + +`HpConfirmController` (`src/danger-confirm.ts:36`) корректен: `cancel()` +резолвит промис `false`, `resolve()` сверяет токен, новый запрос отменяет +предыдущий. Промис виснет не потому, что контроллер теряет решение, а потому +что решение неоткуда взяться: оно приходит событием `hp-confirm-decision` от +элемента ``, а элемента в DOM нет. + +`render()` (`src/houseplan-card.ts:11165`) — цепочка ранних `return`, и +`hp-confirm` стоит только в её конце (`:11872`): + +| Строка | Ветка | Рисует | +|---|---|---| +| `:11166` | нет `_config`/`hass` | `nothing` | +| `:11170` | языковой гейт `cold` | загрузчик языка | +| `:11171` | языковой гейт `warm` | `noChange` | +| `:11192` | `fixed_floor` pending | своя карточка | +| `:11203` | `fixed_floor` invalid | своя карточка | +| `:11216` | `!model.length` (онбординг) | своя карточка | +| `:11247` | `!space` | `nothing` | +| `:11872` | основная | **единственное место с `hp-confirm`** | + +Удаление сохранённого плана вызывается из онбординга +(`src/houseplan-onboarding-runtime.ts:273` → `_deleteServerPlan`, который +ждёт `_confirmDanger`), то есть буквальный сценарий issue лежит в ветке +`:11216`. + +**Воспроизведено исполнением** (браузер, реальная карточка): очищаю `spaces`, +вызываю `_confirmDanger({...})` → + +``` +controllerHasRequest: true +confirmDialogInDom: false +promiseSettledOrDialogShown: false (ожидание 200 мс) +``` + +**Вторая половина того же дефекта** (в issue не описана, найдена при разборе): +уже открытое подтверждение исчезает, если карточка переходит в любую раннюю +ветку — потеряла пространство, ушла в `fixed_floor` pending. Пользователь +видит, как диалог пропал сам; вызывающий остаётся с неразрешённым промисом. +Поэтому «добавить рендер в ветку онбординга» — недостаточное решение: оно +чинит один вход и оставляет класс. + +**Контракт**: `hp-confirm` не принадлежит ни одной ветке `render()`. Он +рендерится всегда, когда карточка рисует хоть что-то, и переживает смену +ветки. Разделение ответственности прежнее: контроллер владеет состоянием и +токеном, вызывающий — ревалидацией после `await` (введена #32 и не меняется). + +**Граница**: две ветки рисуют «ничего» осознанно — `noChange` языкового гейта +(`:11171`, Lit-сигнал «не трогай DOM», его нельзя обернуть в шаблон) и +`nothing` до инициализации (`:11166`). В них подтверждение показать +невозможно, и правильное поведение — **отклонить запрос сразу** (промис +резолвится `false`), а не оставить его висеть. Пользователь в этот момент +ничего не нажимал: карточка ещё не готова. + +## Скоуп / не-скоуп + +**В скоупе**: структура `render()` в `src/houseplan-card.ts` (вынос +подтверждения из цепочки), поведение `_confirmDanger` в неготовых состояниях, +смоки и мутанты. + +**Не в скоупе**: содержимое и оформление диалога (#32), ревалидация после +`await` у вызывающих (#32), роль `alertdialog` и `aria-describedby` (#406), +`_tapConfirm` и `_vacCalConfirm` — у них своя механика и свои ветки. + +## UX + +Видимых изменений в оформлении нет. Меняется только то, что подтверждение +появляется там, где раньше действие молча не работало. + +## Модель данных и миграция + +Не применимо. + +## i18n + +Новых строк нет. + +## Критерии приёмки + +- **AC1**. В состоянии «нет ни одного пространства» запрос подтверждения + показывает диалог; «Отмена» резолвит промис `false`, подтверждение — `true`. + Доказательство: браузерный смок на реальной карточке. +- **AC2**. Тот же инвариант в ветках `fixed_floor` pending и invalid и в ветке + `!space`. Доказательство: тот же смок, три ветки. +- **AC3**. Удаление сохранённого плана из онбординга доходит до конца: + подтверждение показано, после согласия план удалён. Доказательство: смок на + буквальном сценарии issue (`_deleteServerPlan`). +- **AC4**. Открытое подтверждение переживает смену ветки: карточка уходит в + `fixed_floor` pending при открытом диалоге — диалог остаётся, решение + по-прежнему резолвит промис. Доказательство: смок. +- **AC5**. В неготовых состояниях (`!_config || !hass`, языковой гейт `warm`) + запрос отклоняется немедленно: промис резолвится `false`, DOM не трогается. + Доказательство: юнит/смок с проверкой, что `noChange` из гейта не подменён + шаблоном. +- **AC6**. Существующее поведение основной ветки не изменилось: начальный фокус + на «Отмена», Esc отменяет, замена запроса отменяет предыдущий, вложенный + диалог не ломается. Доказательство: `demo/smoke_danger_confirmation.mjs` + остаётся зелёным без правок его утверждений. +- **AC7**. Бюджет initial не растёт (правка структурная). Доказательство: + `npm run bundle:budget` до и после. + +## План автотестов + +**Browser smoke** (`demo/smoke_danger_confirmation.mjs` — дополнение): + +1. Пустая модель → `_confirmDanger` показывает диалог, «Отмена» → `false` + (AC1). +2. `fixed_floor` pending / invalid / `!space` → то же (AC2). +3. Онбординг: клик по корзине плана → диалог → согласие → план удалён (AC3). +4. Диалог открыт → карточка уходит в `fixed_floor` pending → диалог на месте, + решение резолвит промис (AC4). +5. Языковой гейт `warm` → запрос резолвится `false` сразу, `render()` + по-прежнему возвращает `noChange` (AC5). + +**Мутант** (`scripts/mutation-gate.mjs`): + +- `danger-confirm-back-into-the-branch`: вернуть `hp-confirm` внутрь основной + ветки → смок AC1 красный. + +## Риски + +- **`noChange` нельзя оборачивать.** Оборачивание сигнала в шаблон сломает + оптимизацию Lit и, вероятно, вызовет лишние перерисовки. Смягчение: ветки + `noChange`/`nothing` остаются нетронутыми, для них — явный отказ запроса; + AC5 это фиксирует. +- **Двойной рендер диалога.** Если подтверждение окажется и в общей обёртке, и + в основной ветке, получится два элемента и два источника событий. Смягчение: + смок проверяет ровно один `hp-confirm` в DOM. +- **Отказ в неготовом состоянии может удивить вызывающего.** Действие + отклоняется без участия пользователя. Смягчение: это состояние наступает до + того, как карточка вообще отрисована, то есть нажать было физически нельзя; + вызывающие уже обрабатывают `false` как «пользователь отказался». + +## Откат + +Возврат `hp-confirm` в основную ветку — одно перемещение блока. Данные +пользователя не затрагиваются. + +## Release-артефакты + +- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что подтверждения + работают и до создания первого пространства (User-Visible: yes). +- Скриншоты не меняются: в статике диалог не открыт.