fix: lazy delivery survives flaky networks and stale caches (#353)

Network failure of the editor runtime is no longer terminal: the loader
re-arms to idle and the next explicit press starts a fresh cycle, while a
fingerprint mismatch on either attempt stays terminal. The toast now says
what actually helps — retry advice for the network, refresh advice for a
foreign build — via one shared lazyLoadFailureMessage helper (new i18n key
editor.retry_advice in en/ru/de).

The field smoke caught a second, deeper bug on the way: Chromium records a
FAILED module in the page module map permanently, so retrying the same URL
(even the cache-busted one) never touched the network again. Every retry
now carries a per-cycle nonce and becomes a genuinely new module request.

A proxy-cached stale entry no longer kills the card silently: the entry
facade is rewritten at build time from a static re-export into a top-level
`try{await import(...)}catch{...}` — importers keep the happy-path
guarantee (await import(entry) still resolves only after
customElements.define), and the catch defines a fallback element with a
localized "reload the page" panel. Content-hashed chunks are served with
`public, max-age=31536000, immutable`, and verifyBundleTree now fails on
orphan chunks that the manifest does not name.

Proofs: loader units for re-arm/terminality/toast wording + an AST check
that both loaders forward the terminality flag; smoke_entry_stale (en/ru)
against a tree without the main chunk; smoke_lazy_editor_chunk extended —
second press after network failure now really opens the editor; pytest for
the immutable header; orphan-tree unit; five new registry mutants.
TESTING.md budget line updated to the #352 ceiling alongside.

Issue: #353
User-Visible: yes
This commit is contained in:
Codex
2026-08-28 14:32:08 +00:00
committed by claude[bot]
parent 8cacfda934
commit 78c6020747
49 changed files with 640 additions and 213 deletions
@@ -1,111 +1,111 @@
{
"schema": 1,
"fingerprint": "a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b",
"fingerprint": "99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/houseplan-card-g88CD9VI.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 256127,
"initialViewGzipBytes": 256828,
"lazyFiles": [
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/de-CFxUrXZ2.js",
"houseplan-assets/editor-Do71pUmD.js",
"houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"lazyGzipBytes": 160345,
"lazyGzipBytes": 160366,
"lazyEditorFiles": [
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/editor-Do71pUmD.js",
"houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"lazyEditorGzipBytes": 131827,
"lazyEditorGzipBytes": 131834,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"lazyOnboardingGzipBytes": 7197,
"lazyOnboardingGzipBytes": 7196,
"lazyLocaleFiles": [
"houseplan-assets/de-AOEY0-Iu.js"
"houseplan-assets/de-CFxUrXZ2.js"
],
"lazyLocaleGzipBytes": 21873,
"lazyLocaleGzipBytes": 21886,
"files": [
{
"path": "houseplan-assets/de-AOEY0-Iu.js",
"sha256": "af2926bc08c1d4d617b844f53b7aa8ef772b9751c15570b78b2fb24ab1109c20",
"rawBytes": 71477,
"gzipBytes": 21873,
"path": "houseplan-assets/de-CFxUrXZ2.js",
"sha256": "62ceb0c8d016c48de243b8d643b4f02bae8a623dc8604d115af1420ecb6f7582",
"rawBytes": 71541,
"gzipBytes": 21886,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-2znZ5Si4.js",
"sha256": "f701b93ee31570464f43993fb38d74f142f7e2ff1033e38f6ba79a8c17b6e858",
"path": "houseplan-assets/editor-Do71pUmD.js",
"sha256": "71dea046e80ff8ce30728044ad3cf85ee536798e230306f5ec1e8824a6e0e1d2",
"rawBytes": 3831,
"gzipBytes": 1582,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js"
"houseplan-assets/houseplan-card-g88CD9VI.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BsbMwnTm.js",
"sha256": "6d133886e636a0cd8a38a321f4bec4eac300bd7191631494e895c299b2d9bb6e",
"rawBytes": 910936,
"gzipBytes": 255944,
"path": "houseplan-assets/houseplan-card-g88CD9VI.js",
"sha256": "2040dd1630535449eb8ef3b1a1cfb71da2e14c59b7134b7d64b280dc91b9770f",
"rawBytes": 911212,
"gzipBytes": 256075,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js"
"houseplan-assets/de-CFxUrXZ2.js",
"houseplan-assets/editor-Do71pUmD.js",
"houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"sha256": "a644ece8d0cb490b6c41949e25fa80e64580be2d0d4a0e3fc4e1669b33777f9e",
"path": "houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"sha256": "1d6bd9cf7036174a2d3daba802c1a98c1dc69e118752779be1ad5aa6bd956acd",
"rawBytes": 504959,
"gzipBytes": 129693,
"gzipBytes": 129701,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/houseplan-card-g88CD9VI.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"sha256": "ad84c9b77ef8b3920a69ba80700c97d558c2f6229d2cb589e9548451ef3328c9",
"path": "houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js",
"sha256": "9777a5eb4166704d294d9972f86d0897ec229e40116eb9def15147cd5bf45007",
"rawBytes": 26356,
"gzipBytes": 6645,
"gzipBytes": 6646,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/houseplan-card-g88CD9VI.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-B9Tp5xY6.js",
"sha256": "00bb44aff178ffb82ec71bcd59b67d1a12137ecb4379ec0d606bd613b4447c1b",
"rawBytes": 911,
"gzipBytes": 552,
"path": "houseplan-assets/space-deletion-3-uORj4C.js",
"sha256": "bccd5edc4a4113d78ea05a475b8cadc1175287df2e67327213a95e9647074e25",
"rawBytes": 906,
"gzipBytes": 550,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "6c720bd98beee922433c17737fe95dc4c98c69ab182ddcddd05c42a82d50f968",
"rawBytes": 190,
"gzipBytes": 183,
"sha256": "228910d9e3580441d5a1064cb5107e2f2dd80d99b24a325b2b54843eb32f0009",
"rawBytes": 1089,
"gzipBytes": 753,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js"
"houseplan-assets/houseplan-card-g88CD9VI.js"
],
"dynamicImports": []
}
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BsbMwnTm.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-g88CD9VI.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}
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-BsbMwnTm.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-B9Tp5xY6.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-g88CD9VI.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-3-uORj4C.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
${s.map(s=>{return p`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
<img src=${this.host._display(s.url)} alt="" loading="lazy" decoding="async" />
@@ -1 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{t as a,e as c,o as i,r as s,s as t};
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function a(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),a=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),t=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&a.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:t,count:t.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";export{a,e as c,o as i,r as s,s as t};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BsbMwnTm.js";
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";try{await import("./houseplan-assets/houseplan-card-g88CD9VI.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).":"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)}
@@ -7,6 +7,10 @@ from pathlib import Path
_MANIFEST = "houseplan-assets.json"
_ASSET_DIR = "houseplan-assets"
# Content-hashed chunk bodies never change under their URL (#353 К4). The
# stable entry `houseplan-card.js` is served elsewhere and stays uncached.
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
def resolve_frontend_asset(frontend_root: Path, filename: object) -> Path | None:
"""Resolve one generated JS asset only when the current manifest lists it."""
@@ -11,7 +11,7 @@ except ImportError: # pragma: no cover - old HA compatibility
KEY_HASS = "hass" # type: ignore[assignment]
from .const import FRONTEND_ASSETS_URL
from .frontend_asset_manifest import resolve_frontend_asset
from .frontend_asset_manifest import ASSET_CACHE_CONTROL, resolve_frontend_asset
_FRONTEND_ROOT = Path(__file__).parent / "frontend"
@@ -30,7 +30,13 @@ class HouseplanFrontendAssetView(HomeAssistantView):
)
if path is None:
raise web.HTTPNotFound()
# Chunk names are content-hashed, so a served body can never change
# under its URL — immutable caching is safe and keeps a stale proxy
# from re-serving an old graph after an update (#353 К4).
return web.FileResponse(
path,
headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
headers={
"Cache-Control": ASSET_CACHE_CONTROL,
"X-Content-Type-Options": "nosniff",
},
)
+53
View File
@@ -0,0 +1,53 @@
// #353 AC3b: a proxy-cached entry `houseplan-card.js` that survived an update
// points at a main chunk the manifest-gated server no longer serves. Before
// the fix the static re-export aborted the whole module and the card died
// silently; now the entry defines a fallback element with a human message,
// and `await import(entry)` + `createElement(...).setConfig(...)` stays valid.
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { check, checkAll } from './serve.mjs';
const entry = readFileSync('dist/houseplan-card.js');
const page404 = `<!doctype html><meta charset="utf-8"><body><script type="module">
await import('/assets/houseplan-card.js');
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(card);
window.__done = true;
<\/script></body>`;
const run = async (locale, expected) => {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ locale })).newPage();
let pageErrors = 0;
page.on('pageerror', (error) => { pageErrors++; console.log('EXC', error.message); });
await page.route('**/*', (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/stale.html') {
return route.fulfill({ status: 200, contentType: 'text/html', body: page404 });
}
if (path === '/assets/houseplan-card.js') {
return route.fulfill({ status: 200, contentType: 'text/javascript', body: entry });
}
// Every hashed chunk of the cached build is gone after the update.
return route.fulfill({ status: 404, body: 'nf' });
});
await page.goto('http://demo.local/stale.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__done === true, { timeout: 9000 });
const text = await page.locator('houseplan-card').textContent();
await browser.close();
return { text, pageErrors };
};
const en = await run('en-US');
const ru = await run('ru-RU');
const out = {
entrySurvivesMissingChunk: en.pageErrors === 0 && ru.pageErrors === 0,
englishMessageVisible: en.text.includes('House Plan was updated')
&& en.text.includes('reload the page'),
russianMessageVisible: ru.text.includes('House Plan обновился')
&& ru.text.includes('перезагрузите страницу'),
};
checkAll(out);
check('smoke_entry_stale', Object.values(out).every(Boolean));
if (Object.values(out).every(Boolean)) console.log('OK');
+17 -7
View File
@@ -96,27 +96,36 @@ out.guiEditorLoadsAsynchronously = await gui.page.evaluate(async () => {
return editor?.localName === 'houseplan-card-editor';
});
// Two network failures are terminal for this page. View stays live and the
// retry is the same immutable chunk with a cache-busting query string.
// Two network failures end one load cycle, but are NOT terminal (#353): the
// loader re-arms and the next explicit press starts a fresh cycle that heals
// once the network is back. The retry inside a cycle is the same immutable
// chunk with a cache-busting query string.
const failed = await launchColdView();
let failedRequests = 0;
await failed.page.route(runtimeUrlPattern, async (route) => {
failedRequests += 1;
await route.abort('failed');
});
await failed.page.locator('houseplan-card').evaluate((card) => {
const pressEditor = () => failed.page.locator('houseplan-card').evaluate((card) => {
const root = card.shadowRoot || card.renderRoot;
root.querySelectorAll('.modetab')[0]?.click();
});
await failed.page.waitForFunction(() => window.__card._editorRuntimeLoader.state === 'failed');
await pressEditor();
await failed.page.waitForFunction(() =>
window.__card._editorRuntimeLoader.state === 'idle' && window.__card._toast);
out.networkFailureRetriesExactlyOnce = failedRequests === 2;
out.networkFailureKeepsView = await failed.page.evaluate(() => {
out.networkFailureKeepsViewWithRetryAdvice = await failed.page.evaluate(() => {
const card = window.__card;
return card._mode === 'view'
&& !card._editorRuntime
&& card._toast.includes(card._t('editor.load_failed'))
&& card._toast.includes(card._t('editor.refresh_advice'));
&& card._toast.includes(card._t('editor.retry_advice'));
});
await failed.page.unroute(runtimeUrlPattern);
await pressEditor();
await failed.page.waitForFunction(() =>
window.__card._editorRuntimeLoader.state === 'ready' && window.__card._mode === 'plan');
out.secondPressAfterNetworkFailureOpensEditor = true;
// A valid module from a different build is no safer than a 404. Both attempts
// are fulfilled deliberately so this checks the fingerprint handshake rather
@@ -140,7 +149,8 @@ await mismatch.page.locator('houseplan-card').evaluate((card) => {
await mismatch.page.waitForFunction(() => window.__card._editorRuntimeLoader.state === 'failed');
out.fingerprintMismatchRetriesExactlyOnce = mismatchRequests === 2;
out.fingerprintMismatchKeepsView = await mismatch.page.evaluate(() =>
window.__card._mode === 'view' && !window.__card._editorRuntime);
window.__card._mode === 'view' && !window.__card._editorRuntime
&& window.__card._toast.includes(window.__card._t('editor.refresh_advice')));
await failed.browser.close();
await mismatch.browser.close();
+52 -52
View File
@@ -1,111 +1,111 @@
{
"schema": 1,
"fingerprint": "a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b",
"fingerprint": "99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/houseplan-card-g88CD9VI.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 256127,
"initialViewGzipBytes": 256828,
"lazyFiles": [
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/de-CFxUrXZ2.js",
"houseplan-assets/editor-Do71pUmD.js",
"houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"lazyGzipBytes": 160345,
"lazyGzipBytes": 160366,
"lazyEditorFiles": [
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/editor-Do71pUmD.js",
"houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"lazyEditorGzipBytes": 131827,
"lazyEditorGzipBytes": 131834,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"lazyOnboardingGzipBytes": 7197,
"lazyOnboardingGzipBytes": 7196,
"lazyLocaleFiles": [
"houseplan-assets/de-AOEY0-Iu.js"
"houseplan-assets/de-CFxUrXZ2.js"
],
"lazyLocaleGzipBytes": 21873,
"lazyLocaleGzipBytes": 21886,
"files": [
{
"path": "houseplan-assets/de-AOEY0-Iu.js",
"sha256": "af2926bc08c1d4d617b844f53b7aa8ef772b9751c15570b78b2fb24ab1109c20",
"rawBytes": 71477,
"gzipBytes": 21873,
"path": "houseplan-assets/de-CFxUrXZ2.js",
"sha256": "62ceb0c8d016c48de243b8d643b4f02bae8a623dc8604d115af1420ecb6f7582",
"rawBytes": 71541,
"gzipBytes": 21886,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-2znZ5Si4.js",
"sha256": "f701b93ee31570464f43993fb38d74f142f7e2ff1033e38f6ba79a8c17b6e858",
"path": "houseplan-assets/editor-Do71pUmD.js",
"sha256": "71dea046e80ff8ce30728044ad3cf85ee536798e230306f5ec1e8824a6e0e1d2",
"rawBytes": 3831,
"gzipBytes": 1582,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js"
"houseplan-assets/houseplan-card-g88CD9VI.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BsbMwnTm.js",
"sha256": "6d133886e636a0cd8a38a321f4bec4eac300bd7191631494e895c299b2d9bb6e",
"rawBytes": 910936,
"gzipBytes": 255944,
"path": "houseplan-assets/houseplan-card-g88CD9VI.js",
"sha256": "2040dd1630535449eb8ef3b1a1cfb71da2e14c59b7134b7d64b280dc91b9770f",
"rawBytes": 911212,
"gzipBytes": 256075,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-AOEY0-Iu.js",
"houseplan-assets/editor-2znZ5Si4.js",
"houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js"
"houseplan-assets/de-CFxUrXZ2.js",
"houseplan-assets/editor-Do71pUmD.js",
"houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Dm8qf6N2.js",
"sha256": "a644ece8d0cb490b6c41949e25fa80e64580be2d0d4a0e3fc4e1669b33777f9e",
"path": "houseplan-assets/houseplan-editor-runtime-BuT5Y1tf.js",
"sha256": "1d6bd9cf7036174a2d3daba802c1a98c1dc69e118752779be1ad5aa6bd956acd",
"rawBytes": 504959,
"gzipBytes": 129693,
"gzipBytes": 129701,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/houseplan-card-g88CD9VI.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Dhx_kyQK.js",
"sha256": "ad84c9b77ef8b3920a69ba80700c97d558c2f6229d2cb589e9548451ef3328c9",
"path": "houseplan-assets/houseplan-onboarding-runtime-D8yGRdVi.js",
"sha256": "9777a5eb4166704d294d9972f86d0897ec229e40116eb9def15147cd5bf45007",
"rawBytes": 26356,
"gzipBytes": 6645,
"gzipBytes": 6646,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js",
"houseplan-assets/space-deletion-B9Tp5xY6.js"
"houseplan-assets/houseplan-card-g88CD9VI.js",
"houseplan-assets/space-deletion-3-uORj4C.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-B9Tp5xY6.js",
"sha256": "00bb44aff178ffb82ec71bcd59b67d1a12137ecb4379ec0d606bd613b4447c1b",
"rawBytes": 911,
"gzipBytes": 552,
"path": "houseplan-assets/space-deletion-3-uORj4C.js",
"sha256": "bccd5edc4a4113d78ea05a475b8cadc1175287df2e67327213a95e9647074e25",
"rawBytes": 906,
"gzipBytes": 550,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "6c720bd98beee922433c17737fe95dc4c98c69ab182ddcddd05c42a82d50f968",
"rawBytes": 190,
"gzipBytes": 183,
"sha256": "228910d9e3580441d5a1064cb5107e2f2dd80d99b24a325b2b54843eb32f0009",
"rawBytes": 1089,
"gzipBytes": 753,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BsbMwnTm.js"
"houseplan-assets/houseplan-card-g88CD9VI.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BsbMwnTm.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-g88CD9VI.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}
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-BsbMwnTm.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-B9Tp5xY6.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";import{bU as t,bV as s,bW as e,bX as o,aq as a,bY as l,c1 as i,bZ as h,b_ as n,b$ as c,c0 as r,b as p,A as _,cV as d,ap as u,ar as g,cW as m}from"./houseplan-card-g88CD9VI.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-3-uORj4C.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";class C{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_openSpaceDialog(p,_){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===p){const i=this.host._serverCfg.spaces.find(t=>t.id===_);if(!i)return;const h=t(i),n=i.settings?.custom_fill&&"object"==typeof i.settings.custom_fill?s(i.settings.custom_fill):null,c="none"===h.fill?{...n||e,a:0}:n;return void(this.host._spaceDialog={mode:p,spaceId:_,title:i.title,planUrl:i.plan_url||null,planFile:null,source:i.plan_url?"file":"draw",showBorders:h.showBorders,showNames:h.showNames,zeroWallStyle:l(i),displayTouched:!0,hideDecor:h.hideDecor,hideOpenings:h.hideOpenings,roomColor:h.color,roomOpacity:h.opacity,fillMode:"none"===h.fill?"custom":h.fill,customFill:c,glowEnabled:h.glow,bgColor:h.bgColor,bgMode:"static"===i.settings?.bg_mode||"daynight"===i.settings?.bg_mode?i.settings.bg_mode:null,northDeg:a({},i.settings),sunRays:"boolean"==typeof i.settings?.sun_rays?i.settings.sun_rays:null,tempMin:h.tempMin,tempMax:h.tempMax,showLqi:h.showLqi??this.host._config?.show_signal??!0,cardFontScale:h.cardFontScale,labelTemp:h.labelTemp,labelHum:h.labelHum,labelLqi:h.labelLqi,labelLight:h.labelLight,cellCm:Number(i.cell_cm)>0?Number(i.cell_cm):5,cellCmInput:o(Number(i.cell_cm)>0?Number(i.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const d=i(this.host._imperial);this.host._spaceDialog={mode:p,title:"",planUrl:null,planFile:null,...b(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:r,roomOpacity:c,fillMode:"custom",customFill:{...e,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:n,tempMax:h,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:d,cellCmInput:o(d,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;const o={"image/svg+xml":"svg","image/png":"png","image/jpeg":"jpg","image/webp":"webp"}[e.type]||(e.name.toLowerCase().endsWith(".svg")?"svg":"");if(!o)return void this.host._showToast(this.host._t("toast.plan_formats"));const a=new Uint8Array(await e.arrayBuffer());let l="";for(let t=0;t<a.length;t+=32768)l+=String.fromCharCode(...a.subarray(t,t+32768));const i=btoa(l),h=URL.createObjectURL(e),n=await new Promise(t=>{const s=new Image;s.onload=()=>t(s.naturalWidth&&s.naturalHeight?s.naturalWidth/s.naturalHeight:1.414),s.onerror=()=>t(1.414),s.src=h});URL.revokeObjectURL(h),this.host._spaceDialog={...this.host._spaceDialog,planFile:{ext:o,b64:i,aspect:n,name:e.name}}}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return p`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return p`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return p`<div class="savedplans">
${s.map(s=>{return p`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
<img src=${this.host._display(s.url)} alt="" loading="lazy" decoding="async" />
@@ -1 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{t as a,e as c,o as i,r as s,s as t};
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function a(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),a=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),t=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&a.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:t,count:t.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";export{a,e as c,o as i,r as s,s as t};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a43476c101e31e0d3e3020d73f02e6dd01f08436f1f404819f58dfbeeebd8f9b";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BsbMwnTm.js";
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="99af70faf186a79ae24e2901edbf39e13d6388796d8958ab05fc9b1fca671aae";try{await import("./houseplan-assets/houseplan-card-g88CD9VI.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).":"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)}
+7
View File
@@ -23,6 +23,13 @@
remains available only while the configuration store is empty
([#340](https://github.com/Matysh/houseplan-card/issues/340)).
- The lazy editor survives flaky networks and updates: after a network
failure the "Edit" press retries the download instead of staying dead until
a page refresh, the toast says what to do, and a stale cached
`houseplan-card.js` after an update shows a "reload the page" panel instead
of an empty card. Content-hashed chunks are now served with immutable
caching ([#353](https://github.com/Matysh/houseplan-card/issues/353)).
- A very acute apex between walls of different thickness (say 15 and 30 cm)
on legacy plans renders as an honest tip again instead of the "trident":
the inner-face convergence accounts for both thicknesses, not just the
+8
View File
@@ -31,6 +31,14 @@
разрешена только пока хранилище конфигурации пусто
([#340](https://github.com/Matysh/houseplan-card/issues/340)).
- Ленивый редактор переживает нестабильную сеть и обновления: после сетевого
сбоя повторное нажатие «Редактировать» пробует загрузку заново, а не молчит
до перезагрузки страницы; тост подсказывает, что делать. Устаревший
`houseplan-card.js` из кэша после обновления показывает плашку «перезагрузите
страницу» вместо пустой карточки. Чанки с контент-хэшами теперь отдаются с
иммутабельным кэшированием
([#353](https://github.com/Matysh/houseplan-card/issues/353)).
- Очень острая вершина между стенами разной толщины (например, 15 и 30 см)
на старых планах снова рисуется честным остриём, а не «трезубцем»: расчёт
смыкания внутренних граней учитывает обе толщины, а не одну наибольшую
+12 -4
View File
@@ -3336,16 +3336,24 @@ require hands on real hardware — they remain for the human pass.
request for `houseplan-editor-runtime-*.js`. The first Plan/Devices/
Background intent requests it once; later editor switches do not repeat
the request [auto: `smoke_lazy_editor_chunk`].
- [ ] Two failed requests, or a runtime with a different build fingerprint,
leave mode, camera and plan in View and show the localized refresh advice
[auto: `editor-runtime-loader.test`, `smoke_lazy_editor_chunk`].
- [ ] Two failed network requests leave mode, camera and plan in View, show
the localized retry advice, and the next explicit press starts a fresh
load cycle that opens the editor once the network is back. A runtime with
a different build fingerprint is terminal and shows the refresh advice
(#353) [auto: `editor-runtime-loader.test`, `smoke_lazy_editor_chunk`].
- [ ] A cached stale entry whose main chunk the server no longer lists shows a
localized "reload the page" panel instead of a silently dead card; hashed
chunks are served immutable and an orphan chunk on disk fails the bundle
tree check (#353) [auto: `smoke_entry_stale`, `bundle-assets.test`,
`test_frontend_assets`].
- [ ] An empty installation requests the dedicated onboarding chunk, displays
the first-space dialog and still has no editor request. Saving a drawn
first space requests the editor once and continues into Plan; async
`getConfigElement()` still returns `houseplan-card-editor`
[auto: `smoke_lazy_editor_chunk`].
- [ ] `bundle:budget` follows transitive static imports and keeps initial View
at or below 256000 B gzip. Bundle sync, demo freshness, CI artifacts and
at or below 282000 B gzip (#352: ~10% headroom over the calibrated fact,
printed with the trend on every run). Bundle sync, demo freshness, CI artifacts and
release zip validation fail when any manifest-listed asset is missing or
its SHA-256 differs [auto: `bundle-assets.test`, `bundle-freshness.test`,
release-contract tests].
+6 -2
View File
@@ -107,8 +107,12 @@ content-hashed modules. A lone `houseplan-card.js` is not a supported install.
The ordinary View does not download editor code. The first opening of Plan,
Device or Background may therefore take a brief moment. If that internal module
cannot be loaded after one retry, the plan stays in View and asks you to refresh
the page; no half-open editor is kept.
cannot be loaded after one retry, the plan stays in View; no half-open editor is
kept. After a network failure the card invites you to check the connection and
press again — the next press starts a fresh download. Only when the tab holds
code from another build does the advice ask for a page refresh instead. A fully
stale proxy-cached `houseplan-card.js` no longer leaves an empty card after an
update: it shows a panel asking to reload the page.
### Permissions
+7 -2
View File
@@ -107,8 +107,13 @@ resources:
В обычном просмотре код редакторов не загружается. Поэтому первое открытие
«Плана», «Устройств» или «Подложки» может занять короткое время. Если внутренний
модуль не загрузился и после одной повторной попытки, план остаётся в просмотре
и предлагает обновить страницу; наполовину открытого редактора не остаётся.
модуль не загрузился и после одной повторной попытки, план остаётся в просмотре;
наполовину открытого редактора не остаётся. При сетевом сбое карточка предлагает
проверить сеть и нажать ещё раз — повторное нажатие пробует загрузку заново.
Если же на вкладке остался код другой версии карточки, поможет только
перезагрузка страницы, о чём и говорит подсказка. Полностью устаревший
`houseplan-card.js` из кэша прокси после обновления больше не оставляет пустую
карточку: вместо неё показывается плашка с просьбой перезагрузить страницу.
### Права пользователей
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+21 -21
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
"sourceFingerprint": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"sourceFingerprint": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,8 +15,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "78d328498bd17a9ee1ec44b389d244b61fca9ac52c9aad6631aa85c53233fec0"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -26,8 +26,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "a4aa3af48578ee41594a9b1a6e98b4d39f3324a949a9fd9e63d5854576d5db85"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "3be239a5a494f6776540a9c0049694ee6f9d2ba927c8bcb4b26ee5a69a228a7d"
},
"space-create": {
"file": "03-space-create.png",
@@ -37,8 +37,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -48,8 +48,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -59,8 +59,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -70,8 +70,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "8affadfce926fe2f6cf344c33f611c44fcbc79c1969f69c3a6935ff2e99c36c3"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -81,8 +81,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -92,8 +92,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
"file": "08-room-card.png",
@@ -103,8 +103,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
"file": "09-device-info.png",
@@ -114,8 +114,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "076f7b445baa360e1201aedb8a776f311b10b45de6de32da555039426ae14eb1",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
"sourceSha256": "20360427643d04e7068255e2a64cbdb296bfc772d8c11cfeb0baf753520addb5",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
}
+2
View File
@@ -9,6 +9,7 @@ import {
bundleManifestPlugin,
cleanBundleOutputPlugin,
editorRuntimeRetryUrlPlugin,
entryFallbackPlugin,
} from './scripts/bundle-manifest.mjs';
const SOURCE_FINGERPRINT = sourceFingerprint();
@@ -35,6 +36,7 @@ export default {
typescript({ compilerOptions: { outDir: 'dist/.ts' } }),
terser({ format: { comments: false } }),
editorRuntimeRetryUrlPlugin(),
entryFallbackPlugin(),
bundleManifestPlugin(SOURCE_FINGERPRINT),
],
};
+49
View File
@@ -174,6 +174,55 @@ export function editorRuntimeRetryUrlPlugin() {
};
}
/**
* Rewrite the entry facade so a stale cached `houseplan-card.js` fails loudly
* instead of killing the card silently (#353 К3). Rollup emits the facade as a
* STATIC re-export of the content-hashed main chunk; after an update a proxy-
* cached facade points at a chunk the manifest-gated server no longer serves,
* and a static import failure aborts the whole module before any code runs.
*
* The rewrite keeps the happy path intact via top-level await: an importer's
* `await import(entry)` does not resolve until the inner import settles, so
* `customElements.define` of the real card still happens-before the importer
* continues — demo, smokes and golden run unchanged. Only the failure branch
* is new: it defines a minimal fallback element with a human message.
*
* Must run BEFORE bundleManifestPlugin so the manifest hashes the final code.
*/
export function entryFallbackPlugin() {
return {
name: 'houseplan-entry-fallback',
generateBundle(_options, bundle) {
const entry = Object.values(bundle)
.find((item) => item.type === 'chunk' && item.isEntry);
if (!entry) throw new Error('entry chunk was not emitted');
const pattern = /export\{[^}]*\}from(["'])(\.\/houseplan-assets\/[^"']+\.js)\1;?/g;
const matches = [...entry.code.matchAll(pattern)];
if (matches.length !== 1) {
throw new Error(`entry facade re-export count is ${matches.length}, expected 1`);
}
const asset = matches[0][2];
const fallback = 'try{await import("' + asset + '")}'
+ '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)."'
+ ':"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)}';
entry.code = entry.code.replace(pattern, fallback);
},
};
}
/** Remove only files named by the previous generated manifest. */
export function cleanBundleOutputPlugin(outputRoot = 'dist') {
return {
+12 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -49,6 +49,17 @@ export function verifyBundleTree(root) {
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
}
}
// #353 К5: a chunk on disk that the manifest does not name is dead weight —
// it would ride into the release zip and mask sync bugs. Fail loudly.
const assetDir = resolve(root, 'houseplan-assets');
if (existsSync(assetDir)) {
const listed = new Set(manifest.files.map((file) => file.path));
for (const name of readdirSync(assetDir)) {
if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {
throw new Error(`orphan bundle asset: houseplan-assets/${name}`);
}
}
}
return manifest;
}
+58
View File
@@ -634,6 +634,64 @@ const MUTANT_DEFINITIONS = [
replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;',
}],
},
{
id: 'lazy-loader-network-failure-terminal',
guard: 'node --test --test-name-pattern="#353 AC1" test/editor-runtime-loader.test.mjs',
because: 'a transient network failure must re-arm the loader for the next explicit press — '
+ 'a terminal state blocks the editor until a page refresh (#353 К1)',
patches: [{
file: 'src/editor-runtime-loader.ts',
find: " this._setState(sawMismatch ? 'failed' : 'idle');",
replace: " this._setState('failed');",
}],
},
{
id: 'lazy-loader-terminality-hardcoded',
guard: 'node --test --test-name-pattern="#353" test/editor-runtime-loader.test.mjs',
because: 'a hardcoded terminal flag shows the wrong advice — the network toast must invite '
+ 'another press, the mismatch toast must demand a refresh (#353 К2/AC5)',
patches: [{
file: 'src/editor-runtime-loader.ts',
find: ' this.options.failed?.(lastError, { terminal: sawMismatch });',
replace: ' this.options.failed?.(lastError, { terminal: true });',
}],
},
{
id: 'lazy-chunk-cache-control-reverted',
guard: 'node scripts/backend-test-guard.mjs hashed_chunks_are_immutably_cacheable '
+ 'tests_backend/test_frontend_assets.py',
because: 'content-hashed chunk bodies never change under their URL — reverting to no-cache '
+ 'reopens the stale-proxy window and refetches on every editor entry (#353 К4)',
patches: [{
file: 'custom_components/houseplan/frontend_asset_manifest.py',
find: 'ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"',
replace: 'ASSET_CACHE_CONTROL = "no-cache"',
}],
},
{
id: 'bundle-tree-orphans-ignored',
guard: 'node --test --test-name-pattern="#353 AC4" test/bundle-assets.test.mjs',
because: 'an unlisted chunk on disk rides into the release zip and masks sync bugs — '
+ 'the tree check must fail loudly on orphans (#353 К5)',
patches: [{
file: 'scripts/bundle-tree.mjs',
find: " if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {\n"
+ ' throw new Error(`orphan bundle asset: houseplan-assets/${name}`);\n'
+ ' }',
replace: " void name; void listed;",
}],
},
{
id: 'entry-fallback-rewrite-skipped',
guard: 'node demo/smoke_entry_stale.mjs',
because: 'without the rewrite the entry keeps a STATIC re-export: after an update a cached '
+ 'entry aborts before any code runs and the card dies silently (#353 К3)',
patches: [{
file: 'scripts/bundle-manifest.mjs',
find: ' entry.code = entry.code.replace(pattern, fallback);',
replace: ' void fallback;',
}],
},
{
id: 'junction-limit-key-precision-lost',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+7 -4
View File
@@ -54,12 +54,15 @@ export const SMOKE_LINKS = [
{
symbols: [
'EditorRuntimeLoader', 'EditorRuntimeLoaderState', 'EditorRuntimeModule',
'EditorRuntimeLoaderFailure', 'lazyLoadFailureMessage',
'EDITOR_RUNTIME_FINGERPRINT', 'createHouseplanEditorRuntime',
],
smokes: ['smoke_lazy_editor_chunk.mjs'],
because: 'the production-bundle scenario proves that the content-hashed editor chunk stays '
+ 'off the View network path, is shared by all three editors, retries once, and refuses an '
+ 'incompatible build before editor state is installed (#337)',
smokes: ['smoke_lazy_editor_chunk.mjs', 'smoke_entry_stale.mjs'],
because: 'the production-bundle scenarios prove that the content-hashed editor chunk stays '
+ 'off the View network path, is shared by all three editors, retries once per cycle, '
+ 're-arms after a network failure so the next press heals (#353), refuses an incompatible '
+ 'build before editor state is installed (#337), and that a stale cached entry shows a '
+ 'localized reload panel instead of dying silently (#353)',
},
{
symbols: [
+38 -7
View File
@@ -5,20 +5,49 @@ export interface EditorRuntimeModule<Runtime> {
create(): Runtime;
}
export interface EditorRuntimeLoaderFailure {
/** Terminal failures need a page refresh; the rest retry on the next intent. */
readonly terminal: boolean;
}
export interface EditorRuntimeLoaderOptions<Runtime> {
readonly expectedFingerprint: string;
readonly load: (attempt: 0 | 1) => Promise<EditorRuntimeModule<Runtime>>;
readonly install: (runtime: Runtime) => void;
readonly stateChanged?: (state: EditorRuntimeLoaderState) => void;
readonly failed?: (error: unknown) => void;
readonly failed?: (error: unknown, info: EditorRuntimeLoaderFailure) => void;
}
/**
* One toast wording for every lazy-runtime failure (#353 AC5). A terminal
* failure means this tab runs code from another build — only a refresh helps.
* A network failure heals on the next explicit press, so the advice differs.
*/
export type LazyLoadFailureKey =
| 'editor.load_failed' | 'editor.refresh_advice' | 'editor.retry_advice';
export function lazyLoadFailureMessage(
t: (key: LazyLoadFailureKey) => string,
info: EditorRuntimeLoaderFailure,
): string {
return `${t('editor.load_failed')} ${t(info.terminal ? 'editor.refresh_advice' : 'editor.retry_advice')}`;
}
/** Loader-owned marker: the served module belongs to a different build. */
class FingerprintMismatchError extends Error {}
/**
* One atomic lazy-runtime boundary shared by all editor entry points.
*
* A failed module is retried exactly once. Construction happens before
* `install`, so a parse, fingerprint or constructor failure cannot leave a
* half-installed editor attached to the View card.
* A failed module is retried exactly once per load cycle. Construction happens
* before `install`, so a parse, fingerprint or constructor failure cannot
* leave a half-installed editor attached to the View card.
*
* Failure outcomes differ (#353): a fingerprint mismatch on either attempt is
* terminal — the tab holds another build and only a refresh helps, so `ensure`
* keeps returning `false` without importing again. Any other failure (network,
* parse) reports via `failed` and returns the loader to `idle`, so the NEXT
* explicit user intent starts a fresh cycle. There are no background retries.
*/
export class EditorRuntimeLoader<Runtime> {
private _state: EditorRuntimeLoaderState = 'idle';
@@ -43,11 +72,12 @@ export class EditorRuntimeLoader<Runtime> {
private async _loadWithRetry(): Promise<boolean> {
let lastError: unknown = new Error('Editor runtime did not load');
let sawMismatch = false;
for (const attempt of [0, 1] as const) {
try {
const module = await this.options.load(attempt);
if (module.fingerprint !== this.options.expectedFingerprint) {
throw new Error(
throw new FingerprintMismatchError(
`Editor runtime fingerprint mismatch: expected ${this.options.expectedFingerprint}, got ${module.fingerprint}`,
);
}
@@ -57,10 +87,11 @@ export class EditorRuntimeLoader<Runtime> {
return true;
} catch (error: unknown) {
lastError = error;
if (error instanceof FingerprintMismatchError) sawMismatch = true;
}
}
this._setState('failed');
this.options.failed?.(lastError);
this._setState(sawMismatch ? 'failed' : 'idle');
this.options.failed?.(lastError, { terminal: sawMismatch });
return false;
}
+12 -7
View File
@@ -271,7 +271,12 @@ import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
} from './mode-transition';
import { EditorRuntimeLoader, type EditorRuntimeLoaderState } from './editor-runtime-loader';
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
// Chromium records a FAILED module in the page module map permanently — a
// retry of the same URL resolves from that map without touching the network.
// Each retry therefore needs a fresh query nonce to become a new module (#353).
let hpLazyRetrySeq = 0;
import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
@@ -815,7 +820,7 @@ export class HouseplanCard extends LitElement {
? await import('./houseplan-editor-runtime')
: await import(/* @vite-ignore */ (() => {
const url = new URL(EDITOR_RETRY_ASSET, import.meta.url);
url.searchParams.set('hp_retry', CARD_VERSION);
url.searchParams.set('hp_retry', `${CARD_VERSION}-${++hpLazyRetrySeq}`);
return url.href;
})()) as typeof import('./houseplan-editor-runtime');
return {
@@ -829,9 +834,9 @@ export class HouseplanCard extends LitElement {
this._editorRuntime = runtime;
},
stateChanged: (state) => this._editorRuntimeStateChanged(state),
failed: (error) => {
failed: (error, info) => {
console.error('[houseplan] unable to load editor runtime', error);
this._showToast(`${this._t('editor.load_failed')} ${this._t('editor.refresh_advice')}`);
this._showToast(lazyLoadFailureMessage((key) => this._t(key), info));
},
});
@@ -844,7 +849,7 @@ export class HouseplanCard extends LitElement {
? await import('./houseplan-onboarding-runtime')
: await import(/* @vite-ignore */ (() => {
const url = new URL('__HOUSEPLAN_ONBOARDING_RETRY_ASSET__', import.meta.url);
url.searchParams.set('hp_retry', CARD_VERSION);
url.searchParams.set('hp_retry', `${CARD_VERSION}-${++hpLazyRetrySeq}`);
return url.href;
})()) as typeof import('./houseplan-onboarding-runtime');
return {
@@ -857,9 +862,9 @@ export class HouseplanCard extends LitElement {
install: (runtime) => {
this._onboardingRuntime = runtime;
},
failed: (error) => {
failed: (error, info) => {
console.error('[houseplan] unable to load onboarding runtime', error);
this._showToast(`${this._t('editor.load_failed')} ${this._t('editor.refresh_advice')}`);
this._showToast(lazyLoadFailureMessage((key) => this._t(key), info));
},
});
+1
View File
@@ -9,6 +9,7 @@
"editor.loading_aria": "Der Plan-Editor wird geladen",
"editor.load_failed": "Der Editor konnte nicht geladen werden.",
"editor.refresh_advice": "Seite aktualisieren und erneut versuchen.",
"editor.retry_advice": "Verbindung prüfen und erneut drücken.",
"color_picker.title": "Farbwähler",
"color_picker.hue": "Farbton",
"color_picker.saturation": "Sättigung",
+1
View File
@@ -9,6 +9,7 @@
"editor.loading_aria": "The plan editor is loading",
"editor.load_failed": "Could not load the editor.",
"editor.refresh_advice": "Refresh the page and try again.",
"editor.retry_advice": "Check your connection and press again.",
"color_picker.title": "Color picker",
"color_picker.hue": "Hue",
"color_picker.saturation": "Saturation",
+1
View File
@@ -9,6 +9,7 @@
"editor.loading_aria": "Редактор плана загружается",
"editor.load_failed": "Не удалось загрузить редактор.",
"editor.refresh_advice": "Обновите страницу и повторите попытку.",
"editor.retry_advice": "Проверьте сеть и нажмите ещё раз.",
"color_picker.title": "Выбор цвета",
"color_picker.hue": "Оттенок",
"color_picker.saturation": "Насыщенность",
+49
View File
@@ -147,3 +147,52 @@ test('bundle tree verification fails for a missing or tampered manifest asset',
rmSync(temp, { recursive: true, force: true });
}
});
test('entry facade fails loudly when the main chunk is unavailable (#353 AC3a)', () => {
const entry = readFileSync(new URL('../dist/houseplan-card.js', import.meta.url), 'utf8');
assert.match(
entry,
/^globalThis\.__HOUSEPLAN_BUILD_FINGERPRINT__="[0-9a-f]{64}";/,
'the fail-closed fingerprint intro must survive the rewrite',
);
assert.match(
entry,
/try\{await import\("\.\/houseplan-assets\/[^"]+\.js"\)\}catch\(/,
'the entry must await the main chunk so importers keep the happy-path guarantee',
);
assert.match(entry, /customElements\.define\("houseplan-card",/);
assert.match(entry, /reload the page/);
assert.doesNotMatch(
entry,
/(?:^|;)(?:export|import)[\s{"']/m,
'no static import/export may remain — a static edge aborts the module before any code runs',
);
});
test('bundle tree verification rejects orphan chunks (#353 AC4)', async () => {
const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { verifyBundleTree, sha256Bytes } = await import('../scripts/bundle-tree.mjs');
const root = mkdtempSync(join(tmpdir(), 'hp-tree-'));
mkdirSync(join(root, 'houseplan-assets'));
const entryCode = 'try{await import("./houseplan-assets/main-abc.js")}catch(e){}';
const chunkCode = 'export const x = 1;';
writeFileSync(join(root, 'houseplan-card.js'), entryCode);
writeFileSync(join(root, 'houseplan-assets', 'main-abc.js'), chunkCode);
writeFileSync(join(root, 'houseplan-assets.json'), JSON.stringify({
schema: 1,
fingerprint: 'f'.repeat(64),
entry: 'houseplan-card.js',
files: [
{ path: 'houseplan-card.js', sha256: sha256Bytes(Buffer.from(entryCode)) },
{ path: 'houseplan-assets/main-abc.js', sha256: sha256Bytes(Buffer.from(chunkCode)) },
],
}));
assert.equal(verifyBundleTree(root).files.length, 2, 'a clean tree verifies');
writeFileSync(join(root, 'houseplan-assets', 'junk-old.js'), 'stale');
assert.throws(
() => verifyBundleTree(root),
/orphan bundle asset: houseplan-assets\/junk-old\.js/,
);
});
+103
View File
@@ -137,3 +137,106 @@ test('documentation capture materializes the complete bundle tree', () => {
assert.match(capture, /import ['"]\.\.\/\.\.\/scripts\/bundle-sync\.mjs['"]/);
assert.doesNotMatch(capture, /copyFileSync\(BUNDLE,\s*DEMO_BUNDLE\)/);
});
test('network failure re-arms the loader for the next explicit intent (#353 AC1)', async () => {
const attempts = [];
const failures = [];
let cycles = 0;
const loader = new EditorRuntimeLoader({
expectedFingerprint: 'same',
load: async (attempt) => {
if (attempt === 0) cycles++;
attempts.push(attempt);
if (cycles < 3) throw new Error('net::ERR_INTERNET_DISCONNECTED');
return { fingerprint: 'same', create: () => 'runtime' };
},
install: () => {},
failed: (error, info) => failures.push({ error: String(error), terminal: info.terminal }),
});
assert.equal(await loader.ensure(), false);
assert.equal(loader.state, 'idle', 'a network failure must not be terminal');
assert.deepEqual(attempts, [0, 1]);
assert.equal(await loader.ensure(), false, 'second press runs a fresh cycle');
assert.deepEqual(attempts, [0, 1, 0, 1]);
assert.equal(failures.length, 2, 'every failed cycle is reported');
assert.ok(failures.every((failure) => failure.terminal === false));
assert.equal(await loader.ensure(), true, 'third press succeeds');
assert.equal(loader.state, 'ready');
assert.equal(failures.length, 2);
});
test('fingerprint mismatch on either attempt is terminal (#353 AC2)', async () => {
const attempts = [];
const failures = [];
const loader = new EditorRuntimeLoader({
expectedFingerprint: 'entry',
load: async (attempt) => {
attempts.push(attempt);
if (attempt === 0) return { fingerprint: 'other', create: () => 'foreign' };
throw new Error('net::ERR_FAILED');
},
install: () => {},
failed: (error, info) => failures.push(info.terminal),
});
assert.equal(await loader.ensure(), false);
assert.equal(loader.state, 'failed', 'a foreign build was observed — only a refresh helps');
assert.deepEqual(failures, [true]);
assert.equal(await loader.ensure(), false);
assert.deepEqual(attempts, [0, 1], 'terminal failure never imports again');
});
test('lazy failure toast wording follows terminality (#353 AC5)', async () => {
const { lazyLoadFailureMessage } = await import('../test-build/editor-runtime-loader.js');
const t = (key) => `<${key}>`;
assert.equal(
lazyLoadFailureMessage(t, { terminal: true }),
'<editor.load_failed> <editor.refresh_advice>',
);
assert.equal(
lazyLoadFailureMessage(t, { terminal: false }),
'<editor.load_failed> <editor.retry_advice>',
);
});
test('both card loaders route their toast through lazyLoadFailureMessage (#353 AC5)', async () => {
const ts = (await import('typescript')).default;
const source = readFileSync(join(repoRoot, 'src', 'houseplan-card.ts'), 'utf8');
const file = ts.createSourceFile(
'houseplan-card.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS,
);
const callbacks = [];
const visit = (node) => {
if (ts.isPropertyAssignment(node)
&& node.name.getText(file) === 'failed'
&& ts.isArrowFunction(node.initializer)) {
callbacks.push(node.initializer);
}
ts.forEachChild(node, visit);
};
visit(file);
assert.equal(callbacks.length, 2, 'editor and onboarding loaders both declare failed callbacks');
for (const callback of callbacks) {
assert.equal(callback.parameters.length, 2, 'the failure info parameter must be declared');
const infoName = callback.parameters[1].name.getText(file);
let forwards = false;
const inspect = (node) => {
if (ts.isCallExpression(node)
&& node.expression.getText(file) === 'lazyLoadFailureMessage'
&& node.arguments.length === 2
&& node.arguments[1].getText(file) === infoName) {
forwards = true;
}
ts.forEachChild(node, inspect);
};
inspect(callback.body);
assert.ok(
forwards,
'the failed callback must forward its info argument into lazyLoadFailureMessage — '
+ 'a hardcoded terminality would show the wrong advice',
);
}
});
+8
View File
@@ -66,3 +66,11 @@ def test_frontend_asset_refuses_wrong_schema_and_symlink_escape(tmp_path: Path)
"schema": 1, "files": [{"path": "houseplan-assets/linked.js"}],
}), encoding="utf-8")
assert resolve_frontend_asset(root, "linked.js") is None
def test_hashed_chunks_are_immutably_cacheable() -> None:
"""#353 AC3-в: content-hashed bodies never change under their URL."""
assert _MODULE.ASSET_CACHE_CONTROL == "public, max-age=31536000, immutable"
view_source = (_PATH.parent / "frontend_assets.py").read_text(encoding="utf-8")
assert "ASSET_CACHE_CONTROL" in view_source
assert '"no-cache"' not in view_source