feat: add complete German localization

Add Deutsch across all card surfaces and backend flows, backed by the language registry introduced in #62. German loads as a fingerprint-checked page-shared locale chunk so EN/RU remain synchronous and the initial View budget stays intact. Root render gates prevent mixed-language flashes, retry once, and fail open to English. Extend parity, runtime, bundle, browser and visual coverage, plus contributor and user documentation.

Issue: #348
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 11:58:46 +03:00
parent 7a0702adea
commit 12cd8a1af3
50 changed files with 2265 additions and 524 deletions
+11 -2
View File
@@ -16,8 +16,8 @@ A shipped UI language has three matching parts:
1. `src/i18n/<code>.json` for the card;
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
integration;
3. one static entry (dictionary import, code and native label) in
`src/i18n/registry.ts`.
3. one registry entry (code, native label and eager dictionary or lazy loader)
in `src/i18n/registry.ts`.
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
@@ -29,6 +29,15 @@ also fail on mismatched keys, empty values and changed placeholders.
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
or remove them.
English and Russian are synchronous fallback/legacy locales. German is the
reference lazy third-locale implementation: its module carries the same build
fingerprint as the entry bundle, has one content-hashed retry URL and is listed
under `lazyLocaleFiles` in `houseplan-assets.json`. New sizeable locales should
follow that path unless a measured initial-bundle budget explicitly justifies
an eager import. Extend the runtime, manifest, file/key/placeholder parity and
regional-locale tests together; never bypass the registry by importing a locale
directly in a component.
The current `subst()` helper does not implement plural rules. Phrase strings so
their grammar does not depend on the numeric value (for example, use a neutral
label followed by `{n}` rather than an English singular/plural pair).
@@ -1,96 +1,111 @@
{
"schema": 1,
"fingerprint": "e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479",
"fingerprint": "fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-OQuczTJf.js",
"houseplan-assets/houseplan-card-r12Gbmbf.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 255908,
"initialViewGzipBytes": 256014,
"lazyFiles": [
"houseplan-assets/editor-DMlizeQy.js",
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/de-D1v4NXmi.js",
"houseplan-assets/editor-DCM9zOUG.js",
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"lazyGzipBytes": 138428,
"lazyGzipBytes": 160371,
"lazyEditorFiles": [
"houseplan-assets/editor-DMlizeQy.js",
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/editor-DCM9zOUG.js",
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"lazyEditorGzipBytes": 131779,
"lazyEditorGzipBytes": 131840,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"lazyOnboardingGzipBytes": 7203,
"lazyOnboardingGzipBytes": 7204,
"lazyLocaleFiles": [
"houseplan-assets/de-D1v4NXmi.js"
],
"lazyLocaleGzipBytes": 21882,
"files": [
{
"path": "houseplan-assets/editor-DMlizeQy.js",
"sha256": "7aeb38573980efdf90749280c7be4150f41bec4939919c9d84ecdf028185fd6a",
"rawBytes": 3699,
"gzipBytes": 1530,
"path": "houseplan-assets/de-D1v4NXmi.js",
"sha256": "22e1ceddb969fc41613eda6e46ef23f5bdc78e2cf2ebc54dd9e034afd0e4179d",
"rawBytes": 71477,
"gzipBytes": 21882,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DCM9zOUG.js",
"sha256": "ceb7440e98fe3d1a0e03df973ffa54885be47a5f583d96ca88adb7f9af0f126b",
"rawBytes": 3831,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-OQuczTJf.js",
"sha256": "bbff295ebc0def9401caac0001245371490977fe612cc08c2402cfca5b827faf",
"rawBytes": 910654,
"gzipBytes": 255726,
"path": "houseplan-assets/houseplan-card-r12Gbmbf.js",
"sha256": "d7a481d2c8436ff1304cb5a57a9dacf4f33152c6bcea10a3890b2b50fc315366",
"rawBytes": 910015,
"gzipBytes": 255832,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/editor-DMlizeQy.js",
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js"
"houseplan-assets/de-D1v4NXmi.js",
"houseplan-assets/editor-DCM9zOUG.js",
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"sha256": "616d30f0d4a61a14f8c149c55e6a8d12647aa80bdd31c32b57d2803ba9c5653b",
"path": "houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"sha256": "96bc8b839a02422bc5581e21a8e23252273638f35cc1156fb50c611195b3c408",
"rawBytes": 504952,
"gzipBytes": 129695,
"gzipBytes": 129700,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
"sha256": "218d3b7f139f12bb842f7f888ba2e2fa97ca2f11b8bbfd6c2b9e16eff0647eef",
"path": "houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
"sha256": "0d1c720e24b006cd3db29f5a3e0f6731c9bd6a0131b7ad9e095e4defed693e08",
"rawBytes": 26356,
"gzipBytes": 6649,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-7mvB_XVT.js",
"sha256": "ca7cdbe51f937d2cd53385204d3b7917ec18f50c51b5aef4eb3e273516b08747",
"path": "houseplan-assets/space-deletion-C17YDpT4.js",
"sha256": "5c0033022fdaeb456ef7e400a7771b2130009cb3ac46b4e5498600ebb7d45cd8",
"rawBytes": 911,
"gzipBytes": 554,
"gzipBytes": 555,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "45da1371d0ca2713a6a8f6108599c614bed0907f2c80556192375dbfdc741e2e",
"sha256": "30e3307c785417cac8bbbc756100d2593150de8519ae559e60d169603c83afc7",
"rawBytes": 190,
"gzipBytes": 182,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";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-r12Gbmbf.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
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.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);
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-OQuczTJf.js";class i 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")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),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;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=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),n=e=>l`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>o[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return l`
${n(s.slice(0,3))}
${i?l`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(e,"editor.default_floor_missing",{id:i})}
</div>`:a}
${n(s.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)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";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-OQuczTJf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-7mvB_XVT.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";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__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";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-r12Gbmbf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-C17YDpT4.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";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__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";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 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__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{t as a,e as c,o as i,r as s,s as t};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";export{d3 as HouseplanCard}from"./houseplan-assets/houseplan-card-OQuczTJf.js";
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-r12Gbmbf.js";
@@ -0,0 +1,30 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": {
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
}
}
},
"abort": {
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
}
},
"options": {
"step": {
"init": {
"data": {
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
}
}
}
},
"issues": {
"broken_plan": {
"title": "Grundrissbild fehlt",
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
}
}
}
+8 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 48;
export const GOLDEN_MATRIX_VERSION = 49;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -663,6 +663,13 @@ export const GOLDEN_SCENARIOS = Object.freeze([
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'device-dialog-desktop-de', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Wohnzimmer-Lichtsteuerung mit absichtlich sehr langem Namen',
language: 'de', theme: 'light', viewport: { width: 1180, height: 1200 }, ...page },
{ id: 'german-view-mobile-light', fixture: 'visual', space: 'golden-geometry',
mode: 'view', language: 'de', theme: 'light',
viewport: { width: 390, height: 900 }, ...page },
{ id: 'toggle-entity-dialog-desktop-en', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
deviceToggleEntity: 'selected', markerOverrides: [{
+84
View File
@@ -0,0 +1,84 @@
// #348: German is a page-shared lazy locale with a neutral render gate and bounded fallback.
import { readFileSync } from 'node:fs';
import { launchColdView, checkAll, finish } from './serve.mjs';
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
const localePath = manifest.lazyLocaleFiles?.[0];
if (!localePath) throw new Error('German locale is absent from the bundle manifest');
const localeName = localePath.split('/').at(-1);
const localePattern = `**/${localeName}*`;
const replaceWithGermanCard = (suffix = '') => {
const old = window.__card;
const host = document.getElementById('host');
const config = { ...old._config, title: `Deutsch${suffix}`, language: 'de' };
const hass = { ...old.hass, language: 'de-DE', locale: { language: 'de-DE' } };
old.remove();
const card = document.createElement('houseplan-card');
card.setConfig(config);
card.hass = hass;
host.append(card);
window.__card = card;
};
const cold = await launchColdView();
const requested = [];
cold.page.on('request', (request) => requested.push(new URL(request.url()).pathname));
const resourcesBefore = await cold.page.evaluate(() => performance.getEntriesByType('resource')
.map((entry) => new URL(entry.name).pathname));
await cold.page.route(localePattern, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 250));
await route.fallback();
});
await cold.page.evaluate(replaceWithGermanCard);
await cold.page.waitForTimeout(60);
const neutral = await cold.page.locator('houseplan-card').evaluate((card) => ({
inert: card.inert,
busy: card.getAttribute('aria-busy'),
progress: !!card.renderRoot.querySelector('ha-circular-progress'),
text: card.renderRoot.textContent.trim(),
}));
await cold.page.waitForFunction(() => window.__card?._model?.length > 0
&& window.__card._t('btn.save') === 'Speichern'
&& !window.__card.hasAttribute('aria-busy'));
const out = {
englishColdViewDoesNotRequestGerman: !resourcesBefore.some((path) => path.endsWith(`/${localeName}`)),
germanColdFrameIsNeutral: neutral.inert && neutral.busy === 'true'
&& neutral.progress && neutral.text === '',
germanLoadsExactlyOnce: requested.filter((path) => path.endsWith(`/${localeName}`)).length === 1,
germanCopyCommitsAtomically: await cold.page.evaluate(() =>
window.__card._t('btn.save') === 'Speichern'
&& window.__card._t('btn.cancel') === 'Abbrechen'
&& window.__card._t('space.header') === 'Bereich'
&& window.__card.getAttribute('lang') === 'de'),
};
const beforeSecond = requested.length;
await cold.page.evaluate(replaceWithGermanCard, ' 2');
await cold.page.waitForFunction(() => window.__card?._model?.length > 0
&& window.__card._t('btn.save') === 'Speichern');
out.secondCardReusesPageLocale = requested.length === beforeSecond;
const failed = await launchColdView();
let failedRequests = 0;
let warnings = 0;
failed.page.on('console', (message) => {
if (message.type() === 'warning' && message.text().includes('unable to load de locale')) warnings++;
});
await failed.page.route(localePattern, async (route) => {
failedRequests++;
await route.abort('failed');
});
await failed.page.evaluate(replaceWithGermanCard);
await failed.page.waitForFunction(() => window.__card?._model?.length > 0
&& !window.__card.hasAttribute('aria-busy')
&& window.__card._t('btn.save') === 'Save');
out.failureRetriesExactlyOnce = failedRequests === 2;
out.failureFallsBackAndUnblocks = await failed.page.evaluate(() =>
!window.__card.inert && window.__card._t('btn.cancel') === 'Cancel'
&& window.__card.getAttribute('lang') === 'en');
out.failureWarnsOnce = warnings === 1;
await failed.browser.close();
checkAll(out);
await finish(cold.browser, out);
+56 -41
View File
@@ -1,96 +1,111 @@
{
"schema": 1,
"fingerprint": "e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479",
"fingerprint": "fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-OQuczTJf.js",
"houseplan-assets/houseplan-card-r12Gbmbf.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 255908,
"initialViewGzipBytes": 256014,
"lazyFiles": [
"houseplan-assets/editor-DMlizeQy.js",
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/de-D1v4NXmi.js",
"houseplan-assets/editor-DCM9zOUG.js",
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"lazyGzipBytes": 138428,
"lazyGzipBytes": 160371,
"lazyEditorFiles": [
"houseplan-assets/editor-DMlizeQy.js",
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/editor-DCM9zOUG.js",
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"lazyEditorGzipBytes": 131779,
"lazyEditorGzipBytes": 131840,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"lazyOnboardingGzipBytes": 7203,
"lazyOnboardingGzipBytes": 7204,
"lazyLocaleFiles": [
"houseplan-assets/de-D1v4NXmi.js"
],
"lazyLocaleGzipBytes": 21882,
"files": [
{
"path": "houseplan-assets/editor-DMlizeQy.js",
"sha256": "7aeb38573980efdf90749280c7be4150f41bec4939919c9d84ecdf028185fd6a",
"rawBytes": 3699,
"gzipBytes": 1530,
"path": "houseplan-assets/de-D1v4NXmi.js",
"sha256": "22e1ceddb969fc41613eda6e46ef23f5bdc78e2cf2ebc54dd9e034afd0e4179d",
"rawBytes": 71477,
"gzipBytes": 21882,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DCM9zOUG.js",
"sha256": "ceb7440e98fe3d1a0e03df973ffa54885be47a5f583d96ca88adb7f9af0f126b",
"rawBytes": 3831,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-OQuczTJf.js",
"sha256": "bbff295ebc0def9401caac0001245371490977fe612cc08c2402cfca5b827faf",
"rawBytes": 910654,
"gzipBytes": 255726,
"path": "houseplan-assets/houseplan-card-r12Gbmbf.js",
"sha256": "d7a481d2c8436ff1304cb5a57a9dacf4f33152c6bcea10a3890b2b50fc315366",
"rawBytes": 910015,
"gzipBytes": 255832,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/editor-DMlizeQy.js",
"houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js"
"houseplan-assets/de-D1v4NXmi.js",
"houseplan-assets/editor-DCM9zOUG.js",
"houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Bk__jFmU.js",
"sha256": "616d30f0d4a61a14f8c149c55e6a8d12647aa80bdd31c32b57d2803ba9c5653b",
"path": "houseplan-assets/houseplan-editor-runtime-DkMruBHm.js",
"sha256": "96bc8b839a02422bc5581e21a8e23252273638f35cc1156fb50c611195b3c408",
"rawBytes": 504952,
"gzipBytes": 129695,
"gzipBytes": 129700,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Dor16U6K.js",
"sha256": "218d3b7f139f12bb842f7f888ba2e2fa97ca2f11b8bbfd6c2b9e16eff0647eef",
"path": "houseplan-assets/houseplan-onboarding-runtime-CahFgcPE.js",
"sha256": "0d1c720e24b006cd3db29f5a3e0f6731c9bd6a0131b7ad9e095e4defed693e08",
"rawBytes": 26356,
"gzipBytes": 6649,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js",
"houseplan-assets/space-deletion-7mvB_XVT.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js",
"houseplan-assets/space-deletion-C17YDpT4.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-7mvB_XVT.js",
"sha256": "ca7cdbe51f937d2cd53385204d3b7917ec18f50c51b5aef4eb3e273516b08747",
"path": "houseplan-assets/space-deletion-C17YDpT4.js",
"sha256": "5c0033022fdaeb456ef7e400a7771b2130009cb3ac46b4e5498600ebb7d45cd8",
"rawBytes": 911,
"gzipBytes": 554,
"gzipBytes": 555,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "45da1371d0ca2713a6a8f6108599c614bed0907f2c80556192375dbfdc741e2e",
"sha256": "30e3307c785417cac8bbbc756100d2593150de8519ae559e60d169603c83afc7",
"rawBytes": 190,
"gzipBytes": 182,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-OQuczTJf.js"
"houseplan-assets/houseplan-card-r12Gbmbf.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";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-r12Gbmbf.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
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.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);
-14
View File
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-OQuczTJf.js";class i 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")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),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;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=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),n=e=>l`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>o[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return l`
${n(s.slice(0,3))}
${i?l`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(e,"editor.default_floor_missing",{id:i})}
</div>`:a}
${n(s.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)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";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-OQuczTJf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-7mvB_XVT.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";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__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";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-r12Gbmbf.js";import{i as b,c as $,a as v,s as f,t as y}from"./space-deletion-C17YDpT4.js";const w=1e3,D=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},S="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";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__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";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 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__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{t as a,e as c,o as i,r as s,s as t};
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e499c3cfebb07203abd2397b1303ed1a45286f24fcc9129f45917b8a99ba1479";export{d3 as HouseplanCard}from"./houseplan-assets/houseplan-card-OQuczTJf.js";
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc2e76b2329cb102665073952b1b9f6346ff98d02332e2a07eb58f45429dc757";export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-r12Gbmbf.js";
+7
View File
@@ -135,6 +135,13 @@ the same profiler available between stable promotions.
affordance exists only when both its localized body and complete accessible
label are non-empty; the card factory and `hp-help` enforce this independently,
so incomplete content cannot leave a dead focus target or a layout gap.
English and Russian dictionaries remain in the synchronous graph. German is
a fingerprint-checked lazy locale shared page-wide: every root card/editor
uses the same render gate, keeps a previously committed frame stable during
a language switch, and shows a language-neutral busy frame on a German cold
start. Two failed content-hashed attempts settle on English rather than
leaving an inert surface. The bundle manifest classifies this graph as
`lazyLocaleFiles`, separate from editor and onboarding graphs.
8. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts`
atomically chooses a strict real `sun.sun` sample or browser-local clock
fallback and returns only phase/source/light tokens. `src/day-cycle-render.ts`
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- House Plan now has a complete German interface. `Deutsch` can be selected
explicitly and is chosen automatically for German Home Assistant profiles;
the locale loads on demand without making the ordinary View bundle larger
([#348](https://github.com/Matysh/houseplan-card/issues/348)).
- View now starts without downloading the three editors; their shared runtime
is loaded on first use and protected by one retry and a build-version check.
Release and test tooling now verify the complete multi-file frontend asset
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- В House Plan появилась полная немецкая локализация. `Deutsch` можно выбрать
вручную, а для немецкого профиля Home Assistant она включается автоматически;
словарь загружается по требованию и не утяжеляет обычный режим просмотра
([#348](https://github.com/Matysh/houseplan-card/issues/348)).
- Режим просмотра теперь запускается без загрузки трёх редакторов: их общий
runtime подгружается при первом обращении и защищён повторной попыткой и
проверкой версии сборки. Релизные и тестовые инструменты проверяют всё дерево
+12 -1
View File
@@ -781,7 +781,18 @@ separately promised workflows:
- [ ] Tablet in kiosk/panel mode — View/kiosk, landscape, touch gestures
- [ ] Phone portrait, narrow ≤400 px — View and View dialogs/actions
- [ ] Dark theme and light theme (badges, dialogs, plan contrast)
- [ ] RU profile locale and EN profile locale (+ `language:` card option forcing each)
- [ ] RU, EN and DE profile locales (+ `language:` card option forcing each);
`de-DE`, `de-AT` and `de-CH` resolve to German, while an unknown locale
falls back to English [unit: i18n, i18n-runtime]
- [ ] German cold start requests exactly one locale chunk, shows only a neutral
busy frame before commit and never flashes English; a second card reuses
the page cache. EN/RU request no locale chunk [auto: German locale smoke]
- [ ] German locale download failure retries the content-hashed asset once and
then unblocks the card in English with one warning [unit: i18n-runtime;
auto: German locale smoke fault injection]
- [ ] German View and a representative settings/device dialog fit at desktop
and 390 px without horizontal overflow or clipped actions [golden: German
desktop/mobile scenarios]
## Installation / upgrade / removal
+7 -1
View File
@@ -131,7 +131,7 @@ title: House plan
| `title` | empty | Card title |
| `default_floor` | first/last opened | Initial/fallback space for an unpinned card |
| `floor` | not pinned | Keep this card on one stable space ID or zero-based YAML index |
| `language` | HA language | `auto`, `ru` or `en` |
| `language` | HA language | `auto`, `en`, `ru` or `de` |
| `icon_size` | `2.5` | Base marker size, 1–6% of plan width |
| `show_temperature` | `true` | Compact temperature and humidity values |
| `live_states` | `true` | Work/open/unavailable presentation and activity; alarms remain visible |
@@ -141,6 +141,12 @@ title: House plan
The legacy card-level `tap_action` is ignored. Each marker owns its action.
`auto` recognizes the primary Home Assistant locale: `de`, `de-DE`, `de-AT`
and `de-CH` use German. German is downloaded once on first use and shared by
all House Plan cards on the page. Until it is ready, a neutral busy surface is
shown instead of briefly flashing English; if both bounded download attempts
fail, the card becomes usable in English.
<!-- docs-section: first-run -->
## 4. Quick start
+8 -1
View File
@@ -135,7 +135,7 @@ title: План дома
| `title` | пусто | Заголовок карточки |
| `default_floor` | первое/последнее открытое | Стартовое/резервное пространство незакреплённой карточки |
| `floor` | не закреплено | Всегда показывать один stable ID пространства или числовой индекс YAML с нуля |
| `language` | язык HA | `auto`, `ru` или `en` |
| `language` | язык HA | `auto`, `en`, `ru` или `de` |
| `icon_size` | `2.5` | Базовый размер маркеров, 1–6% ширины плана |
| `show_temperature` | `true` | Показывает компактные значения температуры **и влажности** у подходящих устройств |
| `live_states` | `true` | Включает рабочие/открытые/недоступные состояния, смену иконок и активность; аварии остаются видимыми всегда |
@@ -145,6 +145,13 @@ title: План дома
Карточечный `tap_action` из старых конфигураций игнорируется. Действие задаётся отдельно для каждого устройства.
`auto` учитывает основной язык профиля Home Assistant: `de`, `de-DE`,
`de-AT` и `de-CH` включают немецкий интерфейс. Немецкий словарь один раз
загружается при первом использовании и разделяется всеми карточками House Plan
на странице. До готовности показывается нейтральный индикатор без краткой
вспышки английского текста; если обе ограниченные попытки загрузки не удались,
карточка остаётся работоспособной на английском.
<!-- docs-section: first-run -->
## 4. Быстрый старт
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceFingerprint": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "f8c9541c9d7fde462d0efba63dff70944dc03df76ce988eecaf2b269e2863a96"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "69cc6aa03948282feab1c623da418487cb9b8ebc3e57175b031cc40e5f9a88af"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "f6d190f0b2ac9125cea68aea2ebbcdf9a7081ae2a3565ff253fb1ed0c4a56483"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "ca8bb143d0d6679ff09f8e9c1f5ad68b2ca7cfd96a4740a4a650c251f55dfba5"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "06b67c5108f9ee29a226cc2920484ccffe0c79840aec09721072b25b91ef8f03"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "0b84d425c14389fb3f04779a1f553ebab1b84f801b23b119b3810770500d171f"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "143bda35ac596ce6d72ad7399e5d26238cccae577f2d8cff398cd1efcaf1661f"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "7e308a2b105fa6a9f788449ca30b8183375a32f0d0f8726f4a131e4f35527f57"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "f1bc10b478f7afc68e70cb4c37148f680b0bbd41d50a7d63a21067fc952ff134"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e9dd41c04c57da03c78b4f89b83081b4e8f6024fa958848c0b55380727834f55",
"sourceSha256": "14edbd86bf80d90c9d430a3ddf002c023553026d8cca15e476515aa7a001cba4",
"imageSha256": "2634cd7ead5720ce46ea4d6fa8f31006b740c520a73ddc97afe9a6d9d3b49778"
}
}
+1 -2
View File
@@ -2,7 +2,7 @@
- **Issue:** https://github.com/Matysh/houseplan-card/issues/348
- **Зависимость:** #62 — единый реестр языков и автоматические i18n-gates
- **Статус документа:** готово к ревью ТЗ
- **Статус документа:** реализовано, передано на код-ревью
- **Приоритет / тип:** P1 / feature
- **Трек:** полный: задача меняет публичный языковой контракт, несколько
frontend/backend-поверхностей, первый render и performance budget
@@ -382,4 +382,3 @@ config не нужны.
5. Добавить semantic/layout/network smoke и German golden scenarios.
6. Обновить docs/changelogs, собрать и синхронизировать bundle.
7. Прогнать gates, передать точный SHA на независимое code review.
+12
View File
@@ -12,9 +12,19 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
if (!manifest.lazyEditorFiles?.length) {
throw new Error('bundle has no lazy editor graph');
}
if (!manifest.lazyLocaleFiles?.length) {
throw new Error('bundle has no lazy locale graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy editor graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyLocaleFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy locale graph');
}
if (manifest.lazyLocaleFiles.some((path) => manifest.lazyEditorFiles.includes(path)
|| manifest.lazyOnboardingFiles?.includes(path))) {
throw new Error('lazy locale graph overlaps an editor graph');
}
if (manifest.initialViewGzipBytes > budget) {
throw new Error(
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
@@ -23,6 +33,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
return {
initialViewGzipBytes: manifest.initialViewGzipBytes,
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
};
}
@@ -32,6 +43,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
const result = assertBundleBudget(manifest);
console.log(`initial View: ${result.initialViewGzipBytes} B gzip`);
console.log(`lazy editor: ${result.lazyEditorGzipBytes} B gzip`);
console.log(`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
+33 -5
View File
@@ -6,6 +6,7 @@ import { gzipSync } from 'node:zlib';
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
@@ -27,6 +28,14 @@ export function buildBundleManifest(bundle, fingerprint) {
.filter((item) => item.type === 'chunk')
.map((chunk) => {
const contents = Buffer.from(chunk.code, 'utf8');
const modules = Object.keys(chunk.modules || {}).map((id) => id.replaceAll('\\', '/'));
const role = modules.some((id) => id.endsWith('/src/i18n/de.ts'))
? 'locale'
: modules.some((id) => id.endsWith('/src/houseplan-onboarding-runtime.ts'))
? 'onboarding'
: modules.some((id) => id.endsWith('/src/houseplan-editor-runtime.ts'))
? 'editor'
: undefined;
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
@@ -35,6 +44,7 @@ export function buildBundleManifest(bundle, fingerprint) {
isEntry: chunk.isEntry,
imports: [...chunk.imports].sort(),
dynamicImports: [...chunk.dynamicImports].sort(),
_role: role,
};
})
.sort((left, right) => left.path.localeCompare(right.path));
@@ -48,8 +58,13 @@ export function buildBundleManifest(bundle, fingerprint) {
for (const root of dynamicRoots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
}
const onboardingRoots = dynamicRoots.filter((path) => path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => !onboardingRoots.includes(path));
const localeRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'locale'
|| /(?:^|\/)de-[^/]+\.js$/.test(path));
const onboardingRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'onboarding'
|| path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => (byPath.get(path)?._role === 'editor'
|| /(?:^|\/)editor(?:-[^/]+)?\.js$/.test(path))
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
@@ -59,6 +74,7 @@ export function buildBundleManifest(bundle, fingerprint) {
};
const lazyEditor = graphFrom(editorRoots);
const lazyOnboarding = graphFrom(onboardingRoots);
const lazyLocale = graphFrom(localeRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
@@ -73,7 +89,9 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyEditorGzipBytes: sum(lazyEditor),
lazyOnboardingFiles: [...lazyOnboarding].sort(),
lazyOnboardingGzipBytes: sum(lazyOnboarding),
files,
lazyLocaleFiles: [...lazyLocale].sort(),
lazyLocaleGzipBytes: sum(lazyLocale),
files: files.map(({ _role, ...file }) => file),
};
}
@@ -120,10 +138,14 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
const german = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!german) throw new Error('German locale chunk was not emitted');
let editorReplacements = 0;
let onboardingReplacements = 0;
let germanReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -137,10 +159,16 @@ export function editorRuntimeRetryUrlPlugin() {
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(DE_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), german.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
germanReplacements += chunk.code.split(DE_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(DE_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1) {
if (editorReplacements !== 1 || onboardingReplacements !== 1 || germanReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}, expected 1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${germanReplacements}, expected 1/1/1`);
}
},
};
+10
View File
@@ -27,6 +27,16 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'LanguageRuntime', 'LanguageRuntimeContract', 'languageRenderGate',
'dictionaryFor', 'ensureLanguage',
],
smokes: ['smoke_german_locale.mjs'],
because: 'the production-bundle scenario observes the lazy German chunk through network '
+ 'requests and four root render gates: neutral cold frame, atomic German commit, page-cache '
+ 'reuse and the bounded retry-to-English path cannot be proven from pure registry tests (#348)',
},
{
symbols: ['quantizeKeyCoord', 'INCIDENT_EPS', 'KEY_FACTOR'],
smokes: ['smoke_junction_limits.mjs'],
+8 -2
View File
@@ -1,7 +1,8 @@
/** Card configuration editor (Lovelace GUI). */
import { LitElement, html, nothing } from 'lit';
import { LitElement, html, nothing, noChange } from 'lit';
import { langOf, t, type Lang } from './i18n';
import { languageOptions } from './i18n/registry';
import { LANGUAGE_RUNTIME, languageOptions } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { invalidDefaultFloor } from './card-editor-validation';
class HouseplanCardEditor extends LitElement {
@@ -113,6 +114,11 @@ class HouseplanCardEditor extends LitElement {
protected render() {
if (!this.hass || !this._config) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
+10 -3
View File
@@ -6,7 +6,7 @@
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues } from 'lit';
import { guard } from 'lit/directives/guard.js';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
@@ -232,6 +232,8 @@ import {
} from './coordinate-canonicalization';
import { enqueueSerializedWrite } from './serialized-write-queue';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { LANGUAGE_RUNTIME } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
@@ -10700,7 +10702,13 @@ export class HouseplanCard extends LitElement {
}
}
protected render(): TemplateResult | typeof nothing {
protected render(): TemplateResult | typeof nothing | typeof noChange {
if (!this._config || !this.hass) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
const onboardingRuntimeRequested = !!this._importDialog
|| !!(this._spaceDialog && this._spaceDialogUsesOnboardingRuntime(this._spaceDialog.mode));
const editorRuntimeRequested = this._mode !== 'view'
@@ -10717,7 +10725,6 @@ export class HouseplanCard extends LitElement {
void this._ensureOnboardingRuntime();
}
if (editorRuntimeRequested && !this._editorRuntime) void this._ensureEditorRuntime();
if (!this._config || !this.hass) return nothing;
const model = this._model;
const diagnostics = this.houseplanDiagnostics();
const fixed = this._fixedFloorState(model);
+4 -4
View File
@@ -9,7 +9,7 @@ import {
FALLBACK_DICTIONARY,
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
languageEntry,
dictionaryFor,
resolveLanguageCode,
type Lang,
} from './i18n/registry';
@@ -30,13 +30,13 @@ export function langOf(hass: any, configLang?: string | null): Lang {
/** Translate a key with optional {placeholder} substitution. */
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
const dictionary = languageEntry(lang)?.dictionary;
return subst(dictionary?.[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
const dictionary = dictionaryFor(lang);
return subst(dictionary[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
}
/** Whether a localized value exists and contains useful text after fallback. */
export function hasTranslation(lang: Lang, key: string): boolean {
const value = languageEntry(lang)?.dictionary[key] ?? FALLBACK_DICTIONARY[key];
const value = dictionaryFor(lang)[key] ?? FALLBACK_DICTIONARY[key];
return typeof value === 'string' && value.trim().length > 0;
}
+1061
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
import dictionary from './de.json' with { type: 'json' };
/** Must match the entry bundle before a lazy locale can be committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
-20
View File
@@ -71,8 +71,6 @@
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"title.zoom_fit": "Fit all",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show hidden and disabled devices as service markers (this tab only)",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
@@ -127,7 +125,6 @@
"history.decor_transform": "Transform decor object",
"history.decor_delete": "Delete decor object",
"history.backdrop_transform": "Transform plan backdrop",
"title.markup_opening": "Openings: click a wall to place, click an opening to edit",
"opening.new": "New opening",
"opening.edit": "Opening",
"opening.door": "Door",
@@ -212,8 +209,6 @@
"marker.preview.multiple_sources": "{n} sources",
"marker.preview.more_sources": "+{n} more",
"marker.preview.scaled": "The preview is scaled to {n}% to fit. The saved size is unchanged.",
"marker.preview.demo_activity": "Show activity example",
"marker.preview.demo_notice": "Temporary activity example; the actual device state is unchanged.",
"marker.preview.demo_short": "Show short activity",
"marker.preview.demo_continuous": "Show continuous activity",
"marker.preview.stop_continuous": "Stop continuous activity",
@@ -294,7 +289,6 @@
"confirm.delete_room": "Delete room \"{name}\"?",
"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.merge_rooms": "Merge rooms \"{a}\" and \"{b}\"?",
"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.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?",
@@ -311,7 +305,6 @@
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.contour_min_edges": "Draw at least two edges before closing the outline",
"toast.contour_cannot_close": "The outline cannot close because it is degenerate or intersects itself",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”",
@@ -321,7 +314,6 @@
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
"merge.keep": "Keep",
"merge.no_area": "no area",
"room.split_header": "New room from the split",
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
@@ -361,8 +353,6 @@
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
"rules.title": "Icon rules",
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
@@ -407,13 +397,7 @@
"space.custom_fill": "Fill color",
"space.source_file": "I have a floor-plan image",
"space.source_draw": "No image — I'll outline rooms by hand",
"space.orientation": "Canvas",
"orient.landscape": "Landscape",
"orient.portrait": "Portrait",
"orient.square": "Square",
"fill.temp": "Temperature",
"space.temp_min": "Comfort from",
"space.temp_max": "to",
"tip.temp_avg": "average temperature:",
"tip.hum_avg": "average humidity:",
"space_card.button": "Open the space plan",
@@ -457,8 +441,6 @@
"opening.lock_action": "Lock",
"opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)",
"devbar.add": "Add",
"devbar.show_all": "Hidden and disabled",
"devbar.rules": "Icon rules",
"device_inbox.button": "Devices",
"device_inbox.title": "Devices on the plan",
@@ -734,7 +716,6 @@
"marker.ha_registry_limited": "The full Home Assistant registry is unavailable to this user. The unverified object cannot be shown or used for now.",
"marker.delete_tip": "Completely delete the device from the plan and every aggregate. You can add it again later.",
"tap.run": "Run automation/script/scene",
"tap.cover": "Open/close (curtains/blinds)",
"marker.run_target_label": "What to run",
"marker.run_search_ph": "Search: automation, script or scene…",
"marker.run_target_gone": "Target {id} not found — pick again",
@@ -772,7 +753,6 @@
"run.scene": "scene",
"confirm.tap_run": "Run \"{name}\"?",
"confirm.tap_toggle": "Toggle \"{name}\"?",
"confirm.tap_cover": "Open/close \"{name}\"?",
"confirm.current_state": "Current state: {state}",
"confirm.expected_state": "After switching: {state}",
"confirm.group_current": "on {on} of {total}",
+139
View File
@@ -0,0 +1,139 @@
import { html, type TemplateResult } from 'lit';
export type LocaleDictionary = Record<string, string>;
export interface LazyLanguageModule {
dictionary: LocaleDictionary;
fingerprint: string;
}
export interface RuntimeLanguageEntry {
code: string;
dictionary?: LocaleDictionary;
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
}
export type LanguageLoadState = 'ready' | 'pending' | 'fallback';
export interface LanguageRuntimeContract {
state(code: string): LanguageLoadState;
dictionary(code: string): LocaleDictionary | undefined;
ensure(code: string): Promise<void>;
}
/**
* Page-scoped dictionary cache shared by all House Plan cards and editors.
*
* `fallback` is a settled state: a failed optional locale must never leave a
* card behind an endless loading surface. The synchronous translation helper
* then uses English in the ordinary way.
*/
export class LanguageRuntime {
private readonly dictionaries = new Map<string, LocaleDictionary>();
private readonly pending = new Map<string, Promise<void>>();
private readonly failed = new Set<string>();
public constructor(
private readonly entries: readonly RuntimeLanguageEntry[],
private readonly expectedFingerprint: string,
private readonly warn: (message: string, error: unknown) => void = console.warn,
) {
for (const entry of entries) {
if (entry.dictionary) this.dictionaries.set(entry.code, entry.dictionary);
}
}
public state(code: string): LanguageLoadState {
if (this.dictionaries.has(code)) return 'ready';
if (this.failed.has(code)) return 'fallback';
return 'pending';
}
public dictionary(code: string): LocaleDictionary | undefined {
return this.dictionaries.get(code);
}
public ensure(code: string): Promise<void> {
if (this.state(code) !== 'pending') return Promise.resolve();
const existing = this.pending.get(code);
if (existing) return existing;
const entry = this.entries.find((candidate) => candidate.code === code);
const task = this.load(entry).finally(() => this.pending.delete(code));
this.pending.set(code, task);
return task;
}
private async load(entry: RuntimeLanguageEntry | undefined): Promise<void> {
if (!entry?.loadDictionary) {
if (entry) this.failed.add(entry.code);
return;
}
let lastError: unknown;
for (const attempt of [0, 1] as const) {
try {
const loaded = await entry.loadDictionary(attempt);
if (loaded.fingerprint !== this.expectedFingerprint) {
throw new Error(`locale fingerprint mismatch for ${entry.code}`);
}
this.dictionaries.set(entry.code, loaded.dictionary);
this.failed.delete(entry.code);
return;
} catch (error) {
lastError = error;
}
}
this.failed.add(entry.code);
this.warn(`[houseplan] unable to load ${entry.code} locale; using English`, lastError);
}
}
interface LanguageHostElement {
inert: boolean;
isConnected: boolean;
requestUpdate(): void;
hasAttribute(name: string): boolean;
setAttribute(name: string, value: string): void;
removeAttribute(name: string): void;
}
const committedHosts = new WeakSet<LanguageHostElement>();
const localeInertHosts = new WeakSet<LanguageHostElement>();
const localeBusyHosts = new WeakSet<LanguageHostElement>();
/** Lightweight production render gate; generic controller below remains testable in isolation. */
export function languageRenderGate(
host: LanguageHostElement,
runtime: LanguageRuntimeContract,
code: string | null,
): LanguageRenderGate {
const state = code ? runtime.state(code) : 'ready';
if (!code || state !== 'pending') {
if (localeInertHosts.delete(host)) host.inert = false;
if (localeBusyHosts.delete(host)) host.removeAttribute('aria-busy');
if (code) {
host.setAttribute('lang', state === 'fallback' ? 'en' : code);
committedHosts.add(host);
}
return 'ready';
}
if (!host.inert) {
host.inert = true;
localeInertHosts.add(host);
}
if (!host.hasAttribute('aria-busy')) {
host.setAttribute('aria-busy', 'true');
localeBusyHosts.add(host);
}
void runtime.ensure(code).then(() => {
if (host.isConnected) host.requestUpdate();
});
return committedHosts.has(host) ? 'warm' : 'cold';
}
export type LanguageRenderGate = 'ready' | 'cold' | 'warm';
/** Keep a root Lit surface inert and visually stable while its lazy locale loads. */
/** A language-neutral first frame: no fallback-language copy is allowed to flash. */
export function languageLoadingTemplate(): TemplateResult {
return html`<ha-circular-progress active role="status" aria-busy="true"></ha-circular-progress>`;
}
+62 -1
View File
@@ -1,10 +1,26 @@
import en from './en.json' with { type: 'json' };
import ru from './ru.json' with { type: 'json' };
import type {
LanguageRuntimeContract,
LazyLanguageModule,
LocaleDictionary,
} from './language-runtime';
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const GERMAN_RETRY_ASSET = '__HOUSEPLAN_DE_RETRY_ASSET__';
export interface LanguageEntry {
code: string;
nativeLabel: string;
dictionary: Record<string, string>;
dictionary?: LocaleDictionary;
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
}
async function loadGerman(attempt: 0 | 1): Promise<LazyLanguageModule> {
const module = attempt === 0
? await import('./de')
: await import(/* @vite-ignore */ new URL(`${GERMAN_RETRY_ASSET}?retry`, import.meta.url).href);
return { dictionary: module.dictionary, fingerprint: module.fingerprint };
}
/**
@@ -16,6 +32,7 @@ export interface LanguageEntry {
export const LANGUAGE_REGISTRY = [
{ code: 'en', nativeLabel: 'English', dictionary: en },
{ code: 'ru', nativeLabel: 'Русский', dictionary: ru },
{ code: 'de', nativeLabel: 'Deutsch', loadDictionary: loadGerman },
] as const satisfies readonly LanguageEntry[];
export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
@@ -23,6 +40,50 @@ export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
export const FALLBACK_LANGUAGE_CODE: Lang = 'en';
export const FALLBACK_DICTIONARY = en;
/** One page-scoped cache: multiple cards share the same locale request. */
let germanDictionary: LocaleDictionary | undefined;
let germanPending: Promise<void> | undefined;
let germanFailed = false;
async function settleGerman(): Promise<void> {
let lastError: unknown;
for (const attempt of [0, 1] as const) {
try {
const loaded = await loadGerman(attempt);
if (loaded.fingerprint !== BUILD_FINGERPRINT) throw new Error('locale fingerprint mismatch');
germanDictionary = loaded.dictionary;
return;
} catch (error) {
lastError = error;
}
}
germanFailed = true;
console.warn('[houseplan] unable to load de locale; using English', lastError);
}
export const LANGUAGE_RUNTIME: LanguageRuntimeContract = {
state: (code) => code !== 'de' || germanDictionary
? 'ready' : germanFailed ? 'fallback' : 'pending',
dictionary: (code) => code === 'de'
? germanDictionary : code === 'ru' ? ru : code === 'en' ? en : undefined,
ensure: (code) => {
if (code !== 'de' || germanDictionary || germanFailed) return Promise.resolve();
return germanPending ??= settleGerman().finally(() => { germanPending = undefined; });
},
};
/** Return a loaded dictionary, falling back synchronously to English. */
export function dictionaryFor(value: unknown): LocaleDictionary {
const code = languageEntry(value)?.code ?? FALLBACK_LANGUAGE_CODE;
return LANGUAGE_RUNTIME.dictionary(code) ?? FALLBACK_DICTIONARY;
}
/** Ensure a lazy locale has settled (success or bounded English fallback). */
export function ensureLanguage(value: unknown): Promise<void> {
const code = languageEntry(value)?.code ?? FALLBACK_LANGUAGE_CODE;
return LANGUAGE_RUNTIME.ensure(code);
}
/** Normalize HA locale tags for registry lookup. */
export function normalizeLanguageTag(value: unknown): string {
return typeof value === 'string'
-20
View File
@@ -71,8 +71,6 @@
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"title.zoom_fit": "Вписать всё",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показать скрытые и деактивированные устройства служебными маркерами (только в этой вкладке)",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
@@ -127,7 +125,6 @@
"history.decor_transform": "Трансформация объекта декора",
"history.decor_delete": "Удаление объекта декора",
"history.backdrop_transform": "Трансформация подложки плана",
"title.markup_opening": "Проёмы: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
"opening.edit": "Проём",
"opening.door": "Дверь",
@@ -212,8 +209,6 @@
"marker.preview.multiple_sources": "Источников: {n}",
"marker.preview.more_sources": "ещё {n}",
"marker.preview.scaled": "Предпросмотр уменьшен до {n}%, чтобы поместиться. Сохранённый размер не изменится.",
"marker.preview.demo_activity": "Показать пример активности",
"marker.preview.demo_notice": "Временный пример активности; фактическое состояние устройства не меняется.",
"marker.preview.demo_short": "Показать краткую активность",
"marker.preview.demo_continuous": "Показать постоянную активность",
"marker.preview.stop_continuous": "Остановить постоянную активность",
@@ -294,7 +289,6 @@
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.delete_room_title": "Удалить комнату «{name}»?",
"confirm.delete_room_body": "Выберите, нужно ли оставить физические стены, принадлежащие только этой комнате. Общие с другой комнатой стены сохраняются всегда.",
"confirm.merge_rooms": "Слить комнаты «{a}» и «{b}»?",
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
@@ -311,7 +305,6 @@
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.contour_min_edges": "Чтобы замкнуть контур, сначала нарисуйте минимум две грани",
"toast.contour_cannot_close": "Контур нельзя замкнуть: он вырожден или пересекает сам себя",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
@@ -321,7 +314,6 @@
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить",
"merge.no_area": "без зоны",
"room.split_header": "Новая комната после разделения",
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
@@ -361,8 +353,6 @@
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
"rules.title": "Правила иконок",
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
@@ -407,13 +397,7 @@
"space.custom_fill": "Цвет заливки",
"space.source_file": "У меня есть картинка плана",
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
"space.orientation": "Холст",
"orient.landscape": "Альбомный",
"orient.portrait": "Портретный",
"orient.square": "Квадрат",
"fill.temp": "По температуре",
"space.temp_min": "Комфорт от",
"space.temp_max": "до",
"tip.temp_avg": "средняя температура:",
"tip.hum_avg": "средняя влажность:",
"space_card.button": "Перейти к пространству",
@@ -457,8 +441,6 @@
"opening.lock_action": "Закрыть замок",
"opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить",
"devbar.show_all": "Скрытые и деактивированные",
"devbar.rules": "Правила иконок",
"device_inbox.button": "Устройства",
"device_inbox.title": "Устройства на плане",
@@ -734,7 +716,6 @@
"marker.ha_registry_limited": "Полный реестр Home Assistant недоступен этому пользователю. Неподтверждённый объект временно нельзя показывать или использовать.",
"marker.delete_tip": "Полностью удалить устройство с плана и из всех расчётов. Позже его можно добавить заново.",
"tap.run": "Запустить автоматизацию/скрипт/сцену",
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
"marker.run_target_label": "Что запускать",
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
@@ -772,7 +753,6 @@
"run.scene": "сцена",
"confirm.tap_run": "Запустить «{name}»?",
"confirm.tap_toggle": "Переключить «{name}»?",
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
"confirm.current_state": "Текущее состояние: {state}",
"confirm.expected_state": "После переключения: {state}",
"confirm.group_current": "включено {on} из {total}",
+10 -3
View File
@@ -5,11 +5,13 @@
* (no clicks/hover/tooltips/drag/more-info). A footer button opens
* the space in the full component via a deep-link (`#space=<id>`).
*/
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
import { LitElement, html, nothing, noChange, css, type TemplateResult, type PropertyValues } from 'lit';
import { cardStyles } from './styles';
import { buildSpaceDevices, renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import { LANGUAGE_RUNTIME } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { ContentSigner } from './signing';
import { normalizeDeviceDisplay, openingEntityReferences, referencedContentUrls } from './logic';
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
@@ -758,8 +760,13 @@ class HouseplanSpaceCard extends LitElement {
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
protected render(): TemplateResult | typeof nothing | typeof noChange {
if (!this._config || !this.hass) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
const cfg = this._snap?.config;
if (!cfg) {
// still loading and no snapshot yet
+8 -1
View File
@@ -1,6 +1,8 @@
/** Config editor (Lovelace GUI) for houseplan-space-card. */
import { LitElement, html, nothing } from 'lit';
import { LitElement, html, nothing, noChange } from 'lit';
import { langOf, t, type Lang } from './i18n';
import { LANGUAGE_RUNTIME } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
class HouseplanSpaceCardEditor extends LitElement {
public hass?: any;
@@ -54,6 +56,11 @@ class HouseplanSpaceCardEditor extends LitElement {
protected render() {
if (!this.hass || !this._config) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
+17 -3
View File
@@ -45,6 +45,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
type: 'chunk', fileName: 'houseplan-card.js', code: 'entry', isEntry: true,
imports: ['shared.js'], dynamicImports: [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js',
],
},
'shared.js': {
@@ -60,14 +61,21 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
code: 'onboarding', isEntry: false,
imports: ['shared.js'], dynamicImports: [],
},
'houseplan-assets/de-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js',
code: 'German locale', isEntry: false, imports: [], dynamicImports: [],
modules: { '/repo/src/i18n/de.ts': {} },
},
}, 'fingerprint');
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js']);
assert.deepEqual(manifest.lazyOnboardingFiles, [
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
assert.deepEqual(manifest.lazyLocaleFiles, ['houseplan-assets/de-HASH.js']);
assert.deepEqual(manifest.lazyFiles, [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js', 'houseplan-assets/editor.js',
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
@@ -89,7 +97,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js',
code: 'new URL("__HOUSEPLAN_EDITOR_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url)', modules: {},
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url)', modules: {},
},
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
@@ -99,12 +108,17 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', code: '',
modules: { '/repo/src/houseplan-onboarding-runtime.ts': {} },
},
'houseplan-assets/de-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/de-HASH.js', code: '',
modules: { '/repo/src/i18n/de.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
bundle['houseplan-assets/houseplan-card.js'].code,
'new URL("./houseplan-editor-runtime-HASH.js", import.meta.url);'
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url)',
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url);'
+ 'new URL("./de-HASH.js", import.meta.url)',
);
});
+3 -3
View File
@@ -368,7 +368,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 48);
assert.equal(GOLDEN_MATRIX_VERSION, 49);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -807,8 +807,8 @@ test('editor tray golden contract covers every adaptive width in English and Rus
test('device dialog goldens expose the complete light-source controls at desktop and mobile widths', () => {
const dialogs = GOLDEN_SCENARIOS.filter((scenario) => scenario.id.startsWith('device-dialog-'));
assert.equal(dialogs.length, 2);
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru']));
assert.equal(dialogs.length, 3);
assert.deepEqual(new Set(dialogs.map((scenario) => scenario.language)), new Set(['en', 'ru', 'de']));
for (const scenario of dialogs) {
assert.equal(scenario.deviceLightControls, true, scenario.id);
assert.equal(scenario.deviceId, 'golden-light-two', scenario.id);
+131
View File
@@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
LanguageRuntime, languageRenderGate,
} from '../test-build/i18n/language-runtime.js';
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
};
test('lazy locale runtime deduplicates hosts and accepts the matching build', async () => {
const load = deferred();
let calls = 0;
const runtime = new LanguageRuntime([
{ code: 'en', dictionary: { save: 'Save' } },
{ code: 'de', loadDictionary: async () => { calls++; return load.promise; } },
], 'same-build');
const first = runtime.ensure('de');
const second = runtime.ensure('de');
assert.equal(first, second);
assert.equal(calls, 1);
assert.equal(runtime.state('de'), 'pending');
load.resolve({ dictionary: { save: 'Speichern' }, fingerprint: 'same-build' });
await first;
assert.equal(runtime.state('de'), 'ready');
assert.equal(runtime.dictionary('de').save, 'Speichern');
await runtime.ensure('de');
assert.equal(calls, 1);
});
test('lazy locale runtime retries once, warns once and settles on English fallback', async () => {
const attempts = [];
const warnings = [];
const runtime = new LanguageRuntime([
{
code: 'de',
loadDictionary: async (attempt) => {
attempts.push(attempt);
throw new Error(`failure ${attempt}`);
},
},
], 'build', (message, error) => warnings.push([message, error]));
await runtime.ensure('de');
assert.deepEqual(attempts, [0, 1]);
assert.equal(runtime.state('de'), 'fallback');
assert.equal(runtime.dictionary('de'), undefined);
assert.equal(warnings.length, 1);
await runtime.ensure('de');
assert.equal(warnings.length, 1);
assert.deepEqual(attempts, [0, 1]);
});
test('lazy locale runtime rejects a stale fingerprint before accepting retry', async () => {
const runtime = new LanguageRuntime([{
code: 'de',
loadDictionary: async (attempt) => ({
dictionary: { save: attempt ? 'Speichern' : 'Stale' },
fingerprint: attempt ? 'current' : 'old',
}),
}], 'current');
await runtime.ensure('de');
assert.equal(runtime.dictionary('de').save, 'Speichern');
});
class FakeHost {
controllers = [];
inert = false;
isConnected = true;
attrs = new Map();
updates = 0;
addController(controller) { this.controllers.push(controller); }
requestUpdate() { this.updates++; }
hasAttribute(name) { return this.attrs.has(name); }
setAttribute(name, value) { this.attrs.set(name, value); }
removeAttribute(name) { this.attrs.delete(name); }
}
test('locale render gate exposes cold/warm states, inertness and ignores disconnected hosts', async () => {
const load = deferred();
const runtime = new LanguageRuntime([
{ code: 'en', dictionary: {} },
{ code: 'de', loadDictionary: async () => load.promise },
], 'build');
const host = new FakeHost();
let language = 'en';
assert.equal(languageRenderGate(host, runtime, language), 'ready');
assert.equal(host.attrs.get('lang'), 'en');
language = 'de';
assert.equal(languageRenderGate(host, runtime, language), 'warm');
assert.equal(host.inert, true);
assert.equal(host.attrs.get('aria-busy'), 'true');
host.isConnected = false;
load.resolve({ dictionary: {}, fingerprint: 'build' });
await runtime.ensure('de');
await Promise.resolve();
assert.equal(host.updates, 0);
assert.equal(languageRenderGate(host, runtime, language), 'ready');
assert.equal(host.inert, false);
assert.equal(host.hasAttribute('aria-busy'), false);
assert.equal(host.attrs.get('lang'), 'de');
const coldLoad = deferred();
const coldRuntime = new LanguageRuntime([{
code: 'de', loadDictionary: async () => coldLoad.promise,
}], 'build');
const coldHost = new FakeHost();
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'cold');
coldLoad.resolve({ dictionary: {}, fingerprint: 'build' });
await coldRuntime.ensure('de');
await Promise.resolve();
assert.equal(coldHost.updates, 1);
assert.equal(languageRenderGate(coldHost, coldRuntime, 'de'), 'ready');
assert.equal(coldHost.attrs.get('lang'), 'de');
});
test('locale render gate exposes the fallback language to assistive technology', async () => {
const runtime = new LanguageRuntime([{
code: 'de', loadDictionary: async () => { throw new Error('offline'); },
}], 'build', () => {});
const host = new FakeHost();
assert.equal(languageRenderGate(host, runtime, 'de'), 'cold');
await runtime.ensure('de');
assert.equal(languageRenderGate(host, runtime, 'de'), 'ready');
assert.equal(host.attrs.get('lang'), 'en');
});
+102 -14
View File
@@ -6,6 +6,8 @@ import {
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
buildLanguageLookup,
dictionaryFor,
ensureLanguage,
languageEntry,
languageOptions,
normalizeLanguageTag,
@@ -14,11 +16,11 @@ import {
import { langOf } from '../test-build/i18n.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const dictionaries = new Map(
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
);
await Promise.all(LANGUAGE_REGISTRY.map(({ code }) => ensureLanguage(code)));
const dictionaries = new Map(LANGUAGE_REGISTRY.map(({ code }) => [code, dictionaryFor(code)]));
const en = dictionaries.get('en');
const ru = dictionaries.get('ru');
const de = dictionaries.get('de');
const cardSource = readHouseplanProductionSource();
test('i18n: registry codes and English fallback are valid', () => {
@@ -32,12 +34,15 @@ test('i18n: registry codes and English fallback are valid', () => {
normalizedCodes.length,
'registry codes must be unique after locale normalization',
);
for (const { code, nativeLabel, dictionary } of LANGUAGE_REGISTRY) {
for (const { code, nativeLabel, dictionary, loadDictionary } of LANGUAGE_REGISTRY) {
assert.equal(code, code.trim(), `${code} has surrounding whitespace`);
assert.doesNotMatch(code, /_/u, `${code} must use BCP 47 hyphens`);
assert.match(code, /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u);
assert.ok(nativeLabel.trim(), `${code} has no native label`);
assert.ok(dictionary && typeof dictionary === 'object', `${code} has no dictionary`);
assert.ok(
(dictionary && typeof dictionary === 'object') || typeof loadDictionary === 'function',
`${code} has neither an eager dictionary nor a lazy loader`,
);
}
});
@@ -69,15 +74,48 @@ test('i18n: registry matches frontend and backend locale files', () => {
);
});
test('i18n: backend dictionaries preserve the English structure and placeholders', () => {
const flatten = (value, prefix = '', result = new Map()) => {
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, path, result);
else result.set(path, child);
}
return result;
};
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
const backend = new Map(LANGUAGE_REGISTRY.map(({ code }) => [
code,
flatten(JSON.parse(readFileSync(
new URL(`../custom_components/houseplan/translations/${code}.json`, import.meta.url),
'utf8',
))),
]));
const english = backend.get('en');
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = backend.get(code);
assert.deepEqual([...dictionary.keys()].sort(), [...english.keys()].sort(), `${code} backend key set differs`);
for (const [key, value] of english) {
assert.deepEqual(
placeholders(dictionary.get(key)),
placeholders(value),
`${code} backend placeholder mismatch at ${key}`,
);
}
}
});
test('i18n: every registered dictionary carries the English key set', () => {
const enKeys = Object.keys(en).sort();
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.deepEqual(Object.keys(dictionary).sort(), enKeys, `${code} key set differs`);
}
});
test('i18n: no empty values', () => {
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
for (const [k, v] of Object.entries(dictionary)) {
assert.ok(typeof v === 'string' && v.length > 0, `${code}:${k} is empty`);
}
@@ -87,14 +125,15 @@ test('i18n: no empty values', () => {
test('i18n: placeholders match between languages', () => {
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
for (const k of Object.keys(en)) {
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.equal(ph(en[k]), ph(dictionary[k]), `placeholder mismatch in ${code}:${k}`);
}
}
});
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
const supported = ['en', 'ru', 'pt-BR'];
const supported = ['en', 'ru', 'de', 'pt-BR'];
const cases = [
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
@@ -103,7 +142,9 @@ test('i18n: resolver supports exact, primary, explicit and fallback paths', () =
{ explicit: null, ha: 'ru-RU', expected: 'ru' },
{ explicit: 'ru-RU', ha: 'en-GB', expected: 'en' },
{ explicit: undefined, ha: 'pt-PT', expected: 'en' },
{ explicit: undefined, ha: 'de-DE', expected: 'en' },
{ explicit: undefined, ha: 'de-DE', expected: 'de' },
{ explicit: undefined, ha: 'de-AT', expected: 'de' },
{ explicit: undefined, ha: 'de-CH', expected: 'de' },
];
for (const { explicit, ha, expected } of cases) {
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
@@ -114,7 +155,7 @@ test('i18n: langOf wires card config and both HA locale shapes to the registry',
assert.equal(langOf({ locale: { language: 'ru_RU' }, language: 'en' }), 'ru');
assert.equal(langOf({ locale: { language: 'en-GB' }, language: 'ru' }, 'RU'), 'ru');
assert.equal(langOf({ language: 'ru-RU' }, 'unknown'), 'ru');
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'en');
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'de');
});
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
@@ -123,7 +164,7 @@ test('i18n: editor options follow registry order and preserve unknown raw values
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({ value: code, label: nativeLabel })),
]);
assert.deepEqual(languageOptions('Auto', 'ru'), languageOptions('Auto'));
assert.deepEqual(languageOptions('Auto', 'de').at(-1), { value: 'de', label: 'de' });
assert.deepEqual(languageOptions('Auto', 'de'), languageOptions('Auto'));
assert.deepEqual(
languageOptions('Auto', ' RU ').at(-1),
{ value: ' RU ', label: 'Русский' },
@@ -242,7 +283,8 @@ test('i18n: every literal help call has body and full aria keys in both language
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
for (const key of helpKeys) {
for (const { code, dictionary } of LANGUAGE_REGISTRY) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.ok(dictionary[key]?.trim(), `${code}:${key} is missing or empty`);
assert.ok(dictionary[`${key}.aria`]?.trim(), `${code}:${key}.aria is missing or empty`);
}
@@ -274,9 +316,55 @@ test('help hosts do not duplicate their explanation in a title attribute', () =>
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const { code: lang, dictionary: d } of LANGUAGE_REGISTRY) {
for (const { code: lang } of LANGUAGE_REGISTRY) {
const d = dictionaries.get(lang);
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
}
}
});
test('i18n: German catalog keeps the product glossary and has no translation sentinels', () => {
assert.equal(de['btn.save'], 'Speichern');
assert.equal(de['btn.cancel'], 'Abbrechen');
assert.equal(de['btn.delete'], 'Löschen');
assert.equal(de['space.header'], 'Bereich');
assert.equal(de['physical.partition_properties'], 'Eigenschaften der Trennwand');
assert.equal(de['opening.passage'], 'Offener Durchgang');
for (const [key, value] of Object.entries(de)) {
assert.doesNotMatch(value, /ZXQPH|QXZ|⟦HP/u, `de:${key} contains a translator sentinel`);
assert.doesNotMatch(value, /[А-Яа-яЁё]/u, `de:${key} contains Cyrillic text`);
}
});
test('i18n: German values equal to English are explicitly reviewed', () => {
const allowed = new Set([
'editor.palette',
'confirm.delete_partition_openings_item',
'marker.name_ph',
'marker.pulse_a11y_alarm',
'marker.state_a11y_alarm',
'marker.link_label',
'rules.icon_ph',
'decor.ellipse',
'decor.text',
'decor.text_label',
'gs.unit_m',
'gs.unit_ft',
'wallthick.unit_cm',
'wallthick.unit_in',
'marker.value_badge_position',
'marker.value_badge_attr_current_position',
'gs.north_letter',
'gs.optimize_live_names',
'gs.optimize_details',
'furn.symbol',
'furn.sym_sofa',
'furn.sym_bidet',
'vac.diag_platform',
'vac.diag_status',
'vac.diag_position',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
assert.deepEqual(new Set(equalKeys), allowed);
});
+2 -1
View File
@@ -5,6 +5,7 @@ import test from 'node:test';
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url), 'utf8'));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url), 'utf8'));
const de = JSON.parse(readFileSync(new URL('../src/i18n/de.json', import.meta.url), 'utf8'));
test('runtime exposes only the unified Walls tool state', () => {
const toolType = cardSource.match(/type MarkupTool = ([^;]+);/)?.[1] || '';
@@ -18,7 +19,7 @@ test('runtime exposes only the unified Walls tool state', () => {
});
test('only old tool copy is removed while persisted partition copy remains', () => {
for (const locale of [en, ru]) {
for (const locale of [en, ru, de]) {
for (const key of [
'title.markup_partition',
'markup.hint_partition',
+1 -1
View File
@@ -10,7 +10,7 @@
"tsBuildInfoFile": "test-build/.tsbuildinfo"
},
"include": [
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts",