Files
houseplan-card/docs/specs/402-confirm-outside-main-branch.md
2026-08-31 16:22:50 +03:00

16 KiB
Executable File
Raw Permalink Blame History

ТЗ #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, — «свои ветки» было неверным утверждением. Настоящих причин две, и обе по существу:

  1. У них нет промиса: _tapConfirm хранит синхронный exec(), который вызывается прямо из обработчика кнопки, а _vacCalConfirm — это информационный диалог с закрытием по hp-close. Ни один вызывающий не ждёт разрешения, поэтому «висящего промиса» — самого дефекта этой задачи — у них не бывает.
  2. Их точки входа недостижимы из ранних веток: тап по маркеру и калибровка пылесоса требуют отрисованного плана, то есть той самой основной ветки.

Из этого следует практическое требование к реализации: при выносе 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_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 до и после.
  • AC8. На touch подтверждение в ветке онбординга работает так же, как мышью: диалог появляется, тап по «Отмена» резолвит промис false, тап по scrim не проваливается в план. Доказательство: тот же смок в touch-эмуляции (hasTouch), как это делают существующие touch-смоки.
  • AC9. _tapConfirm и _vacCalConfirm остались в основной ветке и их поведение не изменилось. Доказательство: существующие смоки тапа и калибровки зелёные без правок их утверждений.

План автотестов

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).
  6. Тот же сценарий (1) в touch-эмуляции: диалог появляется, тап по «Отмена» резолвит false, тап по scrim не доходит до плана (AC8).
  7. Один 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).
  • Скриншоты не меняются: в статике диалог не открыт.