`),C=new Map;for(const t of e.rooms)if(t.id){const e=b.get(t)||null;C.set(t.id,Fs(i.glow,t)&&(!e||e.opacity<=0)?{color:s.glow_base.c,opacity:s.glow_base.a,mode:"glow"}:null)}const D=lo(a,c,null,t.virtualLights),T=c.map(s=>{const n=function(t,e,i,s,n){const o=e[t.id];return o&&o.s===t.space?{x:o.x*Bl,y:o.y*Bl}:s[t.id]?s[t.id]:tc(pc(n))}(s,t.layout,t.cfg,h,e),o=(n.x-y[0])/y[2]*100,l=(n.y-y[1])/y[3]*100,d=i.showLqi??!1!==t.showSignal,u=t.presentations?.get(hu(s.id,d))||au(a,s,{liveStates:!1!==t.liveStates,showTemperature:!1!==t.showTemperature,showSignal:d,activityRuntime:t.activityRuntime?.get(s.id),sourceDetails:!1,lightDevices:c,lightSources:D,registryHass:r,reducedMotion:t.reducedMotion}),p=[`left:${o}%`,`top:${l}%`,...jo(u)],_=[s.name,"none"!==u.pulse.kind?na(t.lang,`marker.pulse_a11y_${u.pulse.reason}`):"",To(u.valueBadge)].filter(Boolean).join(", ");return j`
${Wo(u,{})}
-
`}),R=i.showNames?e.rooms.filter(t=>t.name).map(s=>{const n=function(t,e,i){const s=i["rl_"+(t.id||"")];if(s&&s.s===e)return{x:s.x*Bl,y:s.y*Bl};const n=vc(t);return tc({x:n[0],y:n[1]})}(s,e.id,t.layout,t.cfg),o=(n.x-y[0])/y[2]*100,a=(n.y-y[1])/y[3]*100,r=Math.min(1,i.opacity+.25);return j`
`}),P=i.showNames?e.rooms.filter(t=>t.name).map(s=>{const n=vc(s,e.id,t.layout,t.cfg),o=(n.x-y[0])/y[2]*100,a=(n.y-y[1])/y[3]*100,r=Math.min(1,i.opacity+.25);return j`
${s.name}
`}):[],A=e.bg?t.displayUrl?t.displayUrl(e.bg.href):e.bg.href:"",I=t.cfg.spaces.find(e=>e.id===t.spaceId)?.settings||{},E="daynight"===Ya(t.cfg?.settings,I)?Ha(a,t.dayCycleNow??new Date):null,F=hs(t.cfg?.settings,i),z=!!(p.length||f.length&&i.showBorders),O=z?ih(k.length?{rooms:e.rooms,walls:p,extras:f,cellCm:_,passages:k.map(t=>({x:t.rx,y:t.ry,angle:t.angle,length:t.rlen})),hostedCompositeOpenings:$}:{rooms:e.rooms,walls:p,extras:f,cellCm:_}):"",H=z?function(t,e,i,s){let n=uu.get(t);n||(n=new Map,uu.set(t,n));const o=n.get(e);if(o?.fingerprint===i)return o.value;const a=s();return n.set(e,{fingerprint:i,value:a}),a}(t.cfg,e.id,O,()=>ul(e.rooms,p,[],[...k.filter(t=>!t.host).map(t=>({x:t.rx,y:t.ry,angle:t.angle,length:t.rlen})),...$],Zl,_,Jl,Bl,f)):null,N=k.length&&p.length?function(t,e,i,s,n,o,a,r=1){return[n,o,a,r].every(Number.isFinite)&&o>0&&r>0&&i?.length?kl(gl(t,i,s,n,o,a,r),e):e.map(()=>null)}(e.rooms,k.map(t=>({x:t.rx,y:t.ry,angle:t.angle,length:t.rlen})),p,[],Zl,_,Jl,Bl):k.map(()=>null),L=k.length?du({openings:k,geometries:N,fillsByRoomId:w,idPrefix:`${e.id}-static-data`,groupClass:"opening-tunnels static-opening-tunnels",dataLayer:"data"}):G,B=k.length?du({openings:k,geometries:N,fillsByRoomId:C,idPrefix:`${e.id}-static-glow`,groupClass:"opening-tunnels glow-base-tunnels static-opening-tunnels",dataLayer:"glow-base"}):G,q=p.length&&H?.paperD?[{path:H.paperD}]:di(e.rooms),U=i.showBorders?H:null,V=t.stageWidth&&y[2]?t.stageWidth/y[2]:1,K=!!U&&_r(U.depthUnits,V),Y=i.color||"#607d8b",X=i.hideOpenings?[]:g.map(t=>{const e=t.opening,i="passage"!==e.type&&e.contact?a.states?.[e.contact]?.state:null,s=gi(e.type,i,!!e.invert),n=s>0&&!!e.contact,o="gate"===e.type?!e.flip_v:!!e.flip_v,r={type:e.type,length:t.length,angle:t.angle,amount:s,flipH:!!e.flip_h,flipV:!!e.flip_v,base:Y,tone:n?"var(--hp-open)":Y,cellCm:_,gridPitch:Jl,face:od(t,o)};return W`
${s.name}`}):[],R=e.bg?t.displayUrl?t.displayUrl(e.bg.href):e.bg.href:"",A=t.cfg.spaces.find(e=>e.id===t.spaceId)?.settings||{},I="daynight"===Ya(t.cfg?.settings,A)?Ha(a,t.dayCycleNow??new Date):null,E=hs(t.cfg?.settings,i),F=!!(p.length||f.length&&i.showBorders),z=F?ih(k.length?{rooms:e.rooms,walls:p,extras:f,cellCm:_,passages:k.map(t=>({x:t.rx,y:t.ry,angle:t.angle,length:t.rlen})),hostedCompositeOpenings:$}:{rooms:e.rooms,walls:p,extras:f,cellCm:_}):"",O=F?function(t,e,i,s){let n=uu.get(t);n||(n=new Map,uu.set(t,n));const o=n.get(e);if(o?.fingerprint===i)return o.value;const a=s();return n.set(e,{fingerprint:i,value:a}),a}(t.cfg,e.id,z,()=>ul(e.rooms,p,[],[...k.filter(t=>!t.host).map(t=>({x:t.rx,y:t.ry,angle:t.angle,length:t.rlen})),...$],Zl,_,Jl,Bl,f)):null,H=k.length&&p.length?function(t,e,i,s,n,o,a,r=1){return[n,o,a,r].every(Number.isFinite)&&o>0&&r>0&&i?.length?kl(gl(t,i,s,n,o,a,r),e):e.map(()=>null)}(e.rooms,k.map(t=>({x:t.rx,y:t.ry,angle:t.angle,length:t.rlen})),p,[],Zl,_,Jl,Bl):k.map(()=>null),N=k.length?du({openings:k,geometries:H,fillsByRoomId:w,idPrefix:`${e.id}-static-data`,groupClass:"opening-tunnels static-opening-tunnels",dataLayer:"data"}):G,L=k.length?du({openings:k,geometries:H,fillsByRoomId:C,idPrefix:`${e.id}-static-glow`,groupClass:"opening-tunnels glow-base-tunnels static-opening-tunnels",dataLayer:"glow-base"}):G,B=p.length&&O?.paperD?[{path:O.paperD}]:di(e.rooms),q=i.showBorders?O:null,U=t.stageWidth&&y[2]?t.stageWidth/y[2]:1,V=!!q&&_r(q.depthUnits,U),K=i.color||"#607d8b",Y=i.hideOpenings?[]:g.map(t=>{const e=t.opening,i="passage"!==e.type&&e.contact?a.states?.[e.contact]?.state:null,s=gi(e.type,i,!!e.invert),n=s>0&&!!e.contact,o="gate"===e.type?!e.flip_v:!!e.flip_v,r={type:e.type,length:t.length,angle:t.angle,amount:s,flipH:!!e.flip_h,flipV:!!e.flip_v,base:K,tone:n?"var(--hp-open)":K,cellCm:_,gridPitch:Jl,face:od(t,o)};return W`
${Xd(r)}
`});return j`
-
- ${tr(E)}
+ style="aspect-ratio:${y[2]}/${y[3]}${E?";background:"+E:""};--wall-fill:${s.wall_fill.c};--wall-fill-op:${s.wall_fill.a}${I?`;${Qa(I)}`:""}">
+ ${tr(I)}
${""}
-
${P}${R}
+
${T}${P}
`}let mu=null,fu=null,vu=0,yu=-1,bu=null,wu=[];const ku=new Set,xu=(t,e)=>{"function"==typeof t&&e.push(t)};function $u(){if(mu)return mu;try{const t=JSON.parse(localStorage.getItem("houseplan_card_cfg_v1")||"null");if(t&&t.config&&Array.isArray(t.config.spaces)){const e=t.layout||{};return{config:t.config,rev:t.rev||0,configFingerprint:t.config_fingerprint||ih(t.config),layout:e,layoutRev:t.layout_rev||0,layoutFingerprint:t.layout_fingerprint||ih(e),virtualLights:gn(t.virtual_lights,t.rev||0)}}}catch{}return null}function Su(t,e=!1){if(e&&(mu=null,vu++,fu))return fu.catch(()=>null).then(()=>Su(t,!1));if(mu)return Promise.resolve(mu);if(fu)return yu!==vu?fu.catch(()=>null).then(()=>Su(t,!1)):fu;return yu=vu,fu=async function(t,e){const[i,s]=await Promise.all([t.callWS({type:"houseplan/config/get"}),t.callWS({type:"houseplan/layout/get"})]),n=i?.rev??0,o=mu?mn(mu.virtualLights,i?.virtual_lights,n,!!i&&"virtual_lights"in i):gn(i?.virtual_lights,n),a={config:i?.config??null,rev:n,configFingerprint:ih(i?.config??null),layout:s?.layout??{},layoutRev:s?.rev??0,layoutFingerprint:ih(s?.layout??{}),virtualLights:o};e===vu&&(mu=a);const r=t.connection;if(r?.subscribeEvents&&bu!==r){for(const t of wu)t();wu=[],bu=r;const t=()=>{mu=null,vu++,ku.forEach(t=>t())},e=[];try{if(xu(await r.subscribeEvents(t,"houseplan_config_updated"),e),xu(await r.subscribeEvents(t,"houseplan_layout_updated"),e),xu(await r.subscribeEvents(t=>{if(mu){const e=wn(mu.virtualLights,t?.data);if(e===mu.virtualLights)return;mu={...mu,virtualLights:e}}else vu++;ku.forEach(t=>t())},"houseplan_virtual_light_updated"),e),bu===r)wu=e;else for(const t of e)t()}catch{for(const t of e)t();bu===r&&(bu=null)}}return a}(t,vu).finally(()=>{fu=null,yu=-1}),fu}const Mu=3300;function Cu(t,e){return{sources:t,last:Object.fromEntries(e.map(t=>[t.eid,t.state])),flashTs:0,flashKind:null,timer:0,gen:0,expiresAt:0,alarmActive:"alarm"===dn(e).status}}function Du(t,e,i,s){s(t.timer);const n=Cu(e,i);Object.assign(t,n)}function Tu(t,e,i,s,n){t.flashTs&&i-t.flashTs
"transition"===t.activity)&&(i(t.timer),t.flashTs=0,t.flashKind=null,t.expiresAt=0);if("alarm"===dn(e).status){t.alarmActive||(i(t.timer),t.flashTs=0,t.flashKind=null,t.expiresAt=0);for(const i of e)t.last[i.eid]=i.state;return t.alarmActive=!0,null}if(t.alarmActive){for(const i of e)t.last[i.eid]=i.state;return t.alarmActive=!1,null}let s=null;for(const i of e){const e=un(t.last[i.eid],i);("event"===e||!s&&e)&&(s=e),t.last[i.eid]=i.state}return s}class Ru extends ht{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(t){this._config=t}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const t=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(t?.config?.spaces||[]).map(t=>({value:t.id,label:t.title||t.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return sa(this.hass,this._config?.language)}get _schema(){const t=this._spaces||[];return[t.length?{name:"space",selector:{select:{mode:"dropdown",options:t}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{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:{}}}]}render(){if(!this.hass||!this._config)return G;this._loadSpaces();const t=this._lang,e={space:na(t,"editor.space"),title:na(t,"editor.title"),show_button:na(t,"editor.show_button"),button_label:na(t,"editor.button_label"),button_target:na(t,"editor.button_target"),icon_size:na(t,"editor.icon_size"),show_temperature:na(t,"editor.show_temperature"),live_states:na(t,"editor.live_states"),show_signal:na(t,"editor.show_signal")};return j``:W``});return W`${o}`}_renderSvgRoomLabels(t,e){return"iso"===this._renderProjection||t.bg||e.showNames||this._markup?W``:W`${t.rooms.map(t=>{const e=this._roomCenter(t);return W`${t.name}`})}`}_renderWallBodies(t){if("iso"===this._renderProjection)return W``;if(t&&!t.showBorders&&("view"===this._mode||"devices"===this._mode))return W``;const e=this._wallUnionGeometry();if(!e)return W``;const i=this._stageEl,s=this._viewOr(this._baseVb()),n=i&&i.clientWidth&&s.w?i.clientWidth/s.w:1,o=t?.color||"#607d8b",a=_r(e.depthUnits,n),r=this._fillColors.wall_fill;return W`
+ fill=${a.color} fill-opacity=${a.opacity} pointer-events="none">`});return W`${o}`}_renderWallBodies(t){if("iso"===this._renderProjection)return W``;if(t&&!t.showBorders&&("view"===this._mode||"devices"===this._mode))return W``;const e=this._wallUnionGeometry();if(!e)return W``;const i=this._stageEl,s=this._viewOr(this._baseVb()),n=i&&i.clientWidth&&s.w?i.clientWidth/s.w:1,o=t?.color||"#607d8b",a=_r(e.depthUnits,n),r=this._fillColors.wall_fill;return W`
@@ -5560,7 +5547,6 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="51c4bdb005b2539779085578bcce5ee1b4b3
${this._renderOpeningTunnelFills(i,u)}
${this._renderGlowBaseRooms(i,p)}
${this._renderOpeningTunnelFills(i,p,"glow-base")}
- ${this._renderSvgRoomLabels(i,c)}
${A?this._renderGlowLayer(i,c):G}
${this._renderSunRays(i)}
${this._editing?W`${this._renderMeasureLabel(f)} `:G}
diff --git a/demo/smoke_hide_room_names.mjs b/demo/smoke_hide_room_names.mjs
new file mode 100644
index 00000000..037903d5
--- /dev/null
+++ b/demo/smoke_hide_room_names.mjs
@@ -0,0 +1,152 @@
+// #203: `show_names:false` means no permanent room labels in every read-only
+// renderer. Plan keeps its editor-only card and saving never discards layout.
+import { launch, checkAll, finish } from './serve.mjs';
+
+const { page, browser } = await launch({ width: 1000, height: 850 });
+const out = await page.evaluate(async () => {
+ const card = window.__card;
+ const root = (node = card) => node.renderRoot || node.shadowRoot;
+ const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
+ const settle = async () => { await card.updateComplete; await frame(); };
+ const waitMode = async () => {
+ await settle();
+ while (card._modeTransitionBusy) await frame();
+ };
+ const labels = (node = card) => root(node).querySelectorAll('[data-hp="room-label"]');
+ const htmlLabels = (node = card) => root(node).querySelectorAll('div.roomlabel[data-hp="room-label"]');
+ const svgLabels = (node = card) => root(node).querySelectorAll('text.rlabel[data-hp="room-label"]');
+
+ card._setMode('view');
+ await waitMode();
+ const space = card._serverCfg.spaces.find((item) => item.id === card._space);
+ delete space.plan_url;
+ delete space.plan_aspect;
+ const room = space.rooms.find((item) => item.name && item.id && item.area)
+ || space.rooms.find((item) => item.name && item.id);
+ if (!room) throw new Error('fixture has no named room');
+ space.settings = {
+ ...(space.settings || {}), show_borders: true, show_names: true,
+ label_temp: true, label_hum: true, label_lqi: true, label_light: true,
+ };
+ const layoutKey = `rl_${room.id}`;
+ card._layout[layoutKey] = { s: space.id, x: 0.23, y: 0.31, k: 1.6 };
+ card._cfgEpoch++;
+ card._regSignature = '';
+ card.requestUpdate();
+ await settle();
+
+ const first = root().querySelector(`[data-hp="room-label"][data-id="${room.id}"]`);
+ const savedLayout = JSON.stringify(card._layout[layoutKey]);
+ const trueState = {
+ oneHtmlPerNamedRoom: htmlLabels().length === space.rooms.filter((item) => item.name).length,
+ noLegacySvg: svgLabels().length === 0,
+ savedScaleApplied: first?.getAttribute('style')?.includes('--rl-scale:1.6') === true,
+ areaIconKept: !room.area || !!first?.querySelector('.rlgo'),
+ };
+
+ // Cancel: the live preview follows the pending false, but persisted config
+ // and the room-label layout return untouched when the dialog is dismissed.
+ card._openSpaceDialog('edit', space.id);
+ card._spaceDialog = { ...card._spaceDialog, showNames: false };
+ card.requestUpdate();
+ await settle();
+ const livePreviewHidesAll = labels().length === 0;
+ card._spaceDialog = null;
+ card.requestUpdate();
+ await settle();
+ const cancelRestoresTrue = space.settings.show_names === true && htmlLabels().length > 0
+ && JSON.stringify(card._layout[layoutKey]) === savedLayout;
+
+ // Save + reopen: exercise the real existing-space persistence path.
+ card._openSpaceDialog('edit', space.id);
+ card._spaceDialog = { ...card._spaceDialog, showNames: false };
+ await card._saveSpaceDialog();
+ await settle();
+ card._openSpaceDialog('edit', space.id);
+ const reopenReadsFalse = card._spaceDialog?.showNames === false
+ && space.settings.show_names === false;
+ card._spaceDialog = null;
+ card.requestUpdate();
+ await settle();
+
+ const flatFalseHasNoLabels = labels().length === 0
+ && htmlLabels().length === 0 && svgLabels().length === 0;
+
+ card._setMode('plan');
+ await waitMode();
+ const planFalseKeepsEditorCard = htmlLabels().length > 0
+ && svgLabels().length === 0 && !!root().querySelector('.rlgearbtn');
+ card._setMode('view');
+ await waitMode();
+ const viewAfterPlanStillHidden = labels().length === 0;
+
+ // The Labs registry flag is intentionally expired; use the same dormant
+ // renderer fixture hook as the canonical Stage 2 smokes.
+ const active = Object.freeze(['iso']);
+ card._onLabsSnapshot({ active, space: '' });
+ window.__hpLabs = active;
+ await settle();
+ card._setProjection('iso');
+ await settle();
+ const isoFalseHasNoLabels = !!root().querySelector('[data-hp="iso-walls"]')
+ && labels().length === 0;
+ card._setProjection('flat');
+ await settle();
+
+ await customElements.whenDefined('houseplan-space-card');
+ const cfgFalse = structuredClone(card._serverCfg);
+ const layout = structuredClone(card._layout);
+ const staticHass = {
+ ...card.hass,
+ callWS: async (message) => {
+ if (message.type === 'houseplan/config/get') return { config: cfgFalse, rev: 1 };
+ if (message.type === 'houseplan/layout/get') return { layout, rev: 1 };
+ return { ok: true };
+ },
+ };
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const compact = document.createElement('houseplan-space-card');
+ compact.setConfig({ type: 'custom:houseplan-space-card', space: space.id, show_button: false });
+ compact.hass = staticHass;
+ host.appendChild(compact);
+ const deadline = Date.now() + 6000;
+ while (!root(compact)?.querySelector('.hp-static-stage') && Date.now() < deadline) {
+ await new Promise((done) => setTimeout(done, 60));
+ }
+ await compact.updateComplete;
+ const compactFalseHasNoLabels = labels(compact).length === 0
+ && htmlLabels(compact).length === 0 && svgLabels(compact).length === 0;
+
+ // Turning names back on restores the existing HTML card and saved layout.
+ space.settings.show_names = true;
+ card._cfgEpoch++;
+ card.requestUpdate();
+ await settle();
+ const restored = root().querySelector(`[data-hp="room-label"][data-id="${room.id}"]`);
+ const trueRestoresExistingCard = !!restored
+ && restored.tagName.toLowerCase() === 'div'
+ && restored.getAttribute('style')?.includes('--rl-scale:1.6') === true
+ && JSON.stringify(card._layout[layoutKey]) === savedLayout;
+
+ host.remove();
+ card._onLabsSnapshot({ active: Object.freeze([]), space: '' });
+ window.__hpLabs = Object.freeze([]);
+ await settle();
+
+ return {
+ ...trueState,
+ livePreviewHidesAll,
+ cancelRestoresTrue,
+ reopenReadsFalse,
+ flatFalseHasNoLabels,
+ planFalseKeepsEditorCard,
+ viewAfterPlanStillHidden,
+ isoFalseHasNoLabels,
+ compactFalseHasNoLabels,
+ trueRestoresExistingCard,
+ };
+});
+
+checkAll(out);
+await finish(browser, out);
diff --git a/demo/smoke_styling_hooks.mjs b/demo/smoke_styling_hooks.mjs
index 99e6287e..08c0b770 100644
--- a/demo/smoke_styling_hooks.mjs
+++ b/demo/smoke_styling_hooks.mjs
@@ -3,7 +3,7 @@
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
// 2) комната: data-hp="room" + data-id + data-area;
-// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
+// 3) видимая подпись комнаты: data-hp="room-label" + data-id на HTML-карточке;
// 4) проём: data-hp="opening" + data-id + data-kind (door/window/gate);
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
// 6) вкладка пространства: data-hp="space-tab" + data-id;
@@ -50,8 +50,8 @@ const res = await page.evaluate(async () => {
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
// ============ 3. подписи комнат ============
- // одно имя — две разные реализации: