{this._roomSrcFilter=t.target.value,this.requestUpdate()}} />
- ${this._roomSrcCandidates().map(t=>j`
B`
{i(t.value),this._roomSrcOpen=null}}>
${t.label} ${t.sub}
`)}
-
`:W}`:W}`}_renderRoomDialog(){const t=!!this._roomEditId,e=!!this._areaSel||!!this._nameSel.trim(),i=Fi(this._curSpaceCfg),s=this._roomFill||i.fill,o=this._roomCustomFill||i.customFill,n=[...this._freeAreas];if(t&&this._areaSel&&!n.some(t=>t.area_id===this._areaSel)){const t=this.hass.areas[this._areaSel];t&&n.unshift(t)}return j`
`:W}`:W}`}_renderRoomDialog(){const t=!!this._roomEditId,e=!!this._areaSel||!!this._nameSel.trim(),i=Yi(this._curSpaceCfg),s=this._roomFill||i.fill,o=this._roomCustomFill||i.customFill,n=[...this._freeAreas];if(t&&this._areaSel&&!n.some(t=>t.area_id===this._areaSel)){const t=this.hass.areas[this._areaSel];t&&n.unshift(t)}return B`
@@ -5587,17 +5684,17 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc04a319d946937a0d7fb1f2df149fb0fe51
{this._areaSel=t.target.value,!this._nameSel&&this._areaSel&&(this._nameSel=this.hass.areas[this._areaSel]?.name||""),this.requestUpdate()}}>
${this._t("room.no_area_option")}
- ${n.map(t=>j`${t.name} `)}
+ ${n.map(t=>B`${t.name} `)}
${this._t("room.settings_section")}
${this._t("room.fill_label")}
- ${[["","fill.inherit"],...gi.map(t=>[t,"fill."+t])].map(([t,e])=>j`
+ ${[["","fill.inherit"],...Di.map(t=>[t,"fill."+t])].map(([t,e])=>B`
{this._roomFill=t,this.requestUpdate()}} />
${this._t(e)}
`)}
- ${"custom"===s?j`
+ ${"custom"===s?B`
${this._roomCustomFill?this._t("room.custom_fill_own"):this._t("room.custom_fill_space")}
{this._roomCustomFill=null}}>${this._t("btn.reset")}`:W}
+ ${this._roomCustomFill?B`{this._roomCustomFill=null}}>${this._t("btn.reset")} `:W}
`:W}
${this._renderRoomSource("temp")}
${this._renderRoomSource("hum")}
@@ -5621,18 +5718,18 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc04a319d946937a0d7fb1f2df149fb0fe51
${this._rangeInput(50,300,5,Math.round(100*this._roomLabelScale),t=>{this._roomLabelScale=t/100,this.requestUpdate()})}
${Math.round(100*this._roomLabelScale)}%
- ${this._renderCardPreview(Fi(this._curSpaceCfg).cardFontScale,this._roomNameScale,this._roomLabelScale)}
+ ${this._renderCardPreview(Yi(this._curSpaceCfg).cardFontScale,this._roomNameScale,this._roomLabelScale)}
- `}}jc.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomCustomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},jc.ZOOM_MAX=8,jc.ZOOM_MIN=1/3,jc._touchSeen=!1,jc._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,jc.styles=[yt,Jl],customElements.get("houseplan-card")||customElements.define("houseplan-card",jc),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${gc} `,"background:#3ea6ff;color:#04121f;font-weight:700","");
+ `}}ch.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomCustomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ch.ZOOM_MAX=8,ch.ZOOM_MIN=1/3,ch._touchSeen=!1,ch._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ch.styles=[At,mc],customElements.get("houseplan-card")||customElements.define("houseplan-card",ch),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${Ec} `,"background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json
index 02385fcd..ee9879bf 100755
--- a/custom_components/houseplan/manifest.json
+++ b/custom_components/houseplan/manifest.json
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
- "version": "1.61.0-beta.5"
+ "version": "1.61.0-beta.6"
}
diff --git a/demo/golden/README.md b/demo/golden/README.md
index 19dfc69a..5928e0c6 100644
--- a/demo/golden/README.md
+++ b/demo/golden/README.md
@@ -4,7 +4,13 @@ This layer catches visual regressions that DOM smokes cannot: wall seams and
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
chrome, the open contextual tray at wide/medium/narrow widths in English and
Russian (selection, tool options, group and palette), long dialog
-titles/footers, mobile clipping, themes and zoom/remount.
+titles/footers, mobile clipping, themes and zoom/remount. The desktop and
+mobile device-dialog scenarios use a real light and make the complete
+source-role, Glow colour, brightness and radius controls visible; capturing
+only the top of that section fails the scenario before comparison.
+The Glow matrix also keeps one deliberately opaque custom-fill scene with a
+single source and two doorways: it makes hard spill wedges and fully unlit
+radial spokes visible instead of hiding them under a translucent room fill.
## Safety contract
diff --git a/demo/golden/baselines/baseline-manifest.json b/demo/golden/baselines/baseline-manifest.json
index 9495da83..ab9dc06b 100644
--- a/demo/golden/baselines/baseline-manifest.json
+++ b/demo/golden/baselines/baseline-manifest.json
@@ -1,8 +1,8 @@
{
"schema": 1,
- "matrixVersion": 6,
- "acceptedAt": "2026-08-10T15:04:46.442Z",
- "sourceFingerprint": "fc04a319d946937a0d7fb1f2df149fb0fe5164d616bf18f84cac66f0f69e6890",
+ "matrixVersion": 7,
+ "acceptedAt": "2026-08-10T21:45:34.131Z",
+ "sourceFingerprint": "aa95ec153b0073facce2c992e67dff7e0222933f55654bea03b8f4a1bc88a066",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
@@ -19,24 +19,25 @@
"geometry-diagonal-45-opening-dark": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"openings-thick-wall-dark": "5d9ac6add9a5709318180f4554ac62c1c7e2862bd016be7901a2bd450e021f0c",
"openings-hidden-view-dark": "799e791830ab63a1c445b8c77650a577d8e881daa1ff47d1dcf4e238eb1cff16",
- "lighting-glow-sun-dark": "96bc1568319bda267a8b2c3f5eda8e70ca7671ef222195a05a7ad96080691d03",
+ "lighting-glow-sun-dark": "f427f126f57ce3b05fc51552e5d7ad6553cfb802540a206852d9bdfb28a5acb7",
"lighting-fill-light-axis-split-dark": "acdbebe845ede66dbbe9de979aad93ba78dc2779d1ec533b238a9bea0d047d1c",
"lighting-fill-temp-axis-split-dark": "2348b7c6a8594468664755e2fea1b49126e604192599be0a3965f24a0c2d2765",
"lighting-fill-lqi-axis-split-dark": "584adcfb5541b1dddc814c2674bfce8002566bbb667b10501b7ff49fedef6f9c",
- "lighting-temp-glow-dark": "62341e32f5ac3ac4cb7745765be159f77148636c05cd38c8200019df292b4509",
- "lighting-temp-glow-light": "69aa73ff782e0f516c8bcddc4e726177a18a96b8e3050b83f6b34c39ed182996",
- "lighting-custom-glow-dark": "9d8d0f18f99fcf6890b222c6b54a4a5749554bb40475c636ba1370f432da7414",
- "lighting-custom-glow-light": "6bde6176196d4ba5a3d26a675ffa148274b934ffd51ae196996e65382c60b530",
+ "lighting-temp-glow-dark": "d7c2430496dca0302ea91f90326e89204997116293f4efa13e58afdec22a956c",
+ "lighting-temp-glow-light": "0d1f2063a5a34126486092a3881d3a21e9a8962b4bb592141bee249255b1b8b8",
+ "lighting-custom-glow-dark": "504b238cc3094c658d42f50a8dbc757024764c32c376f6843f709c8a93ddf7e0",
+ "lighting-opaque-glow-two-doorways-dark": "30e8f288852d4322c6ff182063b633f303ed62d03a2192633b36cc19f5ca03a6",
+ "lighting-custom-glow-light": "2af515770be22d4af284f971744589e19211f0fcaac1a5f4b15d1cd052f3764a",
"lighting-temp-glow-no-sources-dark": "a169cb08967ee59f5aeb415111936cf5952b1c33a96e2bbcee68a1ec029df1c4",
- "lighting-temp-glow-room-override-dark": "f9c629f2902e5636852b86ad3283620c7d3ec8bba316ff16f4f7a1e3f13f64fb",
- "lighting-manual-auto-spill-overlap-dark": "1474e69b33629e7b52e7381110f480eb56d5eca81c54c304634bf65ce9239df2",
- "hover-over-glow-dark": "f9846a0a91f8bf9f9752dfdd3b3af5ddfa89e25558fcceb3b598557b3584fa09",
- "hover-nested-room-dark": "472f630768f60c3168f64744af891323a64cf71f36f943fc5a56b4f0494efa70",
- "large-house-zoom-040-dark": "1ad9aedab81858f9faa3ca9be1e27cf95917c0b2d0310cfff30920674b73c726",
- "large-house-zoom-250-dark": "e92a875108fe73a18aa4ca5d4707c6fe5813dea93acae1b7d9923dbfa8d29c46",
- "large-house-warm-remount-dark": "de3e23328e855f467772e7f2a8af386be040e4e81a65eb67bea1196733c36a69",
- "device-dialog-desktop-en": "9a40aa140e9834bef2f96aed27916010b22135696f881b1506b1348eaa2a1597",
- "device-dialog-mobile-ru": "5445eb576636362d402f66aa80c3816e01ca7ee75f4ffc86708ff3a43bbfb4a5",
+ "lighting-temp-glow-room-override-dark": "0a183f34c8ccc1f8d686ee7e6c0332717705bd725ae8a95861cd157b9f041404",
+ "lighting-manual-auto-spill-overlap-dark": "ac27d844ac6e1996fd897cc36248d2bb0bbcf8765324a78da3b1b994ae7022ec",
+ "hover-over-glow-dark": "cf3bc7d690d33a4f28bca4f113610a98b257abdf5612f3b5bfd55851286c1d67",
+ "hover-nested-room-dark": "04936c209ebafce96e080fc5ba71611781caefda6e1637ff9ccb20b5ea122eae",
+ "large-house-zoom-040-dark": "463792710f162805aacd177142044603b813ba32e911e202d7cc792115e45a8b",
+ "large-house-zoom-250-dark": "dbb4707c8e4fd351e88c23a21513f0edc658ee05643afd5ba59d91eb0266fd7c",
+ "large-house-warm-remount-dark": "cb5e9c6ecb13affb6f3b4778ce80eff94bad99f6bd4036a301ecc58aa3714223",
+ "device-dialog-desktop-en": "b2c36a4833d09e3eecafd1781b9029534434a90319da2476533534263ae2a439",
+ "device-dialog-mobile-ru": "5b66639f71623d218f33df8db99f70edc139b9de60ffe291641ccf10665b78cb",
"decor-color-popover-mobile-ru": "eab3c5c76b27050dbdbc1627371a16916ea30473934de48842c6ab94a045f9db"
}
}
diff --git a/demo/golden/baselines/device-dialog-desktop-en.png b/demo/golden/baselines/device-dialog-desktop-en.png
index 33603632..110d023d 100644
Binary files a/demo/golden/baselines/device-dialog-desktop-en.png and b/demo/golden/baselines/device-dialog-desktop-en.png differ
diff --git a/demo/golden/baselines/device-dialog-mobile-ru.png b/demo/golden/baselines/device-dialog-mobile-ru.png
index 02babb41..865f7243 100644
Binary files a/demo/golden/baselines/device-dialog-mobile-ru.png and b/demo/golden/baselines/device-dialog-mobile-ru.png differ
diff --git a/demo/golden/baselines/hover-nested-room-dark.png b/demo/golden/baselines/hover-nested-room-dark.png
index 9c74fe29..6034f7a1 100644
Binary files a/demo/golden/baselines/hover-nested-room-dark.png and b/demo/golden/baselines/hover-nested-room-dark.png differ
diff --git a/demo/golden/baselines/hover-over-glow-dark.png b/demo/golden/baselines/hover-over-glow-dark.png
index 38e67640..ebedcd70 100644
Binary files a/demo/golden/baselines/hover-over-glow-dark.png and b/demo/golden/baselines/hover-over-glow-dark.png differ
diff --git a/demo/golden/baselines/large-house-warm-remount-dark.png b/demo/golden/baselines/large-house-warm-remount-dark.png
index 2148e34f..0f0bba10 100644
Binary files a/demo/golden/baselines/large-house-warm-remount-dark.png and b/demo/golden/baselines/large-house-warm-remount-dark.png differ
diff --git a/demo/golden/baselines/large-house-zoom-040-dark.png b/demo/golden/baselines/large-house-zoom-040-dark.png
index af0f0868..f73d90e8 100644
Binary files a/demo/golden/baselines/large-house-zoom-040-dark.png and b/demo/golden/baselines/large-house-zoom-040-dark.png differ
diff --git a/demo/golden/baselines/large-house-zoom-250-dark.png b/demo/golden/baselines/large-house-zoom-250-dark.png
index 7acbfdc7..e2cfa2a3 100644
Binary files a/demo/golden/baselines/large-house-zoom-250-dark.png and b/demo/golden/baselines/large-house-zoom-250-dark.png differ
diff --git a/demo/golden/baselines/lighting-custom-glow-dark.png b/demo/golden/baselines/lighting-custom-glow-dark.png
index 30185519..ba50ffbc 100644
Binary files a/demo/golden/baselines/lighting-custom-glow-dark.png and b/demo/golden/baselines/lighting-custom-glow-dark.png differ
diff --git a/demo/golden/baselines/lighting-custom-glow-light.png b/demo/golden/baselines/lighting-custom-glow-light.png
index 5c7ee535..4ec8d5cb 100644
Binary files a/demo/golden/baselines/lighting-custom-glow-light.png and b/demo/golden/baselines/lighting-custom-glow-light.png differ
diff --git a/demo/golden/baselines/lighting-glow-sun-dark.png b/demo/golden/baselines/lighting-glow-sun-dark.png
index ac6af74c..e04523a8 100644
Binary files a/demo/golden/baselines/lighting-glow-sun-dark.png and b/demo/golden/baselines/lighting-glow-sun-dark.png differ
diff --git a/demo/golden/baselines/lighting-manual-auto-spill-overlap-dark.png b/demo/golden/baselines/lighting-manual-auto-spill-overlap-dark.png
index a85b9a69..59a4ed7b 100644
Binary files a/demo/golden/baselines/lighting-manual-auto-spill-overlap-dark.png and b/demo/golden/baselines/lighting-manual-auto-spill-overlap-dark.png differ
diff --git a/demo/golden/baselines/lighting-opaque-glow-two-doorways-dark.png b/demo/golden/baselines/lighting-opaque-glow-two-doorways-dark.png
new file mode 100644
index 00000000..86793075
Binary files /dev/null and b/demo/golden/baselines/lighting-opaque-glow-two-doorways-dark.png differ
diff --git a/demo/golden/baselines/lighting-temp-glow-dark.png b/demo/golden/baselines/lighting-temp-glow-dark.png
index 670d0e1d..78d95442 100644
Binary files a/demo/golden/baselines/lighting-temp-glow-dark.png and b/demo/golden/baselines/lighting-temp-glow-dark.png differ
diff --git a/demo/golden/baselines/lighting-temp-glow-light.png b/demo/golden/baselines/lighting-temp-glow-light.png
index 7f1c110c..9f319557 100644
Binary files a/demo/golden/baselines/lighting-temp-glow-light.png and b/demo/golden/baselines/lighting-temp-glow-light.png differ
diff --git a/demo/golden/baselines/lighting-temp-glow-room-override-dark.png b/demo/golden/baselines/lighting-temp-glow-room-override-dark.png
index 2750f80e..b33f3767 100644
Binary files a/demo/golden/baselines/lighting-temp-glow-room-override-dark.png and b/demo/golden/baselines/lighting-temp-glow-room-override-dark.png differ
diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs
index deab76e6..39a534aa 100644
--- a/demo/golden/harness.mjs
+++ b/demo/golden/harness.mjs
@@ -48,20 +48,26 @@ async function stableEnvironment(page, scenario) {
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
}
-export async function prepareGoldenScenario(page, scenario) {
- await stableEnvironment(page, scenario);
+/** Apply every data-only scenario override before the fixture crosses into the browser. */
+export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
if (scenario.deviceName && fixture.devices?.[scenario.deviceId])
fixture.devices[scenario.deviceId].name = scenario.deviceName;
- if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean') {
+ if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
+ || typeof scenario.sunRays === 'boolean') {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
space.settings = {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
+ ...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
+ if (scenario.extraOpenings?.length) {
+ const space = fixture.config.spaces.find((item) => item.id === scenario.space);
+ space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
+ }
if (scenario.hideOpenings) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
space.settings = { ...(space.settings || {}), hide_openings: true };
@@ -86,6 +92,16 @@ export async function prepareGoldenScenario(page, scenario) {
fixture.states[entityId] = { ...state, state: 'off' };
}
}
+ if (scenario.stateOverrides) {
+ for (const [entityId, override] of Object.entries(scenario.stateOverrides)) {
+ const current = fixture.states?.[entityId] || { entity_id: entityId, attributes: {} };
+ fixture.states[entityId] = {
+ ...current,
+ ...structuredClone(override),
+ attributes: { ...(current.attributes || {}), ...(override.attributes || {}) },
+ };
+ }
+ }
if (scenario.markerOverrides) {
const ids = new Set(scenario.markerOverrides.map((marker) => marker.id));
fixture.config.markers = [
@@ -97,6 +113,13 @@ export async function prepareGoldenScenario(page, scenario) {
fixture.layout = { ...(fixture.layout || {}), ...structuredClone(scenario.layoutOverrides) };
}
+ return fixture;
+}
+
+export async function prepareGoldenScenario(page, scenario) {
+ await stableEnvironment(page, scenario);
+ const fixture = prepareGoldenFixture(scenario);
+
return page.evaluate(async ({ fixture, scenario }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
@@ -241,6 +264,33 @@ export async function prepareGoldenScenario(page, scenario) {
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
card._openMarkerDialog(device);
await card.updateComplete;
+ if (scenario.deviceLightControls) {
+ card._setMarkerLightRole('always');
+ await card.updateComplete;
+ card._setMarkerGlowMode('fixed');
+ await card.updateComplete;
+ const dialog = card.renderRoot.querySelector('hp-dialog');
+ const body = dialog?.querySelector('.body');
+ const groups = [...(dialog?.querySelectorAll('.markerlightgroup') || [])];
+ const roleInputs = groups[0]?.querySelectorAll('input[name="marker-light-role"]');
+ const glowInputs = groups[1]?.querySelectorAll('input[name="marker-glow-mode"]');
+ const color = groups[1]?.querySelector('hp-color-opacity');
+ const brightness = groups[1]?.querySelector('input[type="range"]');
+ const radius = groups[1]?.querySelector('input.tempin[type="number"]');
+ if (!body || groups.length !== 2 || roleInputs?.length !== 3 || glowInputs?.length !== 3
+ || !roleInputs[1]?.checked || !glowInputs[2]?.checked
+ || !color || color.disabled || !brightness || brightness.disabled || !radius || radius.disabled)
+ throw new Error('golden device light-source controls are incomplete');
+ const bodyRect = body.getBoundingClientRect();
+ const roleRect = groups[0].getBoundingClientRect();
+ body.scrollTop += roleRect.top - bodyRect.top - 8;
+ await frame();
+ const visibleBody = body.getBoundingClientRect();
+ const visibleRole = groups[0].getBoundingClientRect();
+ const visibleGlow = groups[1].getBoundingClientRect();
+ if (visibleRole.top < visibleBody.top - 1 || visibleGlow.bottom > visibleBody.bottom + 1)
+ throw new Error('golden viewport does not show the complete device light-source controls');
+ }
if (scenario.focusDialogClose) {
const dialog = card.renderRoot.querySelector('hp-dialog');
await dialog?.updateComplete;
diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs
index ac22b454..9534ef2d 100644
--- a/demo/golden/matrix.mjs
+++ b/demo/golden/matrix.mjs
@@ -1,5 +1,5 @@
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
-export const GOLDEN_MATRIX_VERSION = 6;
+export const GOLDEN_MATRIX_VERSION = 7;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -57,6 +57,17 @@ export const GOLDEN_SCENARIOS = Object.freeze([
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
+ { id: 'lighting-opaque-glow-two-doorways-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
+ fillMode: 'custom', customFill: { c: '#3f4854', a: 1 }, glowEnabled: true, sunRays: false,
+ allLightsOff: true,
+ stateOverrides: {
+ 'light.golden_light_one': { state: 'on', attributes: { rgb_color: [255, 196, 112], brightness: 255 } },
+ },
+ extraOpenings: [
+ { id: 'light-door-second', type: 'door', x: 0.50, y: 0.32, angle: 90, length: 0.13 },
+ ],
+ layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.22, y: 0.48 } },
+ theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-custom-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
@@ -86,13 +97,13 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'large-house-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
warmRemount: true, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
- dialog: 'device', deviceId: 'golden-climate',
- deviceName: 'Living room climate controller with an intentionally long title',
- focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
+ dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
+ deviceName: 'Living room light controller with an intentionally long title',
+ focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 1200 }, ...page },
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
- dialog: 'device', deviceId: 'golden-climate',
- deviceName: 'Контроллер климата гостиной с намеренно очень длинным названием',
- language: 'ru', theme: 'dark', viewport: { width: 390, height: 760 }, ...page },
+ dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
+ deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
+ language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'decor-color', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
diff --git a/demo/smoke_free_walls.mjs b/demo/smoke_free_walls.mjs
index 48d979bd..5cf41f02 100644
--- a/demo/smoke_free_walls.mjs
+++ b/demo/smoke_free_walls.mjs
@@ -11,6 +11,36 @@ const out = await page.evaluate(async () => {
const c = window.__card;
const PITCH = 1000 / 240;
window.confirm = () => true;
+ // Exercise hp-dialog's HA branch without depending on the Home Assistant
+ // frontend in the standalone demo. The fixed one-line fallback below
+ // reproduces ha-dialog-header's public custom-property contract: Houseplan
+ // must explicitly opt into content-sized localized titles.
+ if (!customElements.get('ha-dialog')) {
+ customElements.define('ha-dialog', class extends HTMLElement {
+ constructor() {
+ super();
+ const root = this.attachShadow({ mode: 'open' });
+ root.innerHTML = `
+
+
+
+
+
`;
+ }
+ });
+ }
+ c.hass = { ...c.hass, locale: { ...(c.hass.locale || {}), language: 'ru' } };
c._serverCfg = {
spaces: [{ id: 'physical', title: 'Physical', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [] }],
@@ -77,6 +107,66 @@ const out = await page.evaluate(async () => {
&& Math.abs((sp.partitions[0].b[0] - sp.partitions[0].a[0]) - 0.2) < 1e-9
&& Math.abs(sp.partitions[0].a[1] - sp.partitions[0].b[1]) < 1e-9;
+ const actionLayout = () => {
+ const root = c.shadowRoot || c.renderRoot;
+ const dialog = root.querySelector('hp-dialog');
+ const footer = dialog?.querySelector('.physicalfooter');
+ const danger = footer?.querySelector('.dialog-action-danger');
+ const commit = footer?.querySelector('.dialog-action-commit');
+ const footerRect = footer?.getBoundingClientRect();
+ const footerStyle = footer ? getComputedStyle(footer) : null;
+ const innerLeft = footerRect && footerStyle
+ ? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
+ const innerRight = footerRect && footerStyle
+ ? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
+ const buttons = [...(footer?.querySelectorAll('button') || [])];
+ return {
+ dialog, footer, danger, commit, footerRect, innerLeft, innerRight, buttons,
+ contained: !!footerRect && buttons.every((button) => {
+ const rect = button.getBoundingClientRect();
+ return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
+ && rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
+ }),
+ };
+ };
+
+ c._openPhysicalDialog('partition', sp.partitions[0].id);
+ await c.updateComplete;
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ const partitionLayout = actionLayout();
+ const haDialog = partitionLayout.dialog?.shadowRoot?.querySelector('ha-dialog');
+ const title = partitionLayout.dialog?.shadowRoot?.querySelector('.title-text');
+ const mockTitle = haDialog?.shadowRoot?.querySelector('.mock-title');
+ const titleRect = title?.getBoundingClientRect();
+ const mockTitleRect = mockTitle?.getBoundingClientRect();
+ const dangerRect = partitionLayout.danger?.getBoundingClientRect();
+ const commitRect = partitionLayout.commit?.getBoundingClientRect();
+ o.physicalTitleUsesAutoHeight = !!haDialog && getComputedStyle(haDialog)
+ .getPropertyValue('--ha-dialog-header-title-height').trim() === 'auto';
+ o.physicalTitleWrapsAndIsContained = !!titleRect && !!mockTitleRect
+ && titleRect.height > 35
+ && titleRect.top >= mockTitleRect.top - 1
+ && titleRect.bottom <= mockTitleRect.bottom + 1;
+ o.partitionFooterActionsContained = partitionLayout.contained
+ && partitionLayout.buttons.length === 3;
+ o.partitionFooterGroupsWrapCleanly = !!dangerRect && !!commitRect
+ && commitRect.top >= dangerRect.bottom - 1
+ && Math.abs(commitRect.right - partitionLayout.innerRight) <= 1;
+ o.partitionFooterTouchTargets = partitionLayout.buttons.every((button) =>
+ button.getBoundingClientRect().height >= 44);
+
+ c._physicalDialog = {
+ kind: 'draft', id: 'join-a', segment: 0, cm: '10', length: '1 m',
+ };
+ await c.updateComplete;
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ const draftLayout = actionLayout();
+ o.draftFooterFourActionsContained = draftLayout.contained
+ && draftLayout.buttons.length === 4
+ && draftLayout.buttons.every((button) => button.scrollWidth <= button.clientWidth + 1);
+ c._physicalDialog = null;
+ await c.updateComplete;
+
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
c._deletePhysicalSelection();
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
diff --git a/demo/smoke_glow.mjs b/demo/smoke_glow.mjs
index 3ae0cf35..94382816 100644
--- a/demo/smoke_glow.mjs
+++ b/demo/smoke_glow.mjs
@@ -19,13 +19,22 @@ const res = await page.evaluate(async () => {
out.hasLitLight = !!litLight;
out.spots = sr().querySelectorAll('.glowlayer circle').length;
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
- // 3) выключение лампы убирает её пятно
+ // 3) выключение лампы плавно гасит пятно и удаляет его после 500 мс
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
const st0 = c.hass.states[eid];
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
await c.updateComplete;
+ const leavingSpot = sr().querySelector('.glow-spot.is-leaving');
+ out.spotFadesOut = !!leavingSpot
+ && getComputedStyle(leavingSpot).transitionDuration.split(',').some((v) => v.trim() === '0.5s');
+ await new Promise((r) => setTimeout(r, 550));
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
+ const returningSpot = [...sr().querySelectorAll('.glow-spot')]
+ .find((node) => node.dataset.glowSource === eid);
+ out.spotFadesIn = !!returningSpot
+ && !returningSpot.classList.contains('is-leaving')
+ && getComputedStyle(returningSpot).transitionDuration.split(',').some((v) => v.trim() === '0.5s');
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
await c.updateComplete;
@@ -53,32 +62,118 @@ const res = await page.evaluate(async () => {
})();
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
+ c._cfgEpoch++; c._glowClipCache.clear();
c.requestUpdate(); await c.updateComplete;
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
const c1 = c._roomCenter(r1);
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
+ // Give the shadow assertion a real physical body. The original fixture has
+ // no walls/partitions/columns, so no shadow mask can legitimately exist.
+ const shadowCenter = [
+ c1[0] + (doorPt[0] - c1[0]) * 0.35,
+ c1[1] + (doorPt[1] - c1[1]) * 0.35,
+ ];
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
- c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
+ c._serverCfg = {
+ ...c._serverCfg,
+ settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 },
+ spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
+ ...s,
+ wall_columns: [
+ ...(s.wall_columns || []).filter((column) => column.id !== 'glow-shadow-column'),
+ {
+ id: 'glow-shadow-column', shape: 'circle', cm: 30,
+ center: [shadowCenter[0] / 1000, shadowCenter[1] / H],
+ },
+ ],
+ })),
+ };
+ c._cfgEpoch++; c._glowClipCache.clear();
c.requestUpdate(); await c.updateComplete;
- // теперь каждый под-контур — отдельный path внутри clipPath
- const clipEls = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
- out.sectorAdded = clipEls.some((cp) => cp.querySelectorAll('path').length >= 2);
- // и сектор в том же направлении обхода не выедает комнату: каждый path — один контур
- out.singleSubpathPerPath = clipEls.every((cp) => [...cp.querySelectorAll('path')]
- .every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1));
- // у входной двери (наружу) сектора нет: дверь на внешней стене r1 (x=min)
+ // One source, one shape: the floor this lamp can see. A doorway, the room
+ // behind it and the shadow of a column are the same region, so they can never
+ // disagree — which is what every earlier bug in this area was made of.
+ const litClips = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
+ const pool = sr().querySelector('.glow-pool');
+ out.poolLimitedToVisibleFloor = !!pool
+ && litClips.length > 0
+ && litClips.every((cp) => cp.querySelectorAll('path.glow-lit').length === 1)
+ && !!pool.getAttribute('clip-path');
+ // A spot paints one primitive through one region. A second layer (a spill
+ // path, an ambient wash, a shadow mask of its own) is exactly how "the
+ // attribute is there but nothing renders" became possible.
+ out.singleLayerPerSource = [...sr().querySelectorAll('.glow-spot')].every((spot) => (
+ spot.children.length === 1 && spot.firstElementChild?.classList.contains('glow-pool')
+ ));
+ // Exactly one blur in the light layer, and only as the penumbra of that one
+ // region: a hair on screen, never a soft sector pasted over the plan.
+ const glowFilters = [...sr().querySelectorAll('defs filter[id^="hp-glow"]')];
+ const pools = sr().querySelector('.glow-pools');
+ out.edgeFeatherIsAHair = glowFilters.length === 1
+ && glowFilters[0].querySelectorAll('feGaussianBlur').length === 1
+ && sr().querySelector('.glow-pools-frame')?.getAttribute('filter') === 'url(#hp-glowfeather)'
+ && Number(pools?.dataset.featherPx) > 0 && Number(pools?.dataset.featherPx) <= 3
+ && [...sr().querySelectorAll('.glowlayer [mask], .glow-pools [filter]')].length === 0;
+ // Дверь наружу: свет через неё не выходит — есть проём, но за ним нет пола.
const minX = Math.min(...poly1.map((p) => p[0]));
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
+ c._cfgEpoch++; c._glowClipCache.clear();
c.requestUpdate(); await c.updateComplete;
- const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
- out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
- // 6б) профиль градиента: плато до 80%, спад на внешних 20%
- const stops = [...sr().querySelectorAll('defs radialGradient')][0];
- const offs = stops ? [...stops.querySelectorAll('stop')].map((st2) => [st2.getAttribute('offset'), st2.getAttribute('stop-opacity')]) : [];
- out.plateau80 = offs.length === 3 && offs[0][0] === '0%' && offs[1][0] === '70%'
- && offs[0][1] === offs[1][1] && offs[2][1] === '0';
+ const roomsBox = (() => {
+ const xs = [], ys = [];
+ for (const room of c._spaceModel().rooms) {
+ for (const point of (c._roomPolyOf?.(room) || room.poly
+ || [[room.x, room.y], [room.x + room.w, room.y + room.h]])) {
+ xs.push(point[0]); ys.push(point[1]);
+ }
+ }
+ return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
+ })();
+ const clipNumbers = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
+ .flatMap((p) => (p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g) || []).map(Number));
+ const slack = c._cmToUnits(20);
+ out.entranceKeepsLightIndoors = clipNumbers.length > 0
+ && clipNumbers.every((value, index) => (index % 2 === 0
+ ? value >= roomsBox.minX - slack && value <= roomsBox.maxX + slack
+ : value >= roomsBox.minY - slack && value <= roomsBox.maxY + slack));
+ // Nothing behind it to light, so for light a front door is masonry like a
+ // window: it must not change the lit region at all. Half a lit tunnel — up
+ // to the centreline, where the room polygon ends — is the failure mode.
+ const litPath = () => [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
+ .map((p) => p.getAttribute('d')).join('|');
+ const withEntrance = litPath();
+ c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
+ ...s, openings: [] })) };
+ c._cfgEpoch++; c._glowClipCache.clear();
+ c.requestUpdate(); await c.updateComplete;
+ out.entranceChangesNothing = withEntrance.length > 0 && litPath() === withEntrance;
+ // 6б) профиль градиента: монотонное затухание по всему радиусу. Плато до
+ // 70% превращало любую обрезанную форму в плашку сплошного цвета с каймой —
+ // именно так сектор в соседней комнате и читался как «залито непонятно чем».
+ const gradient = [...sr().querySelectorAll('defs radialGradient')][0];
+ const offs = gradient
+ ? [...gradient.querySelectorAll('stop')].map((st2) => [
+ Number(String(st2.getAttribute('offset')).replace('%', '')),
+ Number(st2.getAttribute('stop-opacity')),
+ ])
+ : [];
+ const at = (percent) => {
+ for (let i = 1; i < offs.length; i++) {
+ if (offs[i][0] >= percent) {
+ const span = offs[i][0] - offs[i - 1][0] || 1;
+ const k = (percent - offs[i - 1][0]) / span;
+ return offs[i - 1][1] + (offs[i][1] - offs[i - 1][1]) * k;
+ }
+ }
+ return offs.length ? offs[offs.length - 1][1] : 0;
+ };
+ out.falloffMonotonic = offs.length >= 4
+ && offs[0][0] === 0 && offs[0][1] > 0
+ && offs[offs.length - 1][0] === 100 && offs[offs.length - 1][1] === 0
+ && offs.every((stop, i) => i === 0 || (stop[0] > offs[i - 1][0] && stop[1] < offs[i - 1][1]))
+ && at(70) <= offs[0][1] * 0.75;
// Tri-state role is live: Auto -> Never -> Auto removes and restores only
// this marker's own spatial pool.
const litMarkerId = litLight.id;
@@ -136,8 +231,298 @@ const res = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete;
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
+ // Hover must not promote a filtered SVG sibling and flash the screen-blended
+ // Glow layer. The pool/gradient nodes stay alive and hover uses plain SVG.
+ const poolBeforeHover = sr().querySelector('.glow-pool');
+ const gradientBeforeHover = sr().querySelector('radialGradient[id^="hp-glow-"]');
+ const roomEl = sr().querySelector('.room');
+ roomEl?.dispatchEvent(new MouseEvent('mouseenter'));
+ await c.updateComplete;
+ const hoverFill = sr().querySelector('.room-hover-fill');
+ const hoverHalo = sr().querySelector('.room-hover-halo');
+ const hoverOutline = sr().querySelector('.room-hover-outline');
+ out.hoverKeepsGlowDom = poolBeforeHover === sr().querySelector('.glow-pool')
+ && gradientBeforeHover === sr().querySelector('radialGradient[id^="hp-glow-"]');
+ out.hoverUsesPlainSvg = !!roomEl && !!hoverFill && !!hoverHalo && !!hoverOutline
+ && getComputedStyle(roomEl).filter === 'none'
+ && getComputedStyle(hoverHalo).filter === 'none'
+ && getComputedStyle(hoverOutline).filter === 'none';
+ roomEl?.dispatchEvent(new MouseEvent('mouseleave'));
+ await c.updateComplete;
return out;
});
+
+// A deterministic opaque-floor scene guards the two bugs which survived the
+// old DOM-only assertions: separate physical shadows and visible spill at
+// every valid doorway. It intentionally uses thick walls and two receiving
+// rooms, then compares actual stage pixels with the mask/light toggled.
+const rasterFixture = await page.evaluate(async () => {
+ const c = window.__card;
+ const root = c.shadowRoot || c.renderRoot;
+ const spaceId = c._space;
+ const source = c._devices.find((device) => device.id === 'd_light1');
+ const sourceEid = source?.entities.find((eid) => eid.startsWith('light.'));
+ const current = c._serverCfg.spaces.find((space) => space.id === spaceId);
+ if (!source || !sourceEid || !current) return { ready: false };
+
+ const edgeKey = (a, b) => `${a.join(',')}/${b.join(',')}`;
+ const edges = new Map();
+ for (const room of current.rooms || []) {
+ for (let index = 0; index < room.poly.length; index++) {
+ const a = room.poly[index];
+ const b = room.poly[(index + 1) % room.poly.length];
+ const reverse = edgeKey(b, a);
+ if (!edges.has(reverse) && !edges.has(edgeKey(a, b))) edges.set(edgeKey(a, b), { a, b });
+ }
+ }
+ const walls = [...edges.values()].map(({ a, b }, index) => ({
+ key: `glow-raster-wall-${index}`, a, b, cm: 15,
+ }));
+ const openings = [
+ { id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32, angle: 90, length: 0.09 },
+ { id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 },
+ ];
+ c._serverCfg = {
+ ...c._serverCfg,
+ settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 },
+ markers: (c._serverCfg.markers || []).filter((marker) => marker.id !== source.id),
+ spaces: c._serverCfg.spaces.map((space) => space.id !== spaceId ? space : ({
+ ...space,
+ settings: {
+ ...(space.settings || {}), fill_mode: 'custom',
+ custom_fill: { c: '#3f4854', a: 1 }, glow_enabled: true, sun_rays: false,
+ },
+ walls,
+ openings,
+ open_spans: [],
+ wall_columns: [{
+ id: 'glow-raster-column', shape: 'circle', cm: 60, center: [0.18, 0.30],
+ }],
+ partitions: [],
+ })),
+ };
+ c._layout = { ...c._layout, [source.id]: { s: spaceId, x: 0.295, y: 0.36 } };
+ const states = { ...c.hass.states };
+ for (const [eid, state] of Object.entries(states)) {
+ if (eid.startsWith('light.')) states[eid] = { ...state, state: 'off' };
+ }
+ states[sourceEid] = {
+ ...states[sourceEid], state: 'on',
+ attributes: { ...(states[sourceEid]?.attributes || {}), brightness: 255, rgb_color: [255, 196, 112] },
+ };
+ c.hass = { ...c.hass, states };
+ c._cfgEpoch++;
+ c._glowClipCache.clear();
+ c._regSignature = '';
+ c._maybeRebuildDevices();
+ c.requestUpdate();
+ await c.updateComplete;
+ await new Promise((resolve) => setTimeout(resolve, 600));
+
+ const freeze = document.createElement('style');
+ freeze.dataset.glowSmokeFreeze = 'true';
+ freeze.textContent = '*,*::before,*::after{animation:none!important;transition:none!important}';
+ root.querySelector('[data-glow-smoke-freeze]')?.remove();
+ root.append(freeze);
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+
+ const pools = [...root.querySelectorAll('.glow-pool[clip-path]')];
+ const litParts = pools.length > 0 && pools.every((node) => {
+ const clipId = node.getAttribute('clip-path')?.match(/^url\(#(.+)\)$/)?.[1];
+ const path = clipId ? root.getElementById(clipId)?.querySelector('path.glow-lit') : null;
+ // Every lit part is a subpath of the one clipped shape.
+ return !!path && (path.getAttribute('d').match(/M/g) || []).length
+ >= Number(node.dataset.litParts);
+ });
+ const stage = root.querySelector('.stage');
+ const svg = stage?.querySelector('svg');
+ const matrix = svg?.getScreenCTM();
+ if (!pools.length || !stage || !svg || !matrix) {
+ return { ready: false, litParts, sourceEid };
+ }
+ const stageRect = stage.getBoundingClientRect();
+ const toStage = (point) => {
+ const mapped = new DOMPoint(point[0], point[1]).matrixTransform(matrix);
+ return [mapped.x - stageRect.left, mapped.y - stageRect.top];
+ };
+ const sourcePos = c._pos(source);
+ const openingById = new Map(c._openingsR.map((opening) => [opening.id, opening]));
+ const wallDepth = c._cmToUnits(15);
+ const doors = openings.map((stored) => {
+ const opening = openingById.get(stored.id);
+ if (!opening) return null;
+ const radians = opening.angle * Math.PI / 180;
+ const tangent = [Math.cos(radians), Math.sin(radians)];
+ const normal = [-tangent[1], tangent[0]];
+ const sourceSide = ((sourcePos.x - opening.rx) * normal[0]
+ + (sourcePos.y - opening.ry) * normal[1]) >= 0 ? 1 : -1;
+ const outward = [-normal[0] * sourceSide, -normal[1] * sourceSide];
+ const after = wallDepth / 2 + Math.max(6, c._gridPitch * 0.6);
+ const sampleCenter = [opening.rx + outward[0] * after, opening.ry + outward[1] * after];
+ const scan = [];
+ for (let offset = -opening.rlen * 2.5; offset <= opening.rlen * 2.5; offset += 2) {
+ scan.push({
+ offset,
+ point: toStage([
+ sampleCenter[0] + tangent[0] * offset,
+ sampleCenter[1] + tangent[1] * offset,
+ ]),
+ });
+ }
+ // Inside the aperture itself. An unlit bar here is what disconnected the
+ // pool from its own beam and made a doorway read as a red plug (#71).
+ const tunnel = [-0.3, -0.15, 0, 0.15, 0.3].map((share) => toStage([
+ opening.rx + tangent[0] * opening.rlen * share,
+ opening.ry + tangent[1] * opening.rlen * share,
+ ]));
+ return {
+ id: opening.id,
+ openingLength: opening.rlen,
+ sample: toStage(sampleCenter),
+ tunnel,
+ scan,
+ };
+ }).filter(Boolean);
+ // The column: behind it there must be no light, beside it there must be, and
+ // the border between the two must be a line — not a 45 cm gradient.
+ const column = (c._curSpaceCfg.wall_columns || [])[0];
+ let shadow = null;
+ if (column) {
+ const centre = [column.center[0] * 1000, column.center[1] * c._spaceH];
+ const radius = c._cmToUnits(column.cm) / 2;
+ const away = Math.hypot(centre[0] - sourcePos.x, centre[1] - sourcePos.y);
+ const base = Math.atan2(centre[1] - sourcePos.y, centre[0] - sourcePos.x);
+ const half = Math.asin(Math.max(0.02, Math.min(0.9, radius / away)));
+ const at = (angle, distance) => toStage([
+ sourcePos.x + Math.cos(angle) * distance,
+ sourcePos.y + Math.sin(angle) * distance,
+ ]);
+ const far = away + radius + Math.max(10, c._gridPitch);
+ shadow = {
+ inside: [at(base, far), at(base, away + radius * 2.2)],
+ beside: [at(base + half * 4, far), at(base - half * 4, far)],
+ // Across the shadow edge, one plan unit at a time.
+ edge: Array.from({ length: 41 }, (_, index) => {
+ const offset = (index - 20) * 1.0;
+ const angle = base + half + Math.atan2(offset, far);
+ return { offset, point: at(angle, far) };
+ }),
+ };
+ }
+ return { ready: true, litParts, sourceEid, doors, shadow };
+});
+
+if (rasterFixture.ready) {
+ const stage = page.locator('houseplan-card').locator('.stage').first();
+ const lightOn = await stage.screenshot({ animations: 'disabled' });
+ await page.evaluate(async (sourceEid) => {
+ const c = window.__card;
+ const state = c.hass.states[sourceEid];
+ c.hass = {
+ ...c.hass,
+ states: { ...c.hass.states, [sourceEid]: { ...state, state: 'off' } },
+ };
+ await c.updateComplete;
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ }, rasterFixture.sourceEid);
+ const lightOff = await stage.screenshot({ animations: 'disabled' });
+ const metrics = await page.evaluate(async ({ on64, off64, doors, shadow }) => {
+ const decode = async (base64) => {
+ const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
+ return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
+ };
+ const images = await Promise.all([decode(on64), decode(off64)]);
+ const width = images[0].width;
+ const height = images[0].height;
+ if (images.some((image) => image.width !== width || image.height !== height)) {
+ return { dimensionsMatch: false, shadow: null, doors: [] };
+ }
+ const data = images.map((image) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = width; canvas.height = height;
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ context.drawImage(image, 0, 0);
+ return context.getImageData(0, 0, width, height).data;
+ });
+ const [lit, off] = data;
+ const lumaAt = (pixels, x, y) => {
+ const px = Math.max(0, Math.min(width - 1, Math.round(x)));
+ const py = Math.max(0, Math.min(height - 1, Math.round(y)));
+ const offset = (py * width + px) * 4;
+ return pixels[offset] * 0.2126 + pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722;
+ };
+ const meanLuma = (pixels, point, radius = 2) => {
+ let total = 0; let count = 0;
+ for (let y = point[1] - radius; y <= point[1] + radius; y++) {
+ for (let x = point[0] - radius; x <= point[0] + radius; x++) {
+ total += lumaAt(pixels, x, y); count++;
+ }
+ }
+ return total / count;
+ };
+ const delta = (point, radius = 2) => meanLuma(lit, point, radius) - meanLuma(off, point, radius);
+ const doorMetrics = doors.map((door) => {
+ const beyond = door.scan.filter(({ point }) => delta(point, 1) >= 3);
+ const visibleWidth = beyond.length
+ ? beyond[beyond.length - 1].offset - beyond[0].offset : 0;
+ // Median, not mean: the door symbol itself draws a dark line across the
+ // aperture and would otherwise decide the verdict.
+ const tunnelDeltas = door.tunnel
+ .map((point) => delta(point, 1))
+ .sort((left, right) => left - right);
+ return {
+ id: door.id, delta: delta(door.sample), visibleWidth,
+ openingLength: door.openingLength,
+ tunnelDelta: tunnelDeltas[Math.floor(tunnelDeltas.length / 2)],
+ };
+ });
+ let shadowMetrics = null;
+ if (shadow) {
+ const inside = Math.max(...shadow.inside.map((point) => delta(point, 1)));
+ const beside = Math.min(...shadow.beside.map((point) => delta(point, 1)));
+ // Width of the lit → unlit transition, in stage pixels. A geometric edge
+ // crosses it in one or two antialiased pixels; a Gaussian took tens.
+ const profile = shadow.edge.map(({ offset, point }) => ({ offset, value: delta(point, 0) }));
+ const high = Math.max(...profile.map((sample) => sample.value));
+ const low = Math.min(...profile.map((sample) => sample.value));
+ const band = profile.filter((sample) =>
+ sample.value > low + (high - low) * 0.2 && sample.value < low + (high - low) * 0.8);
+ shadowMetrics = {
+ inside, beside, high, low,
+ edgeWidth: band.length
+ ? Math.abs(band[band.length - 1].offset - band[0].offset) : 0,
+ };
+ }
+ return { dimensionsMatch: true, shadow: shadowMetrics, doors: doorMetrics };
+ }, {
+ on64: lightOn.toString('base64'),
+ off64: lightOff.toString('base64'),
+ doors: rasterFixture.doors,
+ shadow: rasterFixture.shadow,
+ });
+ console.log('Glow raster metrics:', JSON.stringify(metrics));
+ res.litPartsMatchClip = rasterFixture.litParts;
+ res.doorwayCarriesLight = metrics.doors.length === 2
+ && metrics.doors.every((door) => door.delta >= 8);
+ res.beamWidthBounded = metrics.doors.length === 2
+ && metrics.doors.every((door) => door.visibleWidth <= door.openingLength * 2);
+ res.apertureItselfLit = metrics.doors.length === 2
+ && metrics.doors.every((door) => door.tunnelDelta >= 8);
+ // An opaque body leaves no light behind it, keeps the floor beside it lit,
+ // and the border between the two is a line rather than a soft ramp.
+ res.occluderCastsShadow = !!metrics.shadow
+ && metrics.shadow.inside <= 3 && metrics.shadow.beside >= 8;
+ res.shadowEdgeIsCrisp = !!metrics.shadow
+ && metrics.shadow.high - metrics.shadow.low >= 8
+ && metrics.shadow.edgeWidth <= 4;
+} else {
+ res.litPartsMatchClip = false;
+ res.doorwayCarriesLight = false;
+ res.beamWidthBounded = false;
+ res.apertureItselfLit = false;
+ res.occluderCastsShadow = false;
+ res.shadowEdgeIsCrisp = false;
+}
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"spots": 1,
diff --git a/demo/smoke_glow_blending.mjs b/demo/smoke_glow_blending.mjs
index e29f8fee..b0365f52 100644
--- a/demo/smoke_glow_blending.mjs
+++ b/demo/smoke_glow_blending.mjs
@@ -80,6 +80,9 @@ try {
const poolFrame = card.renderRoot.querySelector('.glow-pools-frame');
const follows = (left, right) => !!left && !!right
&& !!(left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING);
+ // Each source is limited to the floor its own lamp can see; that region is
+ // a luminance mask on the inner group, so the spot itself stays a single
+ // screen-blended primitive.
const clippedPools = [...card.renderRoot.querySelectorAll('.glow-pool')]
.filter((pool) => pool.hasAttribute('clip-path')).length;
diff --git a/demo/smoke_openwall.mjs b/demo/smoke_openwall.mjs
index b914a93c..f4f8ed6f 100644
--- a/demo/smoke_openwall.mjs
+++ b/demo/smoke_openwall.mjs
@@ -51,16 +51,47 @@ const res = await page.evaluate(async () => {
c._boundaryClick([550, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
- c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
- ...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
+ // 9 m of reach: the question here is whether light CROSSES the virtual
+ // boundary, so the lamp must have enough radius to arrive at the far room.
+ c._serverCfg = {
+ ...c._serverCfg,
+ settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 900 },
+ spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
+ ...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })),
+ };
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / 1000 } };
c.requestUpdate(); await c.updateComplete;
- const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
- out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
+ // A virtual boundary is simply not a wall: light crosses it, so the lit
+ // region of a lamp in r1 must reach past r1's own outline into r2. Counting
+ // clip children no longer says anything — there is one region, not one path
+ // per room.
+ // Every vertex of a visibility region sits ON a wall, so "is this floor
+ // lit?" has to be asked as a point-in-region test, not by looking at
+ // coordinates.
+ const litRings = () => [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
+ .flatMap((p) => p.getAttribute('d').split('M').filter(Boolean).map((sub) => {
+ const numbers = (sub.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
+ const ring = [];
+ for (let i = 0; i + 1 < numbers.length; i += 2) ring.push([numbers[i], numbers[i + 1]]);
+ return ring;
+ }));
+ const isLit = (point) => litRings().some((ring) => {
+ let inside = false;
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+ const [xi, yi] = ring[i]; const [xj, yj] = ring[j];
+ if ((yi > point[1]) !== (yj > point[1])
+ && point[0] < ((xj - xi) * (point[1] - yi)) / ((yj - yi) || 1e-12) + xi) inside = !inside;
+ }
+ return inside;
+ });
+ out.zoneClip = isLit(c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r2')));
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
+ // Just inside r3, in line with the gap that is about to be opened.
+ const probeR3 = [802, 480];
+ const beforeReachesR3 = isLit(probeR3);
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
@@ -71,8 +102,10 @@ const res = await page.evaluate(async () => {
c._boundaryClick([900, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
- const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
- out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
+ // Two hops: through the r1|r2 boundary, then through the r2|r3 one. Only
+ // the part of r3 that the lamp can actually see through both gaps lights
+ // up — but that part must, and none of it did before.
+ out.transitive = !beforeReachesR3 && isLit(probeR3);
} else out.transitive = 'no r3';
// outer wall refuses open
c._setMode('plan'); c._tool = 'boundary';
diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js
index eb1264c2..6655bb0d 100755
--- a/demo/srv/assets/houseplan-card.js
+++ b/demo/srv/assets/houseplan-card.js
@@ -1,7 +1,7 @@
-globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc04a319d946937a0d7fb1f2df149fb0fe5164d616bf18f84cac66f0f69e6890";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let o=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const n=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new o(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:p}=Object,u=globalThis,_=u.trustedTypes,m=_?_.emptyScript:"",g=u.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),u.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:o}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const n=s?.call(this);o?.call(this,e),this.requestUpdate(t,n,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=p(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),o=t.litNonce;void 0!==o&&s.setAttribute("nonce",o),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const o=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==o?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),o="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const n=o.fromAttribute(e,t.type);this[s]=n??this._$Ej?.get(s)??n,this._$Em=null}}requestUpdate(t,e,i,s=!1,o){if(void 0!==t){const n=this.constructor;if(!1===s&&(o=this[t]),i??=n.getPropertyOptions(t),!((i.hasChanged??y)(o,e)||i.useDefault&&i.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(n._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:o},n){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,n??e??this[t]),!0!==o||void 0!==n)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(u.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),z=t=>null===t||"object"!=typeof t&&"function"!=typeof t,A=Array.isArray,I="[ \t\n\f\r]",E=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,N=/-->/g,F=/>/g,O=RegExp(`>|${I}(?:([^\\s"'>=/]+)(${I}*=${I}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,U=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=U(1),B=U(2),G=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!A(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const J=(t,e)=>{const i=t.length-1,s=[];let o,n=2===e?"
":3===e?"":"",r=E;for(let e=0;e"===l[0]?(r=o??E,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?O:'"'===l[3]?H:L):r===H||r===L?r=O:r===N||r===F?r=E:(r=O,o=void 0);const d=r===O&&t[e+1].startsWith("/>")?" ":"";n+=r===E?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,n+(t[i]||">")+(2===e?" ":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let o=0,n=0;const r=t.length-1,a=this.parts,[l,c]=J(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length
0){s.textContent=x?x.emptyScript:"";for(let i=0;iA(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==W&&z(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){A(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const o of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(o),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=W}_$AI(t,e=this,i,s){const o=this.strings;let n=!1;if(void 0===o)t=X(this,t,e,0),n=!z(t)||t!==this._$AH&&t!==G,n&&(this._$AH=t);else{const s=t;let r,a;for(t=o[0],r=0;r{const s=i?.renderBefore??e;let o=s._$litPart$;if(void 0===o){const t=i?.renderBefore??null;s._$litPart$=o=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return o._$AI(t),o})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return G}}lt._$litElement$=!0,lt.finalized=!0,at.litElementHydrateSupport?.({LitElement:lt});const ct=at.litElementPolyfillSupport;ct?.({LitElement:lt}),(at.litElementVersions??=[]).push("4.2.2");const ht=new WeakMap;let dt=0;class pt extends lt{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._titleId="hp-dialog-title-"+ ++dt,this._focusInitial=()=>{const t=this.querySelector("[autofocus]")||this._focusableElements()[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");t?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key)return t.preventDefault(),t.stopImmediatePropagation(),void this._requestClose();if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),o=i[0],n=i[i.length-1];!t.shiftKey||s!==o&&i.includes(s)?t.shiftKey||s!==n||(t.preventDefault(),o.focus()):(t.preventDefault(),n.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=ht.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),ht.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=this._focusRoot,e=this._opener;this._opener=null,this._focusRoot=null;const i=t?ht.get(t):void 0;i?.dialogs.delete(this),super.disconnectedCallback(),t&&i&&requestAnimationFrame(()=>{const i=ht.get(t);if(!i)return;if(!i.dialogs.size)return i.opener?.isConnected&&i.opener.focus({preventScroll:!0}),void ht.delete(t);const s=e?.closest("hp-dialog");e?.isConnected&&s&&i.dialogs.has(s)&&e.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(",");return Array.from(this.querySelectorAll(t)).filter(t=>{const e=getComputedStyle(t);return!t.hidden&&"none"!==e.display&&"hidden"!==e.visibility})}render(){const t=j`
- ${this.icon?j` `:W}
+globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="258d82cce98d0b96a77008ecb76a90516f96fbc26ab50eb69b49466b3c7bab33";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let o=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const n=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new o(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:p}=Object,u=globalThis,_=u.trustedTypes,m=_?_.emptyScript:"",g=u.reactiveElementPolyfillSupport,f=(t,e)=>t,v={toAttribute(t,e){switch(e){case Boolean:t=t?m:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!a(t,e),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:y};Symbol.metadata??=Symbol("metadata"),u.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:o}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const n=s?.call(this);o?.call(this,e),this.requestUpdate(t,n,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=p(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...d(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),o=t.litNonce;void 0!==o&&s.setAttribute("nonce",o),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const o=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==o?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),o="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const n=o.fromAttribute(e,t.type);this[s]=n??this._$Ej?.get(s)??n,this._$Em=null}}requestUpdate(t,e,i,s=!1,o){if(void 0!==t){const n=this.constructor;if(!1===s&&(o=this[t]),i??=n.getPropertyOptions(t),!((i.hasChanged??y)(o,e)||i.useDefault&&i.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(n._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:o},n){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,n??e??this[t]),!0!==o||void 0!==n)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,g?.({ReactiveElement:w}),(u.reactiveElementVersions??=[]).push("2.1.2");const k=globalThis,$=t=>t,x=k.trustedTypes,S=x?x.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,P=document,R=()=>P.createComment(""),A=t=>null===t||"object"!=typeof t&&"function"!=typeof t,z=Array.isArray,I="[ \t\n\f\r]",E=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,N=/-->/g,F=/>/g,O=RegExp(`>|${I}(?:([^\\s"'>=/]+)(${I}*=${I}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,U=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),B=U(1),j=U(2),G=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!z(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const J=(t,e)=>{const i=t.length-1,s=[];let o,n=2===e?"":3===e?"":"",r=E;for(let e=0;e"===l[0]?(r=o??E,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?O:'"'===l[3]?H:L):r===H||r===L?r=O:r===N||r===F?r=E:(r=O,o=void 0);const d=r===O&&t[e+1].startsWith("/>")?" ":"";n+=r===E?i+T:c>=0?(s.push(a),i.slice(0,c)+M+i.slice(c)+C+d):i+C+(-2===c?e:d)}return[Y(t,n+(t[i]||">")+(2===e?" ":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let o=0,n=0;const r=t.length-1,a=this.parts,[l,c]=J(t,e);if(this.el=Z.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=x?x.emptyScript:"";for(let i=0;iz(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==W&&A(this._$AH)?this._$AA.nextSibling.data=t:this.T(P.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(Y(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new Z(t)),e}k(t){z(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const o of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(o),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=W}_$AI(t,e=this,i,s){const o=this.strings;let n=!1;if(void 0===o)t=X(this,t,e,0),n=!A(t)||t!==this._$AH&&t!==G,n&&(this._$AH=t);else{const s=t;let r,a;for(t=o[0],r=0;r{const s=i?.renderBefore??e;let o=s._$litPart$;if(void 0===o){const t=i?.renderBefore??null;s._$litPart$=o=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return o._$AI(t),o})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return G}};ct._$litElement$=!0,ct.finalized=!0,lt.litElementHydrateSupport?.({LitElement:ct});const ht=lt.litElementPolyfillSupport;ht?.({LitElement:ct}),(lt.litElementVersions??=[]).push("4.2.2");const dt=2;let pt=class{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}};const{I:ut}=rt,_t=t=>t,mt=()=>document.createComment(""),gt=(t,e,i)=>{const s=t._$AA.parentNode,o=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(mt(),o),n=s.insertBefore(mt(),o);i=new ut(e,n,t,t.options)}else{const e=i._$AB.nextSibling,n=i._$AM,r=n!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==n._$AU&&i._$AP(e)}if(e!==o||r){let t=i._$AA;for(;t!==e;){const e=_t(t).nextSibling;_t(s).insertBefore(t,o),t=e}}}return i},ft=(t,e,i=t)=>(t._$AI(e,i),t),vt={},yt=(t,e=vt)=>t._$AH=e,bt=t=>{t._$AR(),t._$AA.remove()},wt=(t,e,i)=>{const s=new Map;for(let o=e;o<=i;o++)s.set(t[o],o);return s},kt=(t=>(...e)=>({_$litDirective$:t,values:e}))(class extends pt{constructor(t){if(super(t),t.type!==dt)throw Error("repeat() can only be used in text expressions")}dt(t,e,i){let s;void 0===i?i=e:void 0!==e&&(s=e);const o=[],n=[];let r=0;for(const e of t)o[r]=s?s(e,r):r,n[r]=i(e,r),r++;return{values:n,keys:o}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const o=(t=>t._$AH)(t),{values:n,keys:r}=this.dt(e,i,s);if(!Array.isArray(o))return this.ut=r,n;const a=this.ut??=[],l=[];let c,h,d=0,p=o.length-1,u=0,_=n.length-1;for(;d<=p&&u<=_;)if(null===o[d])d++;else if(null===o[p])p--;else if(a[d]===r[u])l[u]=ft(o[d],n[u]),d++,u++;else if(a[p]===r[_])l[_]=ft(o[p],n[_]),p--,_--;else if(a[d]===r[_])l[_]=ft(o[d],n[_]),gt(t,l[_+1],o[d]),d++,_--;else if(a[p]===r[u])l[u]=ft(o[p],n[u]),gt(t,o[d],o[p]),p--,u++;else if(void 0===c&&(c=wt(r,u,_),h=wt(a,d,p)),c.has(a[d]))if(c.has(a[p])){const e=h.get(r[u]),i=void 0!==e?o[e]:null;if(null===i){const e=gt(t,o[d]);ft(e,n[u]),l[u]=e}else l[u]=ft(i,n[u]),gt(t,o[d],i),o[e]=null;u++}else bt(o[p]),p--;else bt(o[d]),d++;for(;u<=_;){const e=gt(t,l[_+1]);ft(e,n[u]),l[u++]=e}for(;d<=p;){const t=o[d++];null!==t&&bt(t)}return this.ut=r,yt(t,l),G}}),$t=new WeakMap;let xt=0;class St extends ct{constructor(){super(...arguments),this.title="",this.icon="",this.wide=!1,this.dismissOnScrim=!1,this.hass=null,this._opener=null,this._focusRoot=null,this._useHaDialog=!1,this._closing=!1,this._titleId="hp-dialog-title-"+ ++xt,this._focusInitial=()=>{const t=this.querySelector("[autofocus]")||this._focusableElements()[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");t?.focus({preventScroll:!0})},this._requestClose=()=>{this._closing||(this._closing=!0,this.dispatchEvent(new CustomEvent("hp-close",{bubbles:!0,composed:!0})))},this._onKeyDown=t=>{if("Escape"===t.key)return t.preventDefault(),t.stopImmediatePropagation(),void this._requestClose();if("Tab"!==t.key||this._useHaDialog)return;const e=this.renderRoot.querySelector(".close"),i=e?[e,...this._focusableElements()]:this._focusableElements();if(!i.length)return t.preventDefault(),void this.renderRoot.querySelector(".surface")?.focus({preventScroll:!0});const s=this._deepActiveElement(),o=i[0],n=i[i.length-1];!t.shiftKey||s!==o&&i.includes(s)?t.shiftKey||s!==n||(t.preventDefault(),o.focus()):(t.preventDefault(),n.focus())},this._onFallbackCancel=t=>{t.preventDefault(),this._requestClose()},this._onFallbackClick=t=>{this.dismissOnScrim&&t.target===t.currentTarget&&this._requestClose()}}connectedCallback(){super.connectedCallback(),this._opener=this._deepActiveElement();const t=this.getRootNode();this._focusRoot=t;const e=$t.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),$t.set(t,e),this._useHaDialog=!!customElements.get("ha-dialog"),this.addEventListener("keydown",this._onKeyDown,!0)}disconnectedCallback(){this.removeEventListener("keydown",this._onKeyDown,!0);const t=this._focusRoot,e=this._opener;this._opener=null,this._focusRoot=null;const i=t?$t.get(t):void 0;i?.dialogs.delete(this),super.disconnectedCallback(),t&&i&&requestAnimationFrame(()=>{const i=$t.get(t);if(!i)return;if(!i.dialogs.size)return i.opener?.isConnected&&i.opener.focus({preventScroll:!0}),void $t.delete(t);const s=e?.closest("hp-dialog");e?.isConnected&&s&&i.dialogs.has(s)&&e.focus({preventScroll:!0})})}firstUpdated(t){if(super.firstUpdated(t),!this._useHaDialog){const t=this.renderRoot.querySelector("dialog");t&&!t.open&&t.showModal()}queueMicrotask(()=>this._focusInitial())}_deepActiveElement(){let t=document.activeElement;for(;t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t&&t!==document.body?t:null}_focusableElements(){const t=["[autofocus]","a[href]","button:not([disabled])",'input:not([disabled]):not([type="hidden"])',"select:not([disabled])","textarea:not([disabled])",'[contenteditable="true"]','[tabindex]:not([tabindex="-1"])'].join(",");return Array.from(this.querySelectorAll(t)).filter(t=>{const e=getComputedStyle(t);return!t.hidden&&"none"!==e.display&&"hidden"!==e.visibility})}render(){const t=B`
+ ${this.icon?B` `:W}
${this.title}
- `;return this._useHaDialog?j``;return this._useHaDialog?B`${t}
- `:j``:B`
- `}}pt.properties={title:{type:String},icon:{type:String},wide:{type:Boolean,reflect:!0},dismissOnScrim:{type:Boolean,attribute:"dismiss-on-scrim"},hass:{attribute:!1}},pt.styles=n`
+ `}}St.properties={title:{type:String},icon:{type:String},wide:{type:Boolean,reflect:!0},dismissOnScrim:{type:Boolean,attribute:"dismiss-on-scrim"},hass:{attribute:!1}},St.styles=n`
:host {
display: contents;
color: var(--primary-text-color, #e6e7eb);
@@ -43,6 +43,13 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc04a319d946937a0d7fb1f2df149fb0fe51
--dialog-content-padding: 0;
--dialog-surface-background: var(--card-background-color, var(--hp-bg, #202126));
--ha-dialog-border-radius: var(--rad-l, 18px);
+ /* HA's ha-dialog-header defaults this custom property to a one-line
+ fixed height. Our localized slot is intentionally allowed to wrap,
+ so leaving that default in place clips every line after the first at
+ the bottom of the header. auto is HA's public sizing hook and also
+ stays harmless on older ha-dialog implementations that do not consume
+ it. */
+ --ha-dialog-header-title-height: auto;
color: inherit;
}
@@ -184,21 +191,21 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="fc04a319d946937a0d7fb1f2df149fb0fe51
display: flex;
flex-direction: column;
}
- `,customElements.get("hp-dialog")||customElements.define("hp-dialog",pt);const ut=/^#[0-9a-fA-F]{6}$/;function _t(t,e){return"string"==typeof t&&ut.test(t)?t:e}const mt="(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])",gt=new RegExp(`^rgb\\(${mt}, ${mt}, ${mt}\\)$`);function ft(t){if(!Array.isArray(t)||t.length<3)return null;const e=t.slice(0,3);if(!e.every(t=>"number"==typeof t&&Number.isFinite(t)))return null;const[i,s,o]=e.map(t=>Math.round(Math.min(255,Math.max(0,t))));return`rgb(${i}, ${s}, ${o})`}class vt extends lt{constructor(){super(...arguments),this.label="",this.opacityLabel="Opacity",this.color="#607d8b",this.opacity=1,this.disabled=!1,this.showOpacity=!0,this._open=!1,this._pickerRaf=0,this._outsidePointerDown=t=>{this._open&&!t.composedPath().includes(this)&&this._closePicker()},this._keyDown=t=>{this._open&&"Escape"===t.key&&(t.preventDefault(),t.stopImmediatePropagation(),this._closePicker(!0))},this._queuePickerPosition=()=>{this._open&&(this._pickerRaf&&cancelAnimationFrame(this._pickerRaf),this._pickerRaf=requestAnimationFrame(()=>{this._pickerRaf=0,this._positionPicker()}))}}connectedCallback(){super.connectedCallback(),document.addEventListener("pointerdown",this._outsidePointerDown,!0),document.addEventListener("scroll",this._queuePickerPosition,!0),window.addEventListener("resize",this._queuePickerPosition),this.addEventListener("keydown",this._keyDown,!0)}disconnectedCallback(){document.removeEventListener("pointerdown",this._outsidePointerDown,!0),document.removeEventListener("scroll",this._queuePickerPosition,!0),window.removeEventListener("resize",this._queuePickerPosition),this.removeEventListener("keydown",this._keyDown,!0),this._pickerRaf&&cancelAnimationFrame(this._pickerRaf),this._pickerRaf=0,super.disconnectedCallback()}_toggle(){this.disabled||(this._open?this._closePicker():(this._open=!0,this.updateComplete.then(()=>this._positionPicker())))}_closePicker(t=!1){const e=this.renderRoot.querySelector(".picker");if(e?.hidePopover)try{e.matches(":popover-open")&&e.hidePopover()}catch{}this._open=!1,t&&this.updateComplete.then(()=>this.renderRoot.querySelector(".trigger")?.focus())}_positionPicker(){if(!this._open)return;const t=this.renderRoot.querySelector(".trigger"),e=this.renderRoot.querySelector(".picker");if(!t||!e)return;if(e.style.visibility="hidden",e.showPopover)try{e.matches(":popover-open")||e.showPopover()}catch{}const i=t.getBoundingClientRect(),s=e.getBoundingClientRect(),o=Math.max(8,window.innerWidth-s.width-8);let n=i.left;n+s.width>window.innerWidth-8&&(n=i.right-s.width),n=Math.min(o,Math.max(8,n));const r=i.bottom+7,a=i.top-7-s.height;let l=r;l=r+s.height>window.innerHeight-8&&a>=8?a:Math.min(Math.max(8,l),Math.max(8,window.innerHeight-s.height-8)),e.style.left=`${Math.round(n)}px`,e.style.top=`${Math.round(l)}px`,e.style.visibility=""}_emit(t,e){const i=Number.isFinite(e)?e:Number(this.opacity)||0,s=Math.min(1,Math.max(0,i));this.color=t,this.opacity=s,this.dispatchEvent(new CustomEvent("hp-color-opacity-change",{detail:{color:t,opacity:s},bubbles:!0,composed:!0}))}render(){const t=Math.round(100*Math.min(1,Math.max(0,Number(this.opacity)||0))),e=_t(this.color,"#607d8b"),i=this.showOpacity?`${this.label||"Color"}: ${e}, ${t}%`:`${this.label||"Color"}: ${e}`;return j`
- ${this.label?j`
`:W}
+ `,customElements.get("hp-dialog")||customElements.define("hp-dialog",St);const Mt=/^#[0-9a-fA-F]{6}$/;function Ct(t,e){return"string"==typeof t&&Mt.test(t)?t:e}const Dt="(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])",Tt=new RegExp(`^rgb\\(${Dt}, ${Dt}, ${Dt}\\)$`);function Pt(t){if(!Array.isArray(t)||t.length<3)return null;const e=t.slice(0,3);if(!e.every(t=>"number"==typeof t&&Number.isFinite(t)))return null;const[i,s,o]=e.map(t=>Math.round(Math.min(255,Math.max(0,t))));return`rgb(${i}, ${s}, ${o})`}class Rt extends ct{constructor(){super(...arguments),this.label="",this.opacityLabel="Opacity",this.color="#607d8b",this.opacity=1,this.disabled=!1,this.showOpacity=!0,this._open=!1,this._pickerRaf=0,this._outsidePointerDown=t=>{this._open&&!t.composedPath().includes(this)&&this._closePicker()},this._keyDown=t=>{this._open&&"Escape"===t.key&&(t.preventDefault(),t.stopImmediatePropagation(),this._closePicker(!0))},this._queuePickerPosition=()=>{this._open&&(this._pickerRaf&&cancelAnimationFrame(this._pickerRaf),this._pickerRaf=requestAnimationFrame(()=>{this._pickerRaf=0,this._positionPicker()}))}}connectedCallback(){super.connectedCallback(),document.addEventListener("pointerdown",this._outsidePointerDown,!0),document.addEventListener("scroll",this._queuePickerPosition,!0),window.addEventListener("resize",this._queuePickerPosition),this.addEventListener("keydown",this._keyDown,!0)}disconnectedCallback(){document.removeEventListener("pointerdown",this._outsidePointerDown,!0),document.removeEventListener("scroll",this._queuePickerPosition,!0),window.removeEventListener("resize",this._queuePickerPosition),this.removeEventListener("keydown",this._keyDown,!0),this._pickerRaf&&cancelAnimationFrame(this._pickerRaf),this._pickerRaf=0,super.disconnectedCallback()}_toggle(){this.disabled||(this._open?this._closePicker():(this._open=!0,this.updateComplete.then(()=>this._positionPicker())))}_closePicker(t=!1){const e=this.renderRoot.querySelector(".picker");if(e?.hidePopover)try{e.matches(":popover-open")&&e.hidePopover()}catch{}this._open=!1,t&&this.updateComplete.then(()=>this.renderRoot.querySelector(".trigger")?.focus())}_positionPicker(){if(!this._open)return;const t=this.renderRoot.querySelector(".trigger"),e=this.renderRoot.querySelector(".picker");if(!t||!e)return;if(e.style.visibility="hidden",e.showPopover)try{e.matches(":popover-open")||e.showPopover()}catch{}const i=t.getBoundingClientRect(),s=e.getBoundingClientRect(),o=Math.max(8,window.innerWidth-s.width-8);let n=i.left;n+s.width>window.innerWidth-8&&(n=i.right-s.width),n=Math.min(o,Math.max(8,n));const r=i.bottom+7,a=i.top-7-s.height;let l=r;l=r+s.height>window.innerHeight-8&&a>=8?a:Math.min(Math.max(8,l),Math.max(8,window.innerHeight-s.height-8)),e.style.left=`${Math.round(n)}px`,e.style.top=`${Math.round(l)}px`,e.style.visibility=""}_emit(t,e){const i=Number.isFinite(e)?e:Number(this.opacity)||0,s=Math.min(1,Math.max(0,i));this.color=t,this.opacity=s,this.dispatchEvent(new CustomEvent("hp-color-opacity-change",{detail:{color:t,opacity:s},bubbles:!0,composed:!0}))}render(){const t=Math.round(100*Math.min(1,Math.max(0,Number(this.opacity)||0))),e=Ct(this.color,"#607d8b"),i=this.showOpacity?`${this.label||"Color"}: ${e}, ${t}%`:`${this.label||"Color"}: ${e}`;return B`
+ ${this.label?B`
- ${this._open&&!this.disabled?j`
+ ${this._open&&!this.disabled?B`