fix: the danger confirmation lives outside render()'s branches (#402)

hp-confirm sat at the end of a chain of early returns, so in onboarding
(«no spaces yet»), in the fixed-floor states and without a space it did
not exist at all: the trash button next to a saved plan was dead and the
promise hung forever, because the decision event had no source in the
DOM. An already open dialog vanished the moment the card slipped into
one of those branches, leaving the caller waiting for a resolution that
could never come. Before #32 a browser confirm() worked there.

render() is now a wrapper: it takes the body — the old chain, unchanged,
as _renderBody — and renders the confirmation beside it. That fixes the
class rather than the instance: a branch added later cannot lose the
dialog again. noChange and nothing are passed through untouched, since
neither may be wrapped in a template; in those states _confirmDanger
refuses the request outright instead of leaving it pending, which is the
honest answer while the card is not on screen and the user has pressed
nothing.

_tapConfirm and _vacCalConfirm deliberately stay where they are. They
share the same final branch, but they have no promise (a synchronous
exec, a dialog closed by hp-close), so the defect cannot occur there,
and their entry points require a drawn plan.

Proven by a separate smoke rather than an addition to
smoke_danger_confirmation: that file keeps deliberately incomplete
dialog fixtures open, and the extra re-renders this change needs make
them throw. The new smoke runs under touch emulation, because
TOUCH-SUPPORT § Safety floor forbids bypassing a destructive
confirmation and the broken branch pierced that floor on finger as
surely as on mouse. Reverting the wrapper reddens it.

User-Visible: yes
Issue: #402
This commit is contained in:
Codex
2026-08-31 16:47:05 +03:00
parent 931e49e3d6
commit 00b6f41233
6 changed files with 214 additions and 10 deletions
+13 -1
View File
@@ -64,10 +64,22 @@ test('stable space ids use exact lookup and abort before side effects', () => {
});
test('empty render keeps create/import affordances without spatial layers', () => {
const render = methodBody('render');
// #402: цепочка веток переехала из `render` в `_renderBody`, а `render`
// стал обёрткой — подтверждение опасного действия обязано жить снаружи
// веток, иначе в онбординге его не существует вовсе.
const render = methodBody('_renderBody');
const emptyAt = render.indexOf('if (!model.length)');
const addAt = render.indexOf("_openSpaceDialog('create')");
const spatialAt = render.indexOf('const space = this._spaceModel()');
assert.ok(emptyAt >= 0 && emptyAt < addAt && addAt < spatialAt);
assert.match(render, /if \(!space\) return nothing;/);
// Обёртка: тело + подтверждение, причём «ничего не рисуем» пробрасывается
// как есть — `noChange` нельзя оборачивать в шаблон.
const wrapper = methodBody('render');
assert.match(wrapper, /const body = this\._renderBody\(\);/);
assert.match(wrapper, /if \(body === noChange \|\| body === nothing\) return body;/);
assert.match(wrapper, /return html`\$\{body\}\$\{this\._renderDangerConfirm\(\)\}`;/);
assert.equal(render.includes('_renderDangerConfirm'), false,
'подтверждение не должно возвращаться внутрь ветки — это и есть дефект #402');
});