Implement unified dangerous-action confirmation

User-Visible: yes
Issue: #32
This commit is contained in:
Matysh
2026-08-31 00:46:54 +03:00
parent 2782f3b1f3
commit c91ac74e19
40 changed files with 1355 additions and 506 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "df605f95ab57127b928c55c631845277f214cb691013a54a87b1882d4cfedaf6",
"fingerprint": "ecff1039f2dcfe8ac73d4577e08c3133dfd7f57535b7debdc590d5bc64471022",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CmfEfmU0.js",
"houseplan-assets/houseplan-card-D-vtuSN1.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 284055,
"initialViewGzipBytes": 285355,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/de-Cgy8LzOQ.js",
"houseplan-assets/editor-D6GcOhVt.js",
"houseplan-assets/fr-DialrZDD.js",
"houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js",
"houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/de-CRMfGGHY.js",
"houseplan-assets/editor-Cty0mkx9.js",
"houseplan-assets/fr-0kHbASBE.js",
"houseplan-assets/houseplan-editor-runtime-faIXwkKt.js",
"houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js"
],
"lazyGzipBytes": 198441,
"lazyGzipBytes": 199602,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/editor-D6GcOhVt.js",
"houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/editor-Cty0mkx9.js",
"houseplan-assets/houseplan-editor-runtime-faIXwkKt.js"
],
"lazyEditorGzipBytes": 143904,
"lazyEditorGzipBytes": 144465,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js"
],
"lazyOnboardingGzipBytes": 13792,
"lazyOnboardingGzipBytes": 14028,
"lazyLocaleFiles": [
"houseplan-assets/de-Cgy8LzOQ.js",
"houseplan-assets/fr-DialrZDD.js"
"houseplan-assets/de-CRMfGGHY.js",
"houseplan-assets/fr-0kHbASBE.js"
],
"lazyLocaleGzipBytes": 47816,
"lazyLocaleGzipBytes": 48178,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DqwGWZaP.js",
"sha256": "21e6e5a330eddcb8862cb0644a2d8aa716efd1613480f58e5ae991c7d6f0b6f9",
"path": "houseplan-assets/backdrop-pick-CrdXwxkk.js",
"sha256": "7859b71455fc056e01bd3dc26e5b018481cc1bafb347add59b878869887ba1b7",
"rawBytes": 20636,
"gzipBytes": 7071,
"gzipBytes": 7069,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-Cgy8LzOQ.js",
"sha256": "1de8983bc26d116d22be7edfac7e5ba0258d8dba733c453484b0c5325fde422f",
"rawBytes": 80033,
"gzipBytes": 24144,
"path": "houseplan-assets/de-CRMfGGHY.js",
"sha256": "564e1eff12dcc5d634340fda2a34452f3a8adbeb952da9101bc9f130a0052db5",
"rawBytes": 81096,
"gzipBytes": 24343,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-D6GcOhVt.js",
"sha256": "952628166bf0b99af4f740cd0434f037bd8aa9e74a1a3a120a8aebbbe0924ce4",
"path": "houseplan-assets/editor-Cty0mkx9.js",
"sha256": "8b64af9a4ee4e5c0a2f584fa12e0616a17b1fd5e833e0732edb8ad9f73fcdf43",
"rawBytes": 3826,
"gzipBytes": 1581,
"gzipBytes": 1580,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DialrZDD.js",
"sha256": "cb279599952e90a176dfa39ac0c310a6eb18506c51388a24d03f4f9f3c354419",
"rawBytes": 82227,
"gzipBytes": 23672,
"path": "houseplan-assets/fr-0kHbASBE.js",
"sha256": "2ea7db63b1afa0b4d3b15d052fdc069bfa27a02b5a1ffdb6a95401334b7ebc7e",
"rawBytes": 83283,
"gzipBytes": 23835,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CmfEfmU0.js",
"sha256": "c15637c88bc50049a9020d6c6f3843b4c8def2a384f7e971d98045e930855f41",
"rawBytes": 1005654,
"gzipBytes": 283259,
"path": "houseplan-assets/houseplan-card-D-vtuSN1.js",
"sha256": "e847bddfeb4d250e71792fd023f05c996030f0da56d079e66b36747e4bbd4d87",
"rawBytes": 1011575,
"gzipBytes": 284560,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-Cgy8LzOQ.js",
"houseplan-assets/editor-D6GcOhVt.js",
"houseplan-assets/fr-DialrZDD.js",
"houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js",
"houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js"
"houseplan-assets/de-CRMfGGHY.js",
"houseplan-assets/editor-Cty0mkx9.js",
"houseplan-assets/fr-0kHbASBE.js",
"houseplan-assets/houseplan-editor-runtime-faIXwkKt.js",
"houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js",
"sha256": "d4fc4d4748f00cd365bf4442b505627b5ec0ab2f0c10df480dc35b7b81e12490",
"rawBytes": 524022,
"gzipBytes": 135252,
"path": "houseplan-assets/houseplan-editor-runtime-faIXwkKt.js",
"sha256": "0706529aa05cda80ed9d364b18ef7d9df75be9db89835d4feff560690a68b68c",
"rawBytes": 526370,
"gzipBytes": 135816,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js",
"sha256": "51fe9db333a5e67cf30ba0c9dd5d0aad32570dc03d64948d2907a9eaefb65156",
"rawBytes": 27120,
"gzipBytes": 6721,
"path": "houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js",
"sha256": "046ebef3657aaaa500c9164f10c7ff480a9ad5aa3a72f5dec9ad019c3d0190f7",
"rawBytes": 28088,
"gzipBytes": 6959,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "09a6229f8a43d61062885d99751176376253fbedaf8d399a2c629b94160f2834",
"sha256": "917335b7b2132040081ab3c8376e0ab30dfaab62b959859226deb6b0a779016d",
"rawBytes": 1183,
"gzipBytes": 796,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="df605f95ab57127b928c55c631845277f214cb691013a54a87b1882d4cfedaf6";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-CmfEfmU0.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ecff1039f2dcfe8ac73d4577e08c3133dfd7f57535b7debdc590d5bc64471022";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-D-vtuSN1.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="df605f95ab57127b928c55c631845277f214cb691013a54a87b1882d4cfedaf6";try{await import("./houseplan-assets/houseplan-card-CmfEfmU0.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ecff1039f2dcfe8ac73d4577e08c3133dfd7f57535b7debdc590d5bc64471022";try{await import("./houseplan-assets/houseplan-card-D-vtuSN1.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+267
View File
@@ -0,0 +1,267 @@
// #32: one House Plan confirmation surface owns destructive actions in View,
// onboarding and every editor. This smoke covers the real dialog contract,
// then exercises every migrated mutation class through both lazy runtimes.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 320, height: 760 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
await card._ensureOnboardingRuntime();
const editor = card._editorRuntime;
const onboarding = card._onboardingRuntime;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const request = (key = 'delete-plan') => ({
key,
kind: 'destructive',
title: card._t('confirm.delete_plan_title'),
message: card._t('confirm.delete_plan_body'),
objectName: 'A deliberately very long plan filename — Grundriss für das Dachgeschoss.png',
confirmLabel: card._t('btn.delete'),
cancelLabel: card._t('btn.cancel'),
});
const visible = () => {
const component = root().querySelector('hp-confirm');
const dialog = component?.querySelector('hp-dialog');
return {
component,
dialog,
native: dialog?.shadowRoot?.querySelector('dialog'),
surface: dialog?.shadowRoot?.querySelector('.surface'),
footer: dialog?.querySelector('.danger-confirm-footer'),
buttons: [...(dialog?.querySelectorAll('.danger-confirm-footer button') || [])],
};
};
const opener = document.createElement('button');
opener.textContent = 'danger action';
root().append(opener);
opener.focus();
const cancelled = card._confirmDanger(request());
await settle();
let ui = visible();
result.sharedDialogVisible = !!ui.component && !!ui.native?.open
&& ui.dialog?.title === card._t('confirm.delete_plan_title');
result.cancelHasInitialFocus = deepActive() === ui.buttons[0]
&& ui.buttons[0]?.hasAttribute('autofocus');
result.narrowContentFits = !!ui.surface && !!ui.footer
&& ui.surface.getBoundingClientRect().left >= -1
&& ui.surface.getBoundingClientRect().right <= innerWidth + 1
&& ui.surface.scrollWidth <= ui.surface.clientWidth + 1
&& ui.footer.scrollWidth <= ui.footer.clientWidth + 1
&& ui.buttons.every((button) => button.getBoundingClientRect().right <= innerWidth + 1);
ui.buttons[0].click();
result.cancelButtonIsSafe = await cancelled === false;
await settle();
result.cancelRestoresFocus = deepActive() === opener;
const accepted = card._confirmDanger(request());
await settle();
ui = visible();
ui.buttons[1].click();
result.acceptButtonResolvesOnce = await accepted === true;
const escaped = card._confirmDanger(request('escape'));
await settle();
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
result.escapeIsSafe = await escaped === false;
const closed = card._confirmDanger(request('close'));
await settle();
visible().dialog.shadowRoot.querySelector('.close').click();
result.closeIsSafe = await closed === false;
const scrimmed = card._confirmDanger(request('scrim'));
await settle();
const native = visible().native;
native.dispatchEvent(new MouseEvent('click', { bubbles: true }));
result.scrimIsSafe = await scrimmed === false;
const first = card._confirmDanger(request('first'));
const second = card._confirmDanger(request('second'));
await settle();
visible().buttons[1].click();
result.replacementCancelsOldRequest = await first === false && await second === true;
const localeFits = async (language, expectedCancel) => {
card._config = { ...(card._config || {}), language };
card.requestUpdate();
for (let attempt = 0; attempt < 50 && card._t('btn.cancel') !== expectedCancel; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
}
const pending = card._confirmDanger(request(`locale-${language}`));
await settle();
const current = visible();
const fits = current.buttons[0]?.textContent.trim() === expectedCancel
&& current.surface.scrollWidth <= current.surface.clientWidth + 1
&& current.footer.scrollWidth <= current.footer.clientWidth + 1;
current.buttons[0].click();
await pending;
return fits;
};
result.germanNarrowFits = await localeFits('de', 'Abbrechen');
result.frenchNarrowFits = await localeFits('fr', 'Annuler');
opener.remove();
// From here the UI promise is replaced with deterministic decisions so the
// action matrix can prove mutation ownership independently of layout.
const originalConfirm = card._confirmDanger;
let decision = false;
card._confirmDanger = async () => decision;
let geometryCommits = 0;
editor._geometrySnapshot = () => ({ spaceId: 'danger-space' });
editor._commitPhysicalGeometry = () => { geometryCommits++; return true; };
const baseSpace = () => ({
id: 'danger-space', title: 'Danger space', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [], settings: {},
});
const setDraft = (draft) => {
card._space = 'danger-space';
card._serverCfg = { spaces: [{ ...baseSpace(), room_drafts: [draft] }], markers: [], settings: {} };
};
setDraft({ id: 'draft-whole', points: [[0, 0], [1, 0]], segments: [{ cm: 10 }] });
card._physicalDialog = { kind: 'draft', id: 'draft-whole', segment: 0, cm: '10', length: '1 m' };
await editor._deleteDraftWhole();
const wholeCancel = card._serverCfg.spaces[0].room_drafts.length === 1 && geometryCommits === 0;
decision = true;
await editor._deleteDraftWhole();
result.draftWholeCancelAccept = wholeCancel
&& !card._serverCfg.spaces[0].room_drafts && geometryCommits === 1;
decision = false;
setDraft({
id: 'draft-segment', points: [[0, 0], [0.5, 0], [1, 0]],
segments: [{ cm: 10 }, { cm: 10 }],
});
card._physicalDialog = { kind: 'draft', id: 'draft-segment', segment: 0, cm: '10', length: '1 m' };
await editor._deleteDraftSegment();
const segmentCancel = card._serverCfg.spaces[0].room_drafts[0].segments.length === 2
&& geometryCommits === 1;
decision = true;
await editor._deleteDraftSegment();
result.draftSegmentCancelAccept = segmentCancel
&& card._serverCfg.spaces[0].room_drafts[0].segments.length === 1
&& geometryCommits === 2;
let configSaves = 0;
editor._saveConfigNow = async () => { configSaves++; };
card._serverCfg = {
spaces: [baseSpace()], settings: {},
markers: [{ id: 'danger-marker', binding: 'entity:switch.danger', space: 'danger-space' }],
};
card._devices = [];
card._markerDialog = { devId: 'danger-marker', name: 'Danger marker', busy: false };
decision = false;
await editor._deleteMarker();
const markerCancel = card._serverCfg.markers.length === 1 && configSaves === 0;
decision = true;
await editor._deleteMarker();
result.markerCancelAccept = markerCancel && card._serverCfg.markers.length === 1
&& card._serverCfg.markers[0].id === 'danger-marker'
&& card._serverCfg.markers[0].removed === true
&& card._serverCfg.markers[0].hidden === true && configSaves === 1;
const wsCalls = [];
card.hass = {
...card.hass,
callWS: async (message) => {
wsCalls.push(message);
if (message.type === 'houseplan/config/get') return { config: card._serverCfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: card._layout, rev: 1 };
return {};
},
};
const setPlanDialog = () => {
card._spaceDialog = {
mode: 'edit', spaceId: 'danger-space', title: 'Danger space', planUrl: null,
planFile: null, source: 'draw', showBorders: true, showNames: true,
zeroWallStyle: 'dashed', displayTouched: true, hideDecor: false,
hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
busy: false, saved: [{ name: 'old.png', url: '/old.png', size: 1, modified: 1, used_by: [] }],
};
};
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
setPlanDialog();
decision = false;
const before = wsCalls.length;
await runtime._deleteServerPlan('old.png');
const cancelSafe = wsCalls.length === before;
decision = true;
await runtime._deleteServerPlan('old.png');
result[`${name}PlanCancelAccept`] = cancelSafe
&& wsCalls.filter((call) => call.type === 'houseplan/plans/delete').length
=== (name === 'editor' ? 1 : 2);
}
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
card._writeChain = Promise.resolve();
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
card._space = 'another-space';
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
setPlanDialog();
decision = false;
const before = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length;
await runtime._deleteSpace();
const cancelSafe = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before;
decision = true;
await runtime._deleteSpace();
result[`${name}SpaceCancelAccept`] = cancelSafe
&& wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before + 1;
}
const services = [];
card._openingEntityAvailable = () => true;
card._openingInfo = {
id: 'door', type: 'door', rx: 0.5, ry: 0.5, len_cm: 90, lock: 'lock.danger',
};
card.hass = {
...card.hass,
states: {
...card.hass.states,
'lock.danger': { entity_id: 'lock.danger', state: 'locked', attributes: { friendly_name: 'Door' } },
},
callService: async (...args) => { services.push(args); },
};
decision = false;
await card._lockAction('lock.danger', 'unlock');
const unlockCancel = services.length === 0;
decision = true;
await card._lockAction('lock.danger', 'unlock');
result.unlockCancelAccept = unlockCancel && services.length === 1
&& services[0][0] === 'lock' && services[0][1] === 'unlock';
let resolveRace;
card._confirmDanger = () => new Promise((resolve) => { resolveRace = resolve; });
setDraft({ id: 'race-draft', points: [[0, 0], [1, 0]], segments: [{ cm: 10 }] });
card._physicalDialog = { kind: 'draft', id: 'race-draft', segment: 0, cm: '10', length: '1 m' };
const raced = editor._deleteDraftWhole();
card._space = 'changed-space';
resolveRace(true);
await raced;
result.staleContextCannotMutate = card._serverCfg.spaces[0].room_drafts.length === 1
&& geometryCommits === 2;
card._confirmDanger = originalConfirm;
return result;
});
checkAll(out);
await finish(browser, out);
+60 -60
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "df605f95ab57127b928c55c631845277f214cb691013a54a87b1882d4cfedaf6",
"fingerprint": "ecff1039f2dcfe8ac73d4577e08c3133dfd7f57535b7debdc590d5bc64471022",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CmfEfmU0.js",
"houseplan-assets/houseplan-card-D-vtuSN1.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 284055,
"initialViewGzipBytes": 285355,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/de-Cgy8LzOQ.js",
"houseplan-assets/editor-D6GcOhVt.js",
"houseplan-assets/fr-DialrZDD.js",
"houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js",
"houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/de-CRMfGGHY.js",
"houseplan-assets/editor-Cty0mkx9.js",
"houseplan-assets/fr-0kHbASBE.js",
"houseplan-assets/houseplan-editor-runtime-faIXwkKt.js",
"houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js"
],
"lazyGzipBytes": 198441,
"lazyGzipBytes": 199602,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/editor-D6GcOhVt.js",
"houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/editor-Cty0mkx9.js",
"houseplan-assets/houseplan-editor-runtime-faIXwkKt.js"
],
"lazyEditorGzipBytes": 143904,
"lazyEditorGzipBytes": 144465,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js"
],
"lazyOnboardingGzipBytes": 13792,
"lazyOnboardingGzipBytes": 14028,
"lazyLocaleFiles": [
"houseplan-assets/de-Cgy8LzOQ.js",
"houseplan-assets/fr-DialrZDD.js"
"houseplan-assets/de-CRMfGGHY.js",
"houseplan-assets/fr-0kHbASBE.js"
],
"lazyLocaleGzipBytes": 47816,
"lazyLocaleGzipBytes": 48178,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DqwGWZaP.js",
"sha256": "21e6e5a330eddcb8862cb0644a2d8aa716efd1613480f58e5ae991c7d6f0b6f9",
"path": "houseplan-assets/backdrop-pick-CrdXwxkk.js",
"sha256": "7859b71455fc056e01bd3dc26e5b018481cc1bafb347add59b878869887ba1b7",
"rawBytes": 20636,
"gzipBytes": 7071,
"gzipBytes": 7069,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-Cgy8LzOQ.js",
"sha256": "1de8983bc26d116d22be7edfac7e5ba0258d8dba733c453484b0c5325fde422f",
"rawBytes": 80033,
"gzipBytes": 24144,
"path": "houseplan-assets/de-CRMfGGHY.js",
"sha256": "564e1eff12dcc5d634340fda2a34452f3a8adbeb952da9101bc9f130a0052db5",
"rawBytes": 81096,
"gzipBytes": 24343,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-D6GcOhVt.js",
"sha256": "952628166bf0b99af4f740cd0434f037bd8aa9e74a1a3a120a8aebbbe0924ce4",
"path": "houseplan-assets/editor-Cty0mkx9.js",
"sha256": "8b64af9a4ee4e5c0a2f584fa12e0616a17b1fd5e833e0732edb8ad9f73fcdf43",
"rawBytes": 3826,
"gzipBytes": 1581,
"gzipBytes": 1580,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DialrZDD.js",
"sha256": "cb279599952e90a176dfa39ac0c310a6eb18506c51388a24d03f4f9f3c354419",
"rawBytes": 82227,
"gzipBytes": 23672,
"path": "houseplan-assets/fr-0kHbASBE.js",
"sha256": "2ea7db63b1afa0b4d3b15d052fdc069bfa27a02b5a1ffdb6a95401334b7ebc7e",
"rawBytes": 83283,
"gzipBytes": 23835,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CmfEfmU0.js",
"sha256": "c15637c88bc50049a9020d6c6f3843b4c8def2a384f7e971d98045e930855f41",
"rawBytes": 1005654,
"gzipBytes": 283259,
"path": "houseplan-assets/houseplan-card-D-vtuSN1.js",
"sha256": "e847bddfeb4d250e71792fd023f05c996030f0da56d079e66b36747e4bbd4d87",
"rawBytes": 1011575,
"gzipBytes": 284560,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-Cgy8LzOQ.js",
"houseplan-assets/editor-D6GcOhVt.js",
"houseplan-assets/fr-DialrZDD.js",
"houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js",
"houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js"
"houseplan-assets/de-CRMfGGHY.js",
"houseplan-assets/editor-Cty0mkx9.js",
"houseplan-assets/fr-0kHbASBE.js",
"houseplan-assets/houseplan-editor-runtime-faIXwkKt.js",
"houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-GW9Fe7cM.js",
"sha256": "d4fc4d4748f00cd365bf4442b505627b5ec0ab2f0c10df480dc35b7b81e12490",
"rawBytes": 524022,
"gzipBytes": 135252,
"path": "houseplan-assets/houseplan-editor-runtime-faIXwkKt.js",
"sha256": "0706529aa05cda80ed9d364b18ef7d9df75be9db89835d4feff560690a68b68c",
"rawBytes": 526370,
"gzipBytes": 135816,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DGBnhvQK.js",
"sha256": "51fe9db333a5e67cf30ba0c9dd5d0aad32570dc03d64948d2907a9eaefb65156",
"rawBytes": 27120,
"gzipBytes": 6721,
"path": "houseplan-assets/houseplan-onboarding-runtime-CTU52wzg.js",
"sha256": "046ebef3657aaaa500c9164f10c7ff480a9ad5aa3a72f5dec9ad019c3d0190f7",
"rawBytes": 28088,
"gzipBytes": 6959,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DqwGWZaP.js",
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/backdrop-pick-CrdXwxkk.js",
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "09a6229f8a43d61062885d99751176376253fbedaf8d399a2c629b94160f2834",
"sha256": "917335b7b2132040081ab3c8376e0ab30dfaab62b959859226deb6b0a779016d",
"rawBytes": 1183,
"gzipBytes": 796,
"gzipBytes": 795,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CmfEfmU0.js"
"houseplan-assets/houseplan-card-D-vtuSN1.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="df605f95ab57127b928c55c631845277f214cb691013a54a87b1882d4cfedaf6";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-CmfEfmU0.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ecff1039f2dcfe8ac73d4577e08c3133dfd7f57535b7debdc590d5bc64471022";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-D-vtuSN1.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="df605f95ab57127b928c55c631845277f214cb691013a54a87b1882d4cfedaf6";try{await import("./houseplan-assets/houseplan-card-CmfEfmU0.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ecff1039f2dcfe8ac73d4577e08c3133dfd7f57535b7debdc590d5bc64471022";try{await import("./houseplan-assets/houseplan-card-D-vtuSN1.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+10 -1
View File
@@ -27,6 +27,8 @@ houseplan-card/
│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root
│ ├─ houseplan-onboarding-runtime.ts # first-space/import dialogs, independent of editor
│ ├─ hp-dialog.ts # shared HA/native modal shell, focus and transient-overlay lifecycle
│ ├─ hp-confirm.ts # presentation for shared dangerous-action confirmation
│ ├─ danger-confirm.ts # root-owned promise/token confirmation controller
│ ├─ hp-help.ts # presentation-only, localized contextual-help surface
│ ├─ floating-surface.ts # pure visual-viewport flip/shift geometry for dialog surfaces
│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle
@@ -117,6 +119,14 @@ the same profiler available between stable promotions.
commit actions use two explicit wrapping groups: destructive actions stay
left, while Cancel/Save move together to a right-aligned second line when
translated labels do not fit.
Dangerous actions additionally use one eager `HpConfirmController` owned by
`HouseplanCard` and the stateless `hp-confirm` presentation. View, onboarding
and lazy editor callers await the same replace-not-queue promise contract;
X, scrim, Escape, route/mode/space change and disconnect all resolve as
cancellation. An internal token rejects stale or duplicate decisions, and
every caller re-resolves its target after `await` before changing config,
layout or HA state. Native browser `confirm()` is not a supported runtime
surface.
6. **Open passages are negative architecture.** `OpeningCfg.type=passage`
shares placement, wall-cut and tunnel geometry with other openings but has
no visible leaf, state binding or isometric panel. Backend semantic
@@ -1571,4 +1581,3 @@ their passports; `scripts/config-audit.mjs` treats both as `current`.
`invalid_partition_opening_jamb_margin` ship structured JSON details, and
the frontend renders unknown codes localized (code-first, raw messages go
to the console).
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Dangerous actions now use one accessible House Plan confirmation dialog
instead of the browser prompt. Device, draft, plan and space deletion plus
lock opening share clear consequences, safe Cancel/X/Escape behaviour and
stale-target protection ([#32](https://github.com/Matysh/houseplan-card/issues/32)).
## v1.70.0-beta.1 — 2026-08-30
- Wheel, zoom buttons, **Fit all**, the home arrow and kiosk double-tap now
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Опасные действия теперь используют единый доступный диалог подтверждения
House Plan вместо системного окна браузера. Удаление устройства, черновика,
файла плана и пространства, а также открытие замка получили понятные
последствия, безопасные «Отмена»/X/`Esc` и защиту от уже изменившейся цели
([#32](https://github.com/Matysh/houseplan-card/issues/32)).
## v1.70.0-beta.1 — 2026-08-30
- Колесо, кнопки масштаба, «Показать всё», стрелка возврата и двойной тап в
+8 -2
View File
@@ -727,7 +727,8 @@ YAML-сущность без `unique_id` и строки в Entity Registry: п
- неизвестное состояние получает нейтральное/неизвестное оформление;
- клик по значку открывает карточку проёма;
- закрытие выполняется без дополнительного подтверждения;
- открытие замка требует подтверждения;
- открытие замка требует общего диалога подтверждения House Plan; в нём
указано имя замка, а безопасным начальным действием остаётся «Отмена»;
- обычное действие маркера устройства никогда не переключает `lock.*` и не снимает охрану `alarm_control_panel.*`.
### Толстые стены
@@ -835,7 +836,7 @@ binding tombstone не вырезает её из живого устройст
| Цвет и яркость свечения | **Из источника** использует живые данные HA; **Задать цвет** сохраняет живую яркость; **Задать цвет и яркость** фиксирует оба параметра (1–100%) |
| Использовать температуру climate | Добавляет `current_temperature` термостата/кондиционера в маркер и среднее назначенной ему комнаты |
| Скрыть / Показать | Кнопка в левом нижнем углу диалога. После сохранения убирает или возвращает значок; LQI и климатические данные скрытого устройства всё ещё могут участвовать в среднем назначенной комнаты |
| Удалить | Кнопка рядом со «Скрыть». После подтверждения сразу полностью убирает устройство с плана и позволяет позднее добавить его заново |
| Удалить | Кнопка рядом со «Скрыть». Общий диалог House Plan называет устройство и объясняет последствия; после подтверждения устройство полностью исчезает с плана и позднее может быть добавлено заново |
| Радиус свечения | Локально переопределяет общий радиус, 0.1–100 м в хранилище |
| Иконка | Ручная `mdi:*`; пусто — автоматический подбор. Автовариант показывается в поле как значок с именем `mdi:*`, но не сохраняется как ручная замена |
| Отображение | Иконка, иконка + активность или значение вместо иконки. Ниже сразу видны фактический результат, источник и интеграция |
@@ -1891,6 +1892,11 @@ cycle: 0
## Краткая памятка безопасности
- Не отключайте ограничение редактирования администраторами без необходимости: разрешение включает загрузку файлов и запись всего плана.
- Удаление устройства, незавершённого контура или его сегмента, файла плана и
пространства, а также открытие замка используют единый диалог House Plan.
«Отмена» получает начальный фокус; X, клик по затемнению и `Esc` ничего не
меняют. Если выбранный объект успел измениться или исчезнуть, подтверждение
не применяется к другому объекту.
- Для потенциально опасных Toggle/Run/Cover включайте подтверждение.
- Замки открываются только через специальную карточку проёма с подтверждением; не обходите это ручной конфигурацией.
- Перед удалением пространства, массовым объединением комнат или оптимизацией сделайте резервную копию Home Assistant.
+73
View File
@@ -0,0 +1,73 @@
export type HpConfirmKind = 'destructive' | 'warning';
export interface HpConfirmRequest {
key: string;
kind: HpConfirmKind;
title: string;
message: string;
objectName?: string;
confirmLabel: string;
cancelLabel: string;
icon?: string;
}
export interface HpConfirmState {
token: number;
request: Readonly<HpConfirmRequest>;
}
export interface HpConfirmDecision {
token: number;
accepted: boolean;
}
type ActiveConfirmation = HpConfirmState & {
resolve: (accepted: boolean) => void;
};
/**
* One replace-not-queue controller for dangerous actions.
*
* Queuing would let a request outlive the dialog/space that produced it. A
* newer request therefore safely cancels the older promise. The token, rather
* than the caller-provided diagnostic key, owns resolution so stale DOM events
* cannot accept a replacement request.
*/
export class HpConfirmController {
private _sequence = 0;
private _active: ActiveConfirmation | null = null;
public constructor(private readonly _changed: (state: HpConfirmState | null) => void) {}
public get state(): HpConfirmState | null {
if (!this._active) return null;
return { token: this._active.token, request: this._active.request };
}
public confirm(request: HpConfirmRequest): Promise<boolean> {
this.cancel();
const state: HpConfirmState = {
token: ++this._sequence,
request: Object.freeze({ ...request }),
};
return new Promise<boolean>((resolve) => {
this._active = { ...state, resolve };
this._changed(state);
});
}
public resolve(token: number, accepted: boolean): boolean {
const active = this._active;
if (!active || active.token !== token) return false;
this._active = null;
this._changed(null);
active.resolve(accepted);
return true;
}
public cancel(): boolean {
const active = this._active;
if (!active) return false;
return this.resolve(active.token, false);
}
}
+51 -6
View File
@@ -11,6 +11,13 @@ import { guard } from 'lit/directives/guard.js';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
import './hp-confirm';
import {
HpConfirmController,
type HpConfirmDecision,
type HpConfirmRequest,
type HpConfirmState,
} from './danger-confirm';
import type { ColorPickerLabels } from './hp-color-opacity';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
@@ -1548,6 +1555,7 @@ export class HouseplanCard extends LitElement {
private _commitSpace(id: string, authority = false): boolean {
if (!this._canCommitSpace(id, authority)) return false;
if (id !== this._space) {
this._cancelDangerConfirm();
this._cancelCameraTransition(false);
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
@@ -2101,6 +2109,19 @@ export class HouseplanCard extends LitElement {
kind: 'toggle'; text: string; lines: string[];
initialIntent: ResolvedToggleIntent; deviceId: string; exec: () => void;
} | { kind: 'run'; text: string; exec: () => void } | null = null;
/** One eager confirmation owner shared by View, onboarding and lazy editors. */
private _dangerConfirm: HpConfirmState | null = null;
private readonly _dangerConfirmController = new HpConfirmController((state) => {
this._dangerConfirm = state;
});
private _confirmDanger = (request: HpConfirmRequest): Promise<boolean> =>
this._dangerConfirmController.confirm(request);
private _cancelDangerConfirm = (): void => {
this._dangerConfirmController.cancel();
};
private _onDangerConfirmDecision = (event: CustomEvent<HpConfirmDecision>): void => {
this._dangerConfirmController.resolve(event.detail.token, event.detail.accepted);
};
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
@@ -2526,6 +2547,7 @@ export class HouseplanCard extends LitElement {
_editorRuntimeLoadingVisible: { state: true },
_backdropGuard: { state: true },
_tapConfirm: { state: true },
_dangerConfirm: { state: true },
hass: { attribute: false },
_config: { state: true },
_space: { state: true },
@@ -2689,6 +2711,7 @@ export class HouseplanCard extends LitElement {
}
public disconnectedCallback(): void {
this._cancelDangerConfirm();
// HA normally changes the route before removing the old Lovelace tree.
// The explicit event covers routers that keep that tree connected for a
// beat; this fallback covers a direct remove after history navigation.
@@ -5476,7 +5499,7 @@ export class HouseplanCard extends LitElement {
if (action === 'none') return;
// the accidental-tap guard (owner's spec 2026-07-29): any state-changing
// action — toggle or run — may ask first. The dialog is ours, not the
// browser confirm(), so it works and looks right on a wall tablet.
// native browser prompts, so it works and looks right on a wall tablet.
const guarded = (text: string, exec: () => void): void => {
if (actionDevice.marker?.tap_confirm) this._tapConfirm = { kind: 'run', text, exec };
else exec();
@@ -7022,6 +7045,7 @@ export class HouseplanCard extends LitElement {
private _leaveCardRoute(): void {
if (this._routeDepartureHandled) return;
this._routeDepartureHandled = true;
this._cancelDangerConfirm();
// The destination page cannot display this decorative transition and may
// disconnect us before its first measured frame. Commit View atomically so
// the warm tombstone never records an editor camera under `mode: view`.
@@ -7078,6 +7102,7 @@ export class HouseplanCard extends LitElement {
}
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void {
if (mode !== this._mode) this._cancelDangerConfirm();
this._warmModeRequest = 0;
if (!this._editorRuntime) {
if (mode === 'view') {
@@ -7636,11 +7661,11 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._confirmPartitionDelete();
}
private _deleteDraftWhole = (): void => {
private _deleteDraftWhole = (): Promise<void> => {
return this._editorRuntimeOrThrow()._deleteDraftWhole();
}
private _deleteDraftSegment = (): void => {
private _deleteDraftSegment = (): Promise<void> => {
return this._editorRuntimeOrThrow()._deleteDraftSegment();
}
@@ -8687,7 +8712,8 @@ export class HouseplanCard extends LitElement {
}
private get _editorSecondaryDialogBlocked(): boolean {
return !!(this._tapConfirm || this._vacCalConfirm || this._roomDialog || this._mergeDialog
return !!(this._dangerConfirm || this._tapConfirm || this._vacCalConfirm
|| this._roomDialog || this._mergeDialog
|| this._openingDialog || this._physicalDialog || this._openingInfo
|| this._decorTextDialog || this._decorShapeDialog || this._backdropDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog || this._deviceInbox
@@ -11644,6 +11670,13 @@ export class HouseplanCard extends LitElement {
</div>
</hp-dialog>`
: nothing}
${this._dangerConfirm
? html`<hp-confirm .hass=${this.hass}
.request=${this._dangerConfirm.request}
.token=${this._dangerConfirm.token}
@hp-confirm-decision=${this._onDangerConfirmDecision}>
</hp-confirm>`
: nothing}
${this._toast ? html`<div class="toast" role="alert" aria-live="assertive">${this._toast}</div>` : nothing}
</ha-card>
`;
@@ -12724,7 +12757,7 @@ export class HouseplanCard extends LitElement {
* taps on plan icons; here the user has opened the info card and pressed a
* clearly labeled action button — same interaction contract as HA's more-info.
*/
private _lockAction(entityId: string, action: 'lock' | 'unlock'): void {
private async _lockAction(entityId: string, action: 'lock' | 'unlock'): Promise<void> {
// THE ONLY sanctioned lock actuation surface (review CR-1, 2026-07-27).
// The invariant is "no lock or alarm panel is ever actuated by a TAP on the
// plan" — icons, badges, controls[] and the device card all refuse. This
@@ -12734,7 +12767,19 @@ export class HouseplanCard extends LitElement {
if (!this._openingEntityAvailable(entityId)) return;
if (action === 'unlock') {
const name = this.hass?.states?.[entityId]?.attributes?.friendly_name || entityId;
if (!confirm(this._t('confirm.unlock', { name }))) return;
const accepted = await this._confirmDanger({
key: 'unlock',
kind: 'warning',
title: this._t('confirm.unlock_title'),
message: this._t('confirm.unlock_body'),
objectName: name,
confirmLabel: this._t('opening.unlock_action'),
cancelLabel: this._t('btn.cancel'),
});
const opening = this._openingInfo;
if (!accepted || !this._openingEntityAvailable(entityId)
|| !opening || (opening.type !== 'door' && opening.type !== 'gate')
|| opening.lock !== entityId || this.hass?.states?.[entityId]?.state !== 'locked') return;
}
this.hass?.callService?.('lock', action, { entity_id: entityId });
}
+118 -23
View File
@@ -11,6 +11,7 @@ import { guard } from 'lit/directives/guard.js';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
import type { HpConfirmRequest } from './danger-confirm';
import './hp-color-opacity';
import type { ColorPickerLabels } from './hp-color-opacity';
import './hp-help';
@@ -870,6 +871,8 @@ export interface HouseplanEditorHostPort {
_colorPickerLabels: ColorPickerLabels;
_commitSpace: (id: string, authority?: boolean) => boolean;
_commitViewModeAtomic: (from: ModeVisualState | null, targetZoom: number, targetCenterX?: number, targetCenterY?: number) => void;
_confirmDanger: (request: HpConfirmRequest) => Promise<boolean>;
_cancelDangerConfirm: () => void;
_config: CardConfig | undefined;
_contourClosed: boolean;
_curSpaceCfg: any;
@@ -1191,6 +1194,7 @@ public _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof n
}
public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void {
if (mode !== this.host._mode) this.host._cancelDangerConfirm();
this.host._endTabDrag();
this._clearFurniturePreview();
// A mode command is newer than the editor remembered by a same-route warm
@@ -3107,12 +3111,23 @@ public _confirmPartitionDelete = (): void => {
this._commitPhysicalGeometry(this.host._t('history.physical_delete'), before);
};
public _deleteDraftWhole = (): void => {
public _deleteDraftWhole = async (): Promise<void> => {
const id = this.host._physicalDialog?.kind === 'draft'
? this.host._physicalDialog.id
: this.host._physicalSel?.kind === 'draft' ? this.host._physicalSel.id : null;
const spaceId = this.host._space;
if (!id || !this.host._curSpaceCfg) return;
const accepted = await this.host._confirmDanger({
key: 'delete-draft',
kind: 'destructive',
title: this.host._t('confirm.delete_draft_title'),
message: this.host._t('confirm.delete_draft_body'),
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
if (!accepted || this.host._space !== spaceId) return;
const sp = this.host._curSpaceCfg as any;
if (!id || !sp || !confirm(this.host._t('confirm.delete_draft'))) return;
if (!sp || !(sp.room_drafts || []).some((draft: { id?: string }) => draft.id === id)) return;
const before = this._geometrySnapshot();
sp.room_drafts = (sp.room_drafts || []).filter((x: any) => x.id !== id);
if (!sp.room_drafts.length) delete sp.room_drafts;
@@ -3122,15 +3137,32 @@ public _deleteDraftWhole = (): void => {
this._commitPhysicalGeometry(this.host._t('history.physical_delete'), before);
};
public _deleteDraftSegment = (): void => {
public _deleteDraftSegment = async (): Promise<void> => {
const dlg = this.host._physicalDialog;
const spaceId = this.host._space;
if (!dlg || dlg.kind !== 'draft' || !this.host._curSpaceCfg) return;
const draftId = dlg.id;
const segment = dlg.segment || 0;
const accepted = await this.host._confirmDanger({
key: 'delete-draft-segment',
kind: 'destructive',
title: this.host._t('confirm.delete_draft_segment_title'),
message: this.host._t('confirm.delete_draft_segment_body'),
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._physicalDialog;
if (!accepted || this.host._space !== spaceId
|| currentDialog?.kind !== 'draft' || currentDialog.id !== draftId
|| (currentDialog.segment || 0) !== segment) return;
const sp = this.host._curSpaceCfg as any;
if (!dlg || dlg.kind !== 'draft' || !sp) return;
if (!confirm(this.host._t('confirm.delete_draft_segment'))) return;
const index = (sp.room_drafts || []).findIndex((x: any) => x.id === dlg.id);
if (!sp) return;
const index = (sp.room_drafts || []).findIndex((item: { id?: string }) => item.id === draftId);
if (index < 0) return;
const draft = sp.room_drafts[index];
const cut = Math.max(0, Math.min(draft.segments.length - 1, dlg.segment || 0));
if (!Array.isArray(draft.segments) || !Number.isInteger(segment)
|| segment < 0 || segment >= draft.segments.length) return;
const cut = segment;
const pieces: any[] = [];
const leftPoints = draft.points.slice(0, cut + 1);
const rightPoints = draft.points.slice(cut + 1);
@@ -8343,23 +8375,43 @@ public async _deleteMarker(): Promise<void> {
const d = dlg.devId ? this.host._devices.find((x) => x.id === dlg.devId) : null;
const persisted = this.host._markers.find((marker) => marker.id === dlg.devId);
if (!d && !persisted) return;
const label = dlg.name || this.host._t('device.fallback');
if (!confirm(this.host._t('confirm.remove_marker', { name: label }))) return;
const cfg = this.host._serverCfg!;
cfg.markers = cfg.markers || [];
const previousMarkers = cfg.markers;
const targetId = d?.id || persisted!.id;
const binding = d
const expectedBinding = d
? d.bindingKind === 'virtual' ? 'virtual'
: d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : ''
: persisted!.binding;
if (!binding) return;
if (!expectedBinding) return;
const label = dlg.name || this.host._t('device.fallback');
const targetId = dlg.devId;
const accepted = await this.host._confirmDanger({
key: 'remove-marker',
kind: 'destructive',
title: this.host._t('confirm.remove_marker_title'),
message: this.host._t('confirm.remove_marker_body'),
objectName: label,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._markerDialog;
if (!accepted || !currentDialog || currentDialog.busy || currentDialog.devId !== targetId) return;
const currentDevice = this.host._devices.find((item) => item.id === targetId);
const currentPersisted = this.host._markers.find((marker) => marker.id === targetId);
if (!currentDevice && !currentPersisted) return;
const cfg = this.host._serverCfg;
if (!cfg) return;
cfg.markers = cfg.markers || [];
const previousMarkers = cfg.markers;
const binding = currentDevice
? currentDevice.bindingKind === 'virtual' ? 'virtual'
: currentDevice.bindingKind && currentDevice.bindingRef
? `${currentDevice.bindingKind}:${currentDevice.bindingRef}` : ''
: currentPersisted!.binding;
if (!binding || binding !== expectedBinding) return;
const deletion = deletePlanMarkerRecords(
cfg.markers, targetId, binding, binding === 'virtual',
);
cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds);
const cleanupIds = deletion.cleanupIds;
this.host._markerDialog = { ...dlg, busy: true };
this.host._markerDialog = { ...currentDialog, busy: true };
try {
await this._saveConfigNow();
// Housekeeping follows the durable config write. Every call is
@@ -8559,7 +8611,23 @@ public async _readPlanAspect(url: string): Promise<number> {
}
public async _deleteServerPlan(name: string): Promise<void> {
if (!confirm(this.host._t('confirm.delete_plan', { name }))) return;
const dialog = this.host._spaceDialog;
const plan = dialog?.saved?.find((candidate) => candidate.name === name);
if (!dialog || !plan || plan.used_by.length || plan.url === dialog.planUrl) return;
const accepted = await this.host._confirmDanger({
key: 'delete-plan',
kind: 'destructive',
title: this.host._t('confirm.delete_plan_title'),
message: this.host._t('confirm.delete_plan_body'),
objectName: name,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._spaceDialog;
const currentPlan = currentDialog?.saved?.find((candidate) => candidate.name === name);
if (!accepted || !currentDialog || !currentPlan
|| currentPlan.url !== plan.url || currentPlan.modified !== plan.modified
|| currentPlan.used_by.length || currentPlan.url === currentDialog.planUrl) return;
try {
await this.host.hass.callWS({ type: 'houseplan/plans/delete', name });
const d = this.host._spaceDialog;
@@ -8770,15 +8838,42 @@ public async _deleteSpace(): Promise<void> {
this.host._spaceDialog = { ...d, deleteBlockers: dependencies.count };
return;
}
if (!confirm(this.host._t('confirm.delete_space', { title: sp.title }))) return;
this.host._spaceDialog = { ...d, deleteBlockers: 0, busy: true };
if (!sp) return;
const spaceId = d.spaceId!;
const accepted = await this.host._confirmDanger({
key: 'delete-space',
kind: 'destructive',
title: this.host._t('confirm.delete_space_title'),
message: this.host._t('confirm.delete_space_body'),
objectName: sp.title,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._spaceDialog;
const currentConfig = this.host._serverCfg;
if (!accepted || !currentDialog || currentDialog.mode !== 'edit'
|| currentDialog.busy || currentDialog.spaceId !== spaceId || !currentConfig) return;
const currentSpace = currentConfig.spaces.find((candidate) => candidate.id === spaceId);
if (!currentSpace) return;
const currentDependencies = collectSpaceMarkerDependencies(
currentConfig, this.host._layout || {}, spaceId,
);
const currentlyDeletingLastSpace = currentConfig.spaces.length === 1
&& currentConfig.spaces[0]?.id === spaceId;
if (currentDependencies.count && !currentlyDeletingLastSpace) {
this.host._spaceDialog = {
...currentDialog, deleteBlockers: currentDependencies.count,
};
return;
}
this.host._spaceDialog = { ...currentDialog, deleteBlockers: 0, busy: true };
try {
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
await this.host._writeChain;
const response: any = await this.host.hass.callWS({
type: 'houseplan/space/delete',
space_id: d.spaceId,
space_id: spaceId,
expected_config_rev: this.host._cfgRev,
expected_layout_rev: this.host._layoutRev,
});
@@ -8790,7 +8885,7 @@ public async _deleteSpace(): Promise<void> {
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
this.host._spaceDialog = null;
if (this.host._space === d.spaceId) this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
if (this.host._space === spaceId) this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host._showToast(this.host._t('toast.space_deleted'));
@@ -8801,10 +8896,10 @@ public async _deleteSpace(): Promise<void> {
const refreshedConfig = this.host._serverCfg;
if (this.host._spaceDialog && refreshedConfig) {
const refreshed = collectSpaceMarkerDependencies(
refreshedConfig, this.host._layout || {}, d.spaceId || '',
refreshedConfig, this.host._layout || {}, spaceId,
);
const stillLastSpace = refreshedConfig.spaces.length === 1
&& refreshedConfig.spaces[0]?.id === d.spaceId;
&& refreshedConfig.spaces[0]?.id === spaceId;
this.host._spaceDialog = {
...this.host._spaceDialog,
busy: false,
+49 -7
View File
@@ -216,7 +216,23 @@ export class HouseplanOnboardingRuntime {
}
public async _deleteServerPlan(name: string): Promise<void> {
if (!confirm(this.host._t('confirm.delete_plan', { name }))) return;
const dialog = this.host._spaceDialog;
const plan = dialog?.saved?.find((candidate) => candidate.name === name);
if (!dialog || !plan || plan.used_by.length || plan.url === dialog.planUrl) return;
const accepted = await this.host._confirmDanger({
key: 'delete-plan',
kind: 'destructive',
title: this.host._t('confirm.delete_plan_title'),
message: this.host._t('confirm.delete_plan_body'),
objectName: name,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._spaceDialog;
const currentPlan = currentDialog?.saved?.find((candidate) => candidate.name === name);
if (!accepted || !currentDialog || !currentPlan
|| currentPlan.url !== plan.url || currentPlan.modified !== plan.modified
|| currentPlan.used_by.length || currentPlan.url === currentDialog.planUrl) return;
try {
await this.host.hass.callWS({ type: 'houseplan/plans/delete', name });
const dialog = this.host._spaceDialog;
@@ -415,15 +431,41 @@ export class HouseplanOnboardingRuntime {
this.host._spaceDialog = { ...dialog, deleteBlockers: dependencies.count };
return;
}
if (!confirm(this.host._t('confirm.delete_space', { title: space.title }))) return;
this.host._spaceDialog = { ...dialog, deleteBlockers: 0, busy: true };
const spaceId = dialog.spaceId!;
const accepted = await this.host._confirmDanger({
key: 'delete-space',
kind: 'destructive',
title: this.host._t('confirm.delete_space_title'),
message: this.host._t('confirm.delete_space_body'),
objectName: space.title,
confirmLabel: this.host._t('btn.delete'),
cancelLabel: this.host._t('btn.cancel'),
});
const currentDialog = this.host._spaceDialog;
const currentConfig = this.host._serverCfg;
if (!accepted || !currentDialog || currentDialog.mode !== 'edit'
|| currentDialog.busy || currentDialog.spaceId !== spaceId || !currentConfig) return;
const currentSpace = currentConfig.spaces.find((candidate) => candidate.id === spaceId);
if (!currentSpace) return;
const currentDependencies = collectSpaceMarkerDependencies(
currentConfig, this.host._layout || {}, spaceId,
);
const currentlyDeletingLastSpace = currentConfig.spaces.length === 1
&& currentConfig.spaces[0]?.id === spaceId;
if (currentDependencies.count && !currentlyDeletingLastSpace) {
this.host._spaceDialog = {
...currentDialog, deleteBlockers: currentDependencies.count,
};
return;
}
this.host._spaceDialog = { ...currentDialog, deleteBlockers: 0, busy: true };
try {
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
await this.host._writeChain;
const response: { config_rev?: number; layout_rev?: number } = await this.host.hass.callWS({
type: 'houseplan/space/delete',
space_id: dialog.spaceId,
space_id: spaceId,
expected_config_rev: this.host._cfgRev,
expected_layout_rev: this.host._layoutRev,
});
@@ -435,7 +477,7 @@ export class HouseplanOnboardingRuntime {
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
this.host._spaceDialog = null;
if (this.host._space === dialog.spaceId) {
if (this.host._space === spaceId) {
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
}
this.host._regSignature = '';
@@ -451,10 +493,10 @@ export class HouseplanOnboardingRuntime {
const refreshedConfig = this.host._serverCfg;
if (this.host._spaceDialog && refreshedConfig) {
const refreshed = collectSpaceMarkerDependencies(
refreshedConfig, this.host._layout || {}, dialog.spaceId || '',
refreshedConfig, this.host._layout || {}, spaceId,
);
const stillLastSpace = refreshedConfig.spaces.length === 1
&& refreshedConfig.spaces[0]?.id === dialog.spaceId;
&& refreshedConfig.spaces[0]?.id === spaceId;
this.host._spaceDialog = {
...this.host._spaceDialog,
busy: false,
+71
View File
@@ -0,0 +1,71 @@
import { LitElement, html } from 'lit';
import './hp-dialog';
import type { HpConfirmDecision, HpConfirmRequest } from './danger-confirm';
/** Presentation-only confirmation surface; state and mutations stay in the card. */
export class HpConfirm extends LitElement {
static properties = {
hass: { attribute: false },
request: { attribute: false },
token: { type: Number },
};
hass: unknown = null;
request: Readonly<HpConfirmRequest> | null = null;
token = 0;
// Keep the dialog in the card's shadow tree so the shared .btn/.row tokens
// apply without duplicating the entire dialog stylesheet in the eager graph.
protected createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
private _decide(accepted: boolean): void {
this.dispatchEvent(new CustomEvent<HpConfirmDecision>('hp-confirm-decision', {
detail: { token: this.token, accepted },
bubbles: true,
composed: true,
}));
}
protected render() {
const request = this.request;
if (!request) return null;
const destructive = request.kind === 'destructive';
return html`<hp-dialog class="danger-confirm-dialog"
.hass=${this.hass}
.title=${request.title}
.icon=${request.icon || (destructive
? 'mdi:alert-outline' : 'mdi:lock-open-alert-outline')}
dismiss-on-scrim
@hp-close=${() => this._decide(false)}>
<div class="body danger-confirm-body" data-confirm-key=${request.key}>
${request.objectName
? html`<strong class="danger-confirm-object">${request.objectName}</strong>`
: null}
<p>${request.message}</p>
</div>
<div class="row dialog-action-footer danger-confirm-footer" slot="footer">
<span class="dialog-action-group dialog-action-commit">
<button class="btn ghost" type="button" autofocus
@click=${() => this._decide(false)}>${request.cancelLabel}</button>
<button class="btn ${destructive ? 'danger' : 'on'}" type="button"
@click=${() => this._decide(true)}>
<ha-icon icon=${destructive
? 'mdi:trash-can-outline' : 'mdi:lock-open-variant'}></ha-icon>
${request.confirmLabel}
</button>
</span>
</div>
</hp-dialog>`;
}
}
if (!customElements.get('hp-confirm')) customElements.define('hp-confirm', HpConfirm);
declare global {
interface HTMLElementTagNameMap {
'hp-confirm': HpConfirm;
}
}
+12
View File
@@ -39,6 +39,10 @@
"toast.column_duplicate": "Eine Säule mit demselben Zentrum und äußerer Größe existiert bereits",
"confirm.delete_draft": "Sofort den gesamten unvollständigen Raumumriss löschen?",
"confirm.delete_draft_segment": "Dieses Entwurfssegment löschen? Der verbleibende Umriss könnte sich in zwei teilen.",
"confirm.delete_draft_title": "Unvollständigen Umriss löschen?",
"confirm.delete_draft_body": "Der gesamte unvollständige Raumumriss wird gelöscht. Beim Abbrechen wird nichts geändert.",
"confirm.delete_draft_segment_title": "Umrisssegment löschen?",
"confirm.delete_draft_segment_body": "Dieses Entwurfssegment wird gelöscht. Der verbleibende Umriss kann sich in zwei Teile aufteilen.",
"physical.partition_properties": "Eigenschaften der Trennwand",
"physical.column_properties": "Eigenschaften der Säule",
"physical.draft_properties": "Eigenschaften des Entwurfssegments",
@@ -293,8 +297,12 @@
"confirm.delete_room_title": "Raum \"{name}\" löschen?",
"confirm.delete_room_body": "Festlegen, ob die ausschließlich zu diesem Raum gehörenden physischen Wände erhalten bleiben. Mit einem anderen Raum geteilte Wände bleiben immer erhalten.",
"confirm.remove_marker": "„{name}“ aus dem Plan löschen? Das Gerät verschwindet vollständig und trägt nicht mehr zu den Plandaten bei. Es kann später erneut hinzugefügt werden.",
"confirm.remove_marker_title": "Gerät aus dem Plan löschen?",
"confirm.remove_marker_body": "Das Gerät verschwindet vollständig und trägt nicht mehr zu den Plandaten bei. Es kann später erneut hinzugefügt werden.",
"confirm.erase_decor": "Das Objekt „{kind}“ löschen? Diese Aktion kann im Editor rückgängig gemacht werden.",
"confirm.delete_space": "Bereich „{title}“ mit allen Räumen und Markierungen löschen?",
"confirm.delete_space_title": "Bereich löschen?",
"confirm.delete_space_body": "Der Bereich, alle seine Räume und die gesamte Planmarkierung werden gelöscht.",
"space.delete_blocked": "Dieser Bereich wird noch von {n} Gerät(en) verwendet. Die Geräte zuerst in einen anderen Bereich verschieben oder löschen.",
"toast.pos_save_failed": "Position nicht gespeichert: {err}",
"toast.no_entity": "Das Gerät hat keine geeignete Entität",
@@ -730,6 +738,8 @@
"toast.marker_control_cycle": "Dieser Link würde eine kreisförmige Kette von Lichtsteuerungen erzeugen.",
"toast.marker_binding_required": "Wählen Sie ein Home Assistant-Gerät, bevor Sie eine andere Lichtquelle verlinken.",
"confirm.unlock": "„{name}“ entsperren?",
"confirm.unlock_title": "Schloss öffnen?",
"confirm.unlock_body": "House Plan sendet einen Befehl zum Öffnen an dieses Schloss.",
"toast.files_migrate_failed": "Anhänge konnten nicht zur neuen Bindung verschoben werden, Links zeigen weiterhin auf die alten Dateien: {err}",
"space.pick_saved": "Bereits hochgeladen",
"space.pick_saved_hint": "Auf dem Server gespeicherte Pläne, einschließlich solcher, die Sie zuvor getrennt haben",
@@ -739,6 +749,8 @@
"space.in_use": "Ein Bereich verwendet diesen Plan noch — zuerst die Verknüpfung lösen",
"btn.use": "Verwenden",
"confirm.delete_plan": "Plandatei „{name}“ vom Server löschen? Dies kann nicht rückgängig gemacht werden.",
"confirm.delete_plan_title": "Plandatei löschen?",
"confirm.delete_plan_body": "Die Datei wird vom Server gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.",
"toast.plans_list_failed": "Konnte die gespeicherten Pläne nicht auflisten: {err}",
"toast.plan_delete_failed": "Konnte den Plan nicht löschen: {err}",
"marker.hide": "Ausblenden",
+12
View File
@@ -39,6 +39,10 @@
"toast.column_duplicate": "A column with the same centre and outer size already exists",
"confirm.delete_draft": "Delete the whole unfinished room contour?",
"confirm.delete_draft_segment": "Delete this draft segment? The remaining contour may split in two.",
"confirm.delete_draft_title": "Delete unfinished contour?",
"confirm.delete_draft_body": "The entire unfinished room contour will be deleted. Cancelling makes no changes.",
"confirm.delete_draft_segment_title": "Delete contour segment?",
"confirm.delete_draft_segment_body": "This draft segment will be deleted. The remaining contour may split in two.",
"physical.partition_properties": "Partition properties",
"physical.column_properties": "Column properties",
"physical.draft_properties": "Draft segment properties",
@@ -293,8 +297,12 @@
"confirm.delete_room_title": "Delete room \"{name}\"?",
"confirm.delete_room_body": "Choose whether the room's exclusive physical walls should remain. Walls shared with another room are always kept.",
"confirm.remove_marker": "Delete \"{name}\" from the plan? The device will disappear completely and stop contributing to plan data. You can add it again later.",
"confirm.remove_marker_title": "Delete device from the plan?",
"confirm.remove_marker_body": "The device will disappear completely and stop contributing to plan data. You can add it again later.",
"confirm.erase_decor": "Erase the {kind} object? You can undo this action from the editor history.",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
"confirm.delete_space_title": "Delete space?",
"confirm.delete_space_body": "The space, all its rooms and all its plan markup will be deleted.",
"space.delete_blocked": "This space is still used by {n} device(s). Move them to another space or delete them first.",
"toast.pos_save_failed": "Failed to save position: {err}",
"toast.no_entity": "The device has no suitable entity",
@@ -730,6 +738,8 @@
"toast.marker_control_cycle": "This link would create a circular chain of light controls.",
"toast.marker_binding_required": "Choose a Home Assistant device before linking another light source.",
"confirm.unlock": "Unlock “{name}”?",
"confirm.unlock_title": "Unlock?",
"confirm.unlock_body": "House Plan will send an unlock command to this lock.",
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}",
"space.pick_saved": "Already uploaded",
"space.pick_saved_hint": "Plans stored on the server, including ones you detached earlier",
@@ -739,6 +749,8 @@
"space.in_use": "A space still uses this plan — detach it first",
"btn.use": "Use",
"confirm.delete_plan": "Delete the plan file \"{name}\" from the server? This cannot be undone.",
"confirm.delete_plan_title": "Delete plan file?",
"confirm.delete_plan_body": "The file will be deleted from the server. This action cannot be undone.",
"toast.plans_list_failed": "Could not list the stored plans: {err}",
"toast.plan_delete_failed": "Could not delete the plan: {err}",
"marker.hide": "Hide",
+12
View File
@@ -39,6 +39,10 @@
"toast.column_duplicate": "Une colonne avec le même centre et la même dimension extérieure existe déjà",
"confirm.delete_draft": "Supprimer tout le contour de pièce inachevé ?",
"confirm.delete_draft_segment": "Supprimer ce segment de brouillon ? Le contour restant peut se scinder en deux.",
"confirm.delete_draft_title": "Supprimer le contour inachevé ?",
"confirm.delete_draft_body": "Tout le contour de pièce inachevé sera supprimé. L’annulation ne modifie rien.",
"confirm.delete_draft_segment_title": "Supprimer le segment de contour ?",
"confirm.delete_draft_segment_body": "Ce segment de brouillon sera supprimé. Le contour restant peut se scinder en deux.",
"physical.partition_properties": "Propriétés de la cloison",
"physical.column_properties": "Propriétés de la colonne",
"physical.draft_properties": "Propriétés du segment de brouillon",
@@ -293,8 +297,12 @@
"confirm.delete_room_title": "Supprimer la pièce « {name} » ?",
"confirm.delete_room_body": "Choisissez si les murs physiques propres à la pièce doivent être conservés. Les murs partagés avec une autre pièce sont toujours conservés.",
"confirm.remove_marker": "Supprimer « {name} » du plan ? L’appareil disparaîtra complètement et ne contribuera plus aux données du plan. Vous pourrez l’ajouter de nouveau plus tard.",
"confirm.remove_marker_title": "Supprimer l’appareil du plan ?",
"confirm.remove_marker_body": "L’appareil disparaîtra complètement et ne contribuera plus aux données du plan. Vous pourrez l’ajouter de nouveau plus tard.",
"confirm.erase_decor": "Effacer l’objet {kind} ? Vous pouvez annuler cette action depuis l’historique de l’éditeur.",
"confirm.delete_space": "Supprimer l’espace « {title} » avec toutes ses pièces et son tracé ?",
"confirm.delete_space_title": "Supprimer l’espace ?",
"confirm.delete_space_body": "L’espace, toutes ses pièces et tout le tracé du plan seront supprimés.",
"space.delete_blocked": "Cet espace est encore utilisé par {n} appareil(s). Déplacez-les vers un autre espace ou supprimez-les d’abord.",
"toast.pos_save_failed": "Échec de l’enregistrement de la position : {err}",
"toast.no_entity": "L’appareil n’a aucune entité adaptée",
@@ -730,6 +738,8 @@
"toast.marker_control_cycle": "Ce lien créerait une chaîne circulaire de contrôles de lumière.",
"toast.marker_binding_required": "Choisissez un appareil Home Assistant avant de lier une autre source lumineuse.",
"confirm.unlock": "Déverrouiller « {name} » ?",
"confirm.unlock_title": "Déverrouiller ?",
"confirm.unlock_body": "House Plan enverra une commande de déverrouillage à cette serrure.",
"toast.files_migrate_failed": "Les pièces jointes n’ont pas pu être déplacées vers la nouvelle association ; les liens continuent de pointer vers les anciens fichiers : {err}",
"space.pick_saved": "Déjà importé",
"space.pick_saved_hint": "Plans stockés sur le serveur, y compris ceux que vous avez précédemment dissociés",
@@ -739,6 +749,8 @@
"space.in_use": "Un espace utilise encore ce plan — dissociez-le d’abord",
"btn.use": "Utiliser",
"confirm.delete_plan": "Supprimer le fichier de plan « {name} » du serveur ? Cette action est irréversible.",
"confirm.delete_plan_title": "Supprimer le fichier du plan ?",
"confirm.delete_plan_body": "Le fichier sera supprimé du serveur. Cette action est irréversible.",
"toast.plans_list_failed": "Impossible de lister les plans stockés : {err}",
"toast.plan_delete_failed": "Impossible de supprimer le plan : {err}",
"marker.hide": "Masquer",
+12
View File
@@ -39,6 +39,10 @@
"toast.column_duplicate": "Колонна с тем же центром и внешним размером уже существует",
"confirm.delete_draft": "Удалить весь незавершённый контур комнаты?",
"confirm.delete_draft_segment": "Удалить этот сегмент черновика? Оставшийся контур может разделиться на два.",
"confirm.delete_draft_title": "Удалить незавершённый контур?",
"confirm.delete_draft_body": "Весь незавершённый контур комнаты будет удалён. При отмене ничего не изменится.",
"confirm.delete_draft_segment_title": "Удалить сегмент контура?",
"confirm.delete_draft_segment_body": "Этот сегмент черновика будет удалён. Оставшийся контур может разделиться на два.",
"physical.partition_properties": "Свойства перегородки",
"physical.column_properties": "Свойства колонны",
"physical.draft_properties": "Свойства сегмента черновика",
@@ -293,8 +297,12 @@
"confirm.delete_room_title": "Удалить комнату «{name}»?",
"confirm.delete_room_body": "Выберите, нужно ли оставить физические стены, принадлежащие только этой комнате. Общие с другой комнатой стены сохраняются всегда.",
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
"confirm.remove_marker_title": "Удалить устройство с плана?",
"confirm.remove_marker_body": "Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
"confirm.delete_space_title": "Удалить пространство?",
"confirm.delete_space_body": "Пространство, все его комнаты и вся разметка плана будут удалены.",
"space.delete_blocked": "Это пространство всё ещё используется устройствами: {n}. Сначала перенесите их в другое пространство или удалите.",
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
"toast.no_entity": "У устройства нет подходящей сущности",
@@ -730,6 +738,8 @@
"toast.marker_control_cycle": "Такая связь создаст циклическую цепочку управления светом.",
"toast.marker_binding_required": "Сначала выберите устройство Home Assistant, затем добавьте управляемый источник света.",
"confirm.unlock": "Открыть замок «{name}»?",
"confirm.unlock_title": "Открыть замок?",
"confirm.unlock_body": "House Plan отправит этому замку команду открытия.",
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}",
"space.pick_saved": "Уже загруженные",
"space.pick_saved_hint": "Планы, сохранённые на сервере, включая отцеплённые ранее",
@@ -739,6 +749,8 @@
"space.in_use": "План используется пространством — сначала отцепите его",
"btn.use": "Выбрать",
"confirm.delete_plan": "Удалить файл плана «{name}» с сервера? Действие необратимо.",
"confirm.delete_plan_title": "Удалить файл плана?",
"confirm.delete_plan_body": "Файл будет удалён с сервера. Это действие нельзя отменить.",
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
"marker.hide": "Скрыть",
+17
View File
@@ -581,6 +581,23 @@ export const dialogsStyles = css`
max-height: 66vh;
overflow-y: auto;
}
hp-confirm {
display: contents;
}
hp-dialog .danger-confirm-body {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
hp-dialog .danger-confirm-body p {
margin: 0;
line-height: 1.45;
}
hp-dialog .danger-confirm-object {
overflow-wrap: anywhere;
font-size: var(--fs-l);
line-height: 1.3;
}
.descin {
width: 100%;
box-sizing: border-box;
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { HpConfirmController } from '../test-build/danger-confirm.js';
const request = (key = 'delete-space') => ({
key,
kind: 'destructive',
title: 'Delete?',
message: 'This cannot be undone.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
});
test('danger confirmation resolves only the current token and clears its state', async () => {
const changes = [];
const controller = new HpConfirmController((state) => changes.push(state));
const decision = controller.confirm(request());
const token = controller.state.token;
assert.equal(controller.resolve(token + 1, true), false, 'stale DOM decisions are ignored');
assert.equal(controller.resolve(token, true), true);
assert.equal(await decision, true);
assert.equal(controller.state, null);
assert.equal(changes.at(-1), null);
assert.equal(controller.resolve(token, true), false, 'double activation is inert');
});
test('cancel is the safe default and replacement cancels the older request', async () => {
const controller = new HpConfirmController(() => undefined);
const first = controller.confirm(request('first'));
const second = controller.confirm(request('second'));
const secondToken = controller.state.token;
assert.equal(await first, false);
assert.equal(controller.cancel(), true);
assert.equal(await second, false);
assert.equal(controller.resolve(secondToken, true), false);
assert.equal(controller.cancel(), false);
});
test('request text is snapshotted for the visible confirmation', async () => {
const controller = new HpConfirmController(() => undefined);
const mutable = request();
const decision = controller.confirm(mutable);
mutable.title = 'Changed after opening';
assert.equal(controller.state.request.title, 'Delete?');
assert.equal(Object.isFrozen(controller.state.request), true);
controller.cancel();
assert.equal(await decision, false);
});
test('all dangerous-action call sites use the shared confirmation contract', () => {
const files = [
'../src/houseplan-card.ts',
'../src/houseplan-editor-runtime.ts',
'../src/houseplan-onboarding-runtime.ts',
];
const source = files.map((relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')).join('\n');
const nativeCalls = source.match(
/(?:\bwindow\.confirm|\bglobalThis\.confirm|(?<![\w.])confirm)\s*\(/g,
) || [];
const sharedCalls = source.match(/await this(?:\.host)?\._confirmDanger\s*\(\{/g) || [];
assert.equal(nativeCalls.length, 0, 'native browser confirmation must not return');
assert.equal(sharedCalls.length, 8, 'the reviewed inventory stays on the shared surface');
for (const key of [
'delete-draft', 'delete-draft-segment', 'remove-marker',
'delete-plan', 'delete-space', 'unlock',
]) {
assert.match(source, new RegExp(`key: '${key}'`));
}
});
+7 -2
View File
@@ -129,9 +129,14 @@ test('lock actuation remains guarded inside the one sanctioned opening-card meth
const source = readHouseplanProductionSource();
const action = methodBody(source, '_lockAction');
const guardAt = action.indexOf('_openingEntityAvailable(entityId)');
const confirmAt = action.indexOf('confirm(');
const confirmAt = action.indexOf('await this._confirmDanger');
const recheckAt = action.indexOf('_openingEntityAvailable(entityId)', guardAt + 1);
const serviceAt = action.indexOf("callService?.('lock'");
assert.ok(guardAt >= 0 && guardAt < confirmAt && confirmAt < serviceAt);
assert.ok(
guardAt >= 0 && guardAt < confirmAt && confirmAt < recheckAt && recheckAt < serviceAt,
'unlock revalidates the opening binding and entity after confirmation',
);
assert.match(action, /state !== 'locked'/);
assert.equal(source.match(/callService\?\.\('lock'/g)?.length, 1);
assert.equal(source.match(/this\._lockAction\(/g)?.length, 1, 'only the opening card button calls it');
});
+1 -1
View File
@@ -35,7 +35,7 @@
"src/plan-geometry-preflight.ts",
"src/furniture.ts",
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.ts",
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts",
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts", "src/danger-confirm.ts",
"src/opening-placement.ts", "src/opening-dimensions.ts", "src/partition-openings.ts", "src/plan-snap-overlay.ts", "src/wall-face-graph.ts", "src/wall-face-repair.ts", "src/room-deletion.ts", "src/render/opening-symbol.ts",
"src/wall-thickness.ts",
"src/wall-segment-model.ts",