16 KiB
Executable File
ТЗ #402 — Подтверждение опасного действия живёт вне зависимости от ветки render()
- Issue: https://github.com/Matysh/houseplan-card/issues/402
- Приоритет: P1, bug (регресс против поведения до #32); полный трек — класс A
- Ревизия: 2 (2026-08-31) — по SPEC-REVIEW-402-r1 (H1: touch/kiosk; M1: причина исключения соседних подтверждений)
Сценарий
Хозяин ставит карточку в первый раз: пространств ещё нет, идёт онбординг. В
списке сохранённых на сервере планов лежит чужой, ошибочно загруженный файл —
он жмёт корзину. Ничего не происходит. Ни диалога, ни удаления, ни сообщения:
кнопка мертва, и понять почему нельзя. До #32 здесь спрашивал браузерный
confirm(), и удаление работало.
Что человек увидит до и после
До: в состоянии «нет ни одного пространства» (а также при недоступном
fixed_floor и при потерянном пространстве) любое опасное действие молча не
делает ничего; если подтверждение было открыто до перехода в такое состояние —
диалог исчезает сам, а действие остаётся ни выполненным, ни отменённым.
После: подтверждение спрашивается и в этих состояниях; если карточка вообще
ничего не рисует, действие отклоняется явно, а не подвисает.
Проблема и контракт
HpConfirmController (src/danger-confirm.ts:36) корректен: cancel()
резолвит промис false, resolve() сверяет токен, новый запрос отменяет
предыдущий. Промис виснет не потому, что контроллер теряет решение, а потому
что решение неоткуда взяться: оно приходит событием hp-confirm-decision от
элемента <hp-confirm>, а элемента в 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 — не в скоупе, и причина не та, что я
написал в ревизии 1. Они рендерятся в той же финальной ветке render()
(:11802-11878), что и _dangerConfirm, — «свои ветки» было неверным
утверждением. Настоящих причин две, и обе по существу:
- У них нет промиса:
_tapConfirmхранит синхронныйexec(), который вызывается прямо из обработчика кнопки, а_vacCalConfirm— это информационный диалог с закрытием поhp-close. Ни один вызывающий не ждёт разрешения, поэтому «висящего промиса» — самого дефекта этой задачи — у них не бывает. - Их точки входа недостижимы из ранних веток: тап по маркеру и калибровка пылесоса требуют отрисованного плана, то есть той самой основной ветки.
Из этого следует практическое требование к реализации: при выносе
hp-confirm из цепочки соседние два блока остаются на месте. Утащить их
за компанию — значит без нужды поменять поведение диалогов, для которых эта
задача ничего не обещает.
UX
Видимых изменений в оформлении нет. Меняется только то, что подтверждение появляется там, где раньше действие молча не работало.
Touch и kiosk
docs/TOUCH-SUPPORT.md § Safety floor запрещает «best effort» в трёх вещах,
и одна из них — обход подтверждения разрушающего действия. Сейчас на touch
этот пол пробит ровно так же, как на мыши: в ветке онбординга подтверждения
нет вовсе, то есть действие либо не работает, либо (если бы работало) шло бы
без спроса. Задача поднимает пол обратно на всех вводах сразу.
Поведение по вводам после правки одинаково и специальной работы не требует:
переносится место рендера одного и того же компонента hp-confirm,
разметка, размеры целей нажатия, поведение scrim и футера (#32 §6.2) не
меняются ни на строку. Kiosk отдельного пути не имеет — он рисуется той же
основной веткой, где подтверждение и так работало.
Доказательство — AC8: смок из плана прогоняется в touch-эмуляции, то есть проверяет ветку онбординга не только мышью.
Модель данных и миграция
Не применимо.
i18n
Новых строк нет.
Критерии приёмки
- AC1. В состоянии «нет ни одного пространства» запрос подтверждения
показывает диалог; «Отмена» резолвит промис
false, подтверждение —true. Доказательство: браузерный смок на реальной карточке. - AC2. Тот же инвариант в ветках
fixed_floorpending и invalid и в ветке!space. Доказательство: тот же смок, три ветки. - AC3. Удаление сохранённого плана из онбординга доходит до конца:
подтверждение показано, после согласия план удалён. Доказательство: смок на
буквальном сценарии issue (
_deleteServerPlan). - AC4. Открытое подтверждение переживает смену ветки: карточка уходит в
fixed_floorpending при открытом диалоге — диалог остаётся, решение по-прежнему резолвит промис. Доказательство: смок. - AC5. В неготовых состояниях (
!_config || !hass, языковой гейтwarm) запрос отклоняется немедленно: промис резолвитсяfalse, DOM не трогается. Доказательство: юнит/смок с проверкой, чтоnoChangeиз гейта не подменён шаблоном. - AC6. Существующее поведение основной ветки не изменилось: начальный фокус
на «Отмена», Esc отменяет, замена запроса отменяет предыдущий, вложенный
диалог не ломается. Доказательство:
demo/smoke_danger_confirmation.mjsостаётся зелёным без правок его утверждений. - AC7. Бюджет initial не растёт (правка структурная). Доказательство:
npm run bundle:budgetдо и после. - AC8. На touch подтверждение в ветке онбординга работает так же, как
мышью: диалог появляется, тап по «Отмена» резолвит промис
false, тап по scrim не проваливается в план. Доказательство: тот же смок в touch-эмуляции (hasTouch), как это делают существующие touch-смоки. - AC9.
_tapConfirmи_vacCalConfirmостались в основной ветке и их поведение не изменилось. Доказательство: существующие смоки тапа и калибровки зелёные без правок их утверждений.
План автотестов
Browser smoke (demo/smoke_danger_confirmation.mjs — дополнение):
- Пустая модель →
_confirmDangerпоказывает диалог, «Отмена» →false(AC1). fixed_floorpending / invalid /!space→ то же (AC2).- Онбординг: клик по корзине плана → диалог → согласие → план удалён (AC3).
- Диалог открыт → карточка уходит в
fixed_floorpending → диалог на месте, решение резолвит промис (AC4). - Языковой гейт
warm→ запрос резолвитсяfalseсразу,render()по-прежнему возвращаетnoChange(AC5). - Тот же сценарий (1) в touch-эмуляции: диалог появляется, тап по «Отмена»
резолвит
false, тап по scrim не доходит до плана (AC8). - Один
hp-confirmв DOM — не два (риск двойного рендера ниже).
Мутант (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).- Скриншоты не меняются: в статике диалог не открыт.