diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index e00d309f..fa951deb 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -15,6 +15,15 @@ concurrency:
cancel-in-progress: true
jobs:
+ docs:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with: { node-version: 22 }
+ - name: Validate public documentation
+ run: npm run docs:check -- --external
+
provenance:
runs-on: ubuntu-latest
steps:
diff --git a/README.md b/README.md
index 996dfb19..dca9d594 100755
--- a/README.md
+++ b/README.md
@@ -1,379 +1,157 @@
-# 🏠 House Plan — interactive floor plan card for Home Assistant
+# 🏠 House Plan — a live home map for Home Assistant
[](https://github.com/hacs/integration)
[](https://github.com/Matysh/houseplan-card/releases)
-[](https://github.com/Matysh/houseplan-card/stargazers)
[](https://github.com/Matysh/houseplan-card/actions)
-[](LICENSE)
[](https://demo.houseplan.tech)
[](https://t.me/ha_houseplan)
-**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
-a floor plan, outline the rooms with your mouse — and every smart device appears
-in its real place: live states, tap-to-toggle lights, temperature and humidity per
-room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
-tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
-right on your Lovelace dashboard.
+📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
-> **Use a desktop computer to edit plans.** View and kiosk are fully supported
-> on phones and tablets. The editors are designed primarily for a desktop
-> browser with a mouse and keyboard; individual editing operations on touch
-> devices may be awkward, limited, or unavailable.
+
-
+## Your whole home at a glance
-> ### 🚀 Try it live — no install needed
-> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
-> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
-> toggle lights, open the editors, break things. The stand resets itself to a
-> pristine state every hour.
+House Plan turns Home Assistant into a live map of your home. Upload a plan or
+draw rooms directly on the dashboard, bind them to Home Assistant areas, and
+the area's devices appear automatically. You can immediately see where a light
+is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
+sensor has fired.
-🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
+
-**Feature highlights**
+Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
+Plan data and device positions live on the Home Assistant server and stay in
+sync across screens.
-- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
- past: draw and place devices anywhere, pan at any zoom, zoom out to see
- everything, and let one tap fit the whole plan back on screen.
-- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
- virtual walls and a visual decor layer, all drawn with clicks; room resize
- by dragging walls, with live lengths and areas as you drag; smart
- alignment guides and a live ruler in real meters/feet.
-- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
- place and pull a corner to size it, with its real size in metres shown as
- you drag, so the drawing and the photo of your plan finally line up.
-- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
- whole groups of lights (works for dumb switches and stateless remotes too).
-- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
- the floor it can see: through doorways and open boundaries, stopped by walls,
- columns and partitions, which cast real shadows.
-- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
- the day (white noon → golden hour → deep night), while windows on exterior
- walls cast real wedges of sunlight into the rooms; optional cloud cover
- from a weather entity.
-- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
- stops a cover, and the icon itself morphs between open and closed while a
- soft ring pulses as it travels.
-- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
- comfort-range temperature fills, per-room signal heatmap.
-- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
- explicit button, never an accidental tap.
-- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
- auto-carousel, per-screen icon sizes.
-- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
- drives the plan in real time, pouring its path out from under itself;
- current and previous cleanup runs are recorded server-side. Calibration is
- one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
- source picker also covers registry-less map cameras without silently
- rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
- dreame-vacuum and Valetudo.
-- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
- **server-side** — one shared plan for every user and screen, synced live.
+> **Edit on a desktop computer.** View and kiosk are fully supported on phones
+> and tablets. The editors are designed primarily for a mouse and keyboard;
+> individual touch editing operations may be awkward or unavailable. See the
+> exact [touch support contract](docs/TOUCH-SUPPORT.md).
----
+
-## What it is and why
+## What House Plan provides
-House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
+- **Live state and safe actions.** Lights and other safe devices can toggle from
+ the plan; a lock cannot be opened by an accidental plan tap.
+- **Three built-in editors.** Plan creates rooms, walls and openings; Device
+ places and configures markers; Background adds lines, labels and furniture.
+- **Area-aware rooms.** New devices appear automatically, while room cards can
+ show temperature, humidity, light state and average LQI.
+- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
+ backdrop and sunlight through windows.
+- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
+ a robot can show its position, dock and travelled path.
+- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
+ and a separate initial floor for each card.
+- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
+ sizes saved for that display.
-This is convenient when:
+
-- you have many devices and lists are awkward to use;
-- you need to grasp the state of the house "at a glance";
-- you want to give access to family members — anyone can figure out a picture;
-- you want a beautiful overview screen for a wall-mounted tablet.
+
-The integration consists of two parts that are installed together:
+## Your first working room
-- **the Lovelace card** `houseplan-card` — the interactive plan itself;
-- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
+1. Install the integration and add the card to a dashboard.
+2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
+ or choose no image and draw the plan by hand.
+3. In Plan, select **Room outline**, place vertices, and click the first point to
+ close the outline.
+4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
+ that has no devices.
+5. Open Device: devices from the bound area are already placed; drag their
+ markers to the correct positions.
+6. Optionally use Background for lines, text and furniture.
+7. Return to View. The plan now displays live state and accepts safe actions.
----
+
-## How it differs from alternatives
+
-A house plan in Home Assistant is usually built with `picture-elements`,
-`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
-dashboard. Those either lock you into YAML/SVG, or store the plan in the
-Lovelace card config. House Plan is a **shared live map** backed by a Home
-Assistant integration:
+
-| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
-|---|---|---|---|
-| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
-| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
-| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
-| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
-| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
-| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
-| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
+
-**One sentence:** House Plan is the shared, area-aware live map of your home —
-not a general-purpose CAD package. Its Background editor covers practical
-decor, labels and furniture; if you need unrestricted architectural drafting,
-a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
-every tablet on the same live layout.
+
-Key advantages in short:
+Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
+The [Background editor contract](docs/DECOR-EDITOR.md) and
+[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
-- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
-- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
-- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
-- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
- Icon colors follow one principle — **yellow means the device is doing its main job right now**:
- a light is shining, a socket is powering, a fan is spinning, a vacuum is
- cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
- a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
- that mode is the best available fallback. Orange = open / unlocked.
- A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
- spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
- A translucent icon = unavailable. Dark = idle.
-- **A coherent visual Background editor.** Draw lines/shapes, place labels and
- furniture, edit physical styles and transform every object with the same
- selection model. The plan image has its own move/resize/rotate tool, numeric
- properties and shared Undo/Redo.
-- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
-
----
-
-## Wall tablet / TV (kiosk mode)
-
-Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
-(or tick "Wall device (kiosk) mode" in the card editor):
-
-```yaml
-type: custom:houseplan-card
-kiosk: true
-cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
-```
-
-No header, no editors — just the live plan. Swipe to change floors (at 1:1),
-pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
-tune icon and text sizes for THIS screen (saved per device). To hide Home
-Assistant's own header use the companion app's kiosk settings or the
-[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
+
## Installation
-One click if you already run HACS:
+### HACS
-[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
+[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
+1. In HACS open **⋮ → Custom repositories**.
+2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
+3. Install House Plan and restart Home Assistant.
+4. Open **Settings → Devices & services → Add integration → House Plan**.
-### Via HACS (recommended)
+The card is registered automatically. If you manage Lovelace resources
+manually, use the URL served by the integration:
-1. Open **HACS → menu (⋮) → Custom repositories**.
-2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
-3. Find **House Plan** in the list, install it and **restart Home Assistant**.
-4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
+```yaml
+resources:
+ - url: /houseplan_files/houseplan-card.js
+ type: module
+```
-The card is registered automatically — no need to add a Lovelace resource manually.
+Do not use the on-disk path inside `custom_components`; Home Assistant does not
+serve that path as a JavaScript module.
-> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
-> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
->
-> ```yaml
-> resources:
-> - url: /houseplan_files/houseplan-card.js
-> type: module
-> ```
->
-> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
-> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
-> and the element never registers). The correct, integration-served URL is
-> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
-> `houseplan-space-card`) ship in that one file — no separate resource is needed.
+### Manual installation
-### Manually
+Copy `custom_components/houseplan` to `config/custom_components`, restart Home
+Assistant, and add the House Plan integration.
-1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
-2. Restart Home Assistant.
-3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
+### Add the card
-### Adding a plan screen
-
-Create a new dashboard tab (a "Panel" view works best) and add the card:
+Create a dashboard view (Panel works best) and add the card in the UI or as:
```yaml
type: custom:houseplan-card
title: House plan
```
-Nothing else needs to be specified — everything else is configured right on the screen.
+Different screens may start on different spaces:
----
+```yaml
+type: custom:houseplan-card
+default_floor: ground
+```
-## How to use
+All cards share server-side rooms and coordinates. Current mode, viewport and
+selected space remain local to the screen. Revision checks and live sync cover
+concurrent clients, but avoid editing the same object in two browsers at once.
-### Step 1. Add a space (floor)
+## Detailed documentation
-On first open the plan is still empty — House Plan immediately offers to create the first space.
-If your Home Assistant already has **floors** configured, a wizard offers to create a space
-for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
+- [Full user guide](docs/USER-GUIDE.md)
+- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
+- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
+- [Background editor](docs/DECOR-EDITOR.md)
+- [Robot vacuums](docs/VACUUM.md)
+- [Touch support](docs/TOUCH-SUPPORT.md)
-
+
-In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
+## Support and feedback
-
+- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
+- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
+- Before reporting, update House Plan, restart HA and hard-refresh the page.
+ Include the version, browser, logs and reproduction steps; private entity IDs
+ may be replaced with fictional ones.
-> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
+Documentation screenshots are produced by the reproducible
+`npm run docs:capture` command using synthetic data only. Scenario version,
+source fingerprint and every image hash are recorded in the
+[screenshot manifest](docs/images/manifest.json).
-Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs.
-
-### Step 2. Outline the rooms
-
-After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
-
-As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
-
-
-
-While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
-
-Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
-
-- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
-- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
-
-
-### Doors, windows, gates and locks
-
-In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
-opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
-window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
-
-With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
-draws itself in as the real door opens; a window opens its two casements. While open, the moving
-parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
-leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
-passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
-locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
-on the opening shows a status card with both states instead.
-
-Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
-walls** (it slides around corners too), and a **double click opens its properties**.
-
-### Step 3. Devices appear by themselves
-
-As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
-
-By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
-
-From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
-
-
-
-### Step 4. Zoom
-
-The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
-
-
-
-### Step 5. Put the icons in their places
-
-Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
-
-
-
-### Tap actions: control devices from the plan
-
-By default a tap on an icon opens its info card. A device can instead use the
-universal **Toggle state** action. Its editor shows the exact entity or configured
-group, the current state and what the next tap will do; when nothing can be toggled,
-it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
-Lights keep their convenient toggle default. Covers and valves use open/close/stop
-semantics automatically, while locks, alarm panels and secure garage/door/gate covers
-remain blocked. An exact entity binding never falls through to a sibling switch, and
-temporarily unavailable group members are skipped without erasing the configuration.
-A **long press** still opens the info card and right-click still opens HA more-info.
-
-### Icon rules
-
-Which MDI icon a device gets is decided by **icon rules** — editable right in the card
-(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
-live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
-matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
-
-### Step 6. Adding your own devices manually
-
-You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
-
-
-Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
-
-To represent a dumb physical lamp controlled by a smart relay, place a virtual
-point where the lamp really is and set **Light source → Always**. Manual colour,
-brightness and radius stay available even though the point has no HA entity.
-Then open the relay and add that plan source under **Controls other light
-sources**. The relay continues to show the aggregate working state, while Glow,
-room fill and statistics belong to the lamp's position. An unlinked passive
-Always source is deliberately constant-on. With several own `light.*`/`switch.*`
-entities, Always also offers a leading-entity selector; a missing saved choice
-is warned about and retained while a deterministic fallback is used.
-
-To make that virtual lamp manually switchable without creating a Home
-Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
-This exact combination — virtual binding, **Light source → Always**, and
-**Toggle state** — stores a shared on/off state in the House Plan integration.
-It survives page reloads and Home Assistant restarts and updates Glow, room
-fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
-dashboard viewer may toggle it. While this manual mode is active, saved
-**Controls other light sources** remain intact but are not called; changing the
-role, binding or tap action restores their normal behaviour. This operational
-state is deliberately not part of plan exports or Home Assistant entities.
-
-The same dialog controls how the device looks on the plan. **Display** switches between the
-icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
-dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
-**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
-turned the way it is mounted.
-
-
-
-### Styling the plan with card-mod (advanced, unsupported)
-
-The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
-
----
-
-## Uninstalling
-
-1. Remove the card (or the tab with the plan) from the dashboard.
-2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
-3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
-4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
-
----
-
-## Getting help & sharing your plan
-
-- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
- setup help, feature ideas, and screenshots of your plans. The fastest way to
- reach the author and other users.
-- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
- reports and feature requests (please attach your House Plan version).
-- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
- longer-form ideas.
-- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
- ([на русском](docs/CHANGELOG.ru.md)).
-
-When reporting a problem, the version number helps a lot: it is shown in the
-browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
-Services → House Plan**.
-
----
-
-## Frequently asked questions
-
-**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
-
-**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
-
-**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
-
-**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
-
----
-
-
Screenshots were taken on a real Home Assistant configuration.
+License: [MIT](LICENSE).
diff --git a/README.ru.md b/README.ru.md
index a7c6d200..9b232078 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -1,351 +1,161 @@
-# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
+# 🏠 House Plan — живой план дома для Home Assistant
[](https://github.com/hacs/integration)
[](https://github.com/Matysh/houseplan-card/releases)
-[](https://github.com/Matysh/houseplan-card/stargazers)
+[](https://github.com/Matysh/houseplan-card/actions)
[](https://demo.houseplan.tech)
[](https://t.me/ha_houseplan)
-📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
+📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
-**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
-нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
-своих местах: живые состояния, свет по клику, температура и влажность по
-комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
-киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
-редакторов — весь план настраивается прямо на дашборде.
+
-> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
-> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
-> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
-> операции могут быть менее удобны, работать ограниченно или отсутствовать.
+## Дом целиком — одним взглядом
-
+House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
+плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
+Assistant — и устройства появятся на плане автоматически. Сразу видно, где
+горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
+датчик протечки.
-> ### 🚀 Попробовать вживую — без установки
-> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
-> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
-> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
-> возвращается в исходное состояние.
+
-🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
+Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
+внешнего редактора плана. Данные плана и расположение устройств хранятся на
+сервере Home Assistant и синхронизируются между экранами.
-**Главное**
+> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
+> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
+> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
+> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
-- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
- нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
- любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
- план обратно в экран.
-- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
- виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
- перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
- линейка в реальных метрах.
-- 💡 **Свет переключается кликом** из коробки; значок выключателя может
- управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
-- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
- освещает ровно тот пол, который видит: через проёмы и открытые границы,
- а стены, колонны и перегородки его не пропускают и дают настоящие тени.
-- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
- (белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
- в комнаты настоящие клинья солнечного света; облачность — опционально, от
- weather-сущности.
-- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
- останавливает штору, а сам значок морфится между открытым и закрытым
- видом и мягко пульсирует кольцом, пока штора едет.
-- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
- температурная заливка по комфортным границам.
-- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
- никогда случайным тапом.
-- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
- этажами, автокарусель, свои размеры на каждом экране.
-- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
- шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
- текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
- (по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
- выбор источника поддерживают registry-less камеры карт и не подменяют молча
- сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
- (Tasshack) и Valetudo.
-- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
- хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
+
----
+## Что умеет House Plan
+- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
+ переключаются с плана; замок нельзя открыть случайным нажатием.
+- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
+ «Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
+ подписи и мебель.
+- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
+ а карточки комнат показывают температуру, влажность, свет и средний LQI.
+- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
+ солнечные лучи из окон.
+- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
+ робот показывает позицию, базу и пройденный путь.
+- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
+ локальный масштаб и отдельный стартовый этаж для каждой карточки.
+- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
+ значков, сохранённые отдельно для этого устройства.
-## Что это и зачем
+
-House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
+
-Это удобно, когда:
+## Первая рабочая комната
-- устройств много, и списками пользоваться неудобно;
-- нужно быстро понять состояние дома «одним взглядом»;
-- хочется отдать доступ близким — по картинке разберётся любой;
-- вы хотите красивый обзорный экран для настенного планшета.
+1. Установите интеграцию и добавьте карточку на дашборд.
+2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
+ вариант без изображения, чтобы нарисовать план вручную.
+3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
+ контур нажатием на первую точку.
+4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
+ устройств выберите «Без зоны».
+5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
+ перетащите маркеры в нужные места.
+6. При необходимости оформите подложку линиями, текстом и мебелью.
+7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
+ безопасные действия.
-Интеграция состоит из двух частей, которые ставятся вместе:
+
-- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
-- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
+
----
+
-## Чем отличается от аналогов
+
-Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
-или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
-YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
-план** на серверной интеграции Home Assistant:
+
-| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
-|---|---|---|---|
-| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
-| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
-| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
-| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
-| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
-| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
-| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
+Пошаговые сценарии, все инструменты и особые случаи описаны в
+[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
+зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
+[руководстве по пылесосам](docs/VACUUM.md).
-**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
-универсальная CAD-система. Редактор подложки покрывает практический декор,
-надписи и мебель; для свободного архитектурного черчения лучше отдельный
-draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
-на всех планшетах.
-
-Ключевые преимущества коротко:
-
-- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
-- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
-- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
-- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
- Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
- лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
- реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
- если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
- Оранжевый = открыто / не заперто. Пульсирующее красное
- кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
- где само пятно — индикатор включения, а подложка значка остаётся стандартной.
- Полупрозрачный значок = недоступно. Тёмный = покой.
-- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
-- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
-
----
-
-## Настенный планшет / ТВ (киоск-режим)
-
-Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
-галочка «Режим настенного устройства» в редакторе карточки):
-
-```yaml
-type: custom:houseplan-card
-kiosk: true
-cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
-```
-
-Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
-пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
-настройка размеров значков и текста для ЭТОГО экрана (хранится на
-устройстве). Шапку самого Home Assistant скрывают настройки companion-app
-или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
+
## Установка
-В один клик, если у вас уже есть HACS:
+### Через HACS
-[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
+[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
+1. В HACS откройте **⋮ → Пользовательские репозитории**.
+2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
+3. Установите House Plan и перезапустите Home Assistant.
+4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
-### Через HACS (рекомендуется)
+Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
+добавьте именно URL, который публикует интеграция:
-1. Откройте **HACS → меню (⋮) → Custom repositories**.
-2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
-3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
-4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
+```yaml
+resources:
+ - url: /houseplan_files/houseplan-card.js
+ type: module
+```
-Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
-
-> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
-> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
->
-> ```yaml
-> resources:
-> - url: /houseplan_files/houseplan-card.js
-> type: module
-> ```
->
-> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
-> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
-> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
-> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
-> не нужен.
+Не используйте путь к файлу внутри `custom_components`: Home Assistant не
+публикует его как JavaScript-модуль.
### Вручную
-1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
-2. Перезапустите Home Assistant.
-3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
+Скопируйте `custom_components/houseplan` в `config/custom_components`,
+перезапустите Home Assistant и добавьте интеграцию House Plan.
-### Добавление экрана с планом
+### Добавление карточки
-Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
+Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
```yaml
type: custom:houseplan-card
title: План дома
```
-Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
+Для нескольких экранов можно задать разные стартовые пространства:
----
+```yaml
+type: custom:houseplan-card
+default_floor: ground
+```
-## Как пользоваться
+Все карточки используют общие серверные комнаты и координаты. Текущий режим,
+масштаб и выбранное пространство локальны для экрана. Одновременное
+редактирование поддерживает синхронизацию и проверку ревизий, но один объект
+лучше не менять параллельно в двух браузерах.
-### Шаг 1. Добавьте пространство (этаж)
+## Где искать подробности
-При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
+- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
+- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
+- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
+- [Редактор подложки](docs/DECOR-EDITOR.md)
+- [Роботы-пылесосы](docs/VACUUM.md)
+- [Поддержка touch](docs/TOUCH-SUPPORT.md)
-Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
-пространство для каждого: названия подставятся сами, план попросит по очереди,
-любой этаж можно пропустить.
+
-
+## Помощь и обратная связь
-В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
+- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
+- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
+- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
+ обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
+ воспроизведения; приватные entity ID можно заменить вымышленными.
-
+Скриншоты в документации получены воспроизводимой командой
+`npm run docs:capture` только на синтетических данных. Версия сценариев,
+fingerprint исходников и хеш каждого изображения находятся в
+[манифесте снимков](docs/images/manifest.json).
-> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
-
-Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
-
-### Шаг 2. Обведите комнаты
-
-После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
-
-Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
-
-
-
-Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
-
-Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
-
-- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
-- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
-
-
-### Двери, окна, ворота и замки
-
-В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
-примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
-окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
-
-С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
-дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
-подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
-зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
-переключить — клик по проёму показывает карточку с обоими статусами.
-
-Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
-стен** (в том числе за угол), а **двойной клик открывает свойства**.
-
-### Шаг 3. Устройства появляются сами
-
-Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
-
-По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
-
-Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
-
-
-
-### Шаг 4. Масштаб
-
-Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
-
-
-
-### Шаг 5. Расставьте значки по местам
-
-Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
-
-
-
-### Управление с плана (tap actions)
-
-По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
-переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
-свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
-Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
-клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
-(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
-нажатие** всегда открывает инфо-карточку.
-
-### Правила иконок
-
-Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
-прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
-с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
-правило не подошло — решает *device class* сущности (термометр для датчиков
-температуры и т.п.).
-
-### Шаг 6. Добавление своих устройств вручную
-
-Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
-
-
-Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
-
-В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
-анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
-точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
-на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
-может быть маленьким и повёрнутым так, как он установлен.
-
-
-
-### Свои стили через card-mod (для продвинутых, без поддержки)
-
-Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
-
----
-
-## Удаление
-
-1. Уберите карточку (или вкладку с планом) из дашборда.
-2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
-3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
-4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
-
----
-
-## Помощь и обмен опытом
-
-- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
- помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
- связаться с автором и другими пользователями.
-- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
- и запросы фич (пожалуйста, указывайте версию House Plan).
-- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
- развёрнутых обсуждений.
-- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
-
-Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
-**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
-быстрее.
-
----
-
-## Часто задаваемые вопросы
-
-**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
-
-**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
-
-**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
-
-**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
-
----
-
-
Скриншоты сделаны на реальной конфигурации Home Assistant.
+Лицензия: [MIT](LICENSE).
diff --git a/custom_components/houseplan/frontend/houseplan-card.js b/custom_components/houseplan/frontend/houseplan-card.js
index eac6a210..52716caf 100755
--- a/custom_components/houseplan/frontend/houseplan-card.js
+++ b/custom_components/houseplan/frontend/houseplan-card.js
@@ -1,4 +1,4 @@
-globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1";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 n=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 o=(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 n(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 n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.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"),p.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:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(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"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),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 n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),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),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(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}),(p.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,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(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;iI(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!==G&&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){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=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 mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)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 n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{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._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.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){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):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(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.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=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.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._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.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(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j`
+globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a";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 n=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 o=(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 n(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 n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.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"),p.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:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(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"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),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 n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),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),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(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}),(p.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,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(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;iI(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!==G&&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){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=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 mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)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 n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{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._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.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){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):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(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.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=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.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._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.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(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j`
${this.icon?j``:G}
${this.title}`;return this._useHaDialog?j` createHash('sha256').update(value).digest('hex');
+
+export const DOC_SCREENSHOT_VERSION = 1;
+export const DOC_SCREENSHOTS = Object.freeze([
+ {
+ id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
+ space: 'golden-lighting', mode: 'view', roomMetrics: true,
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 900 }, capture: 'page',
+ },
+ {
+ id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
+ space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 390, height: 760 }, capture: 'page',
+ },
+ {
+ id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
+ title: 'House Plan', language: 'en', theme: 'dark',
+ viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
+ },
+ {
+ id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
+ space: 'golden-geometry', mode: 'plan',
+ wallJunctionPreview: {
+ path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
+ pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
+ },
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 900 }, capture: 'page',
+ },
+ {
+ id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
+ space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 900 }, capture: 'page',
+ },
+ {
+ id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
+ space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
+ deviceName: 'Living-room ceiling light',
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
+ },
+ {
+ id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
+ space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
+ deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
+ },
+ {
+ id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
+ space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 900 }, capture: 'page',
+ },
+ {
+ id: 'room-card', file: '08-room-card.png', fixture: 'visual',
+ space: 'golden-lighting', mode: 'view', roomMetrics: true,
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1180, height: 900 }, capture: 'room-card',
+ },
+ {
+ id: 'device-info', file: '09-device-info.png', fixture: 'visual',
+ space: 'golden-lighting', mode: 'view', dialog: 'device-info',
+ deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
+ title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
+ viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
+ },
+]);
+
+const roomCardClip = (page) => page.evaluate(() => {
+ const card = window.__goldenCard;
+ const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
+ const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
+ if (!target) throw new Error('documentation room card is missing');
+ const rect = target.getBoundingClientRect();
+ const marginX = 80;
+ const marginY = 70;
+ return {
+ x: Math.max(0, rect.left - marginX),
+ y: Math.max(0, rect.top - marginY),
+ width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
+ height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
+ };
+});
+
+mkdirSync(OUTPUT, { recursive: true });
+copyFileSync(BUNDLE, DEMO_BUNDLE);
+copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
+
+const { page, browser } = await launch();
+const browserErrors = [];
+page.on('pageerror', (error) => browserErrors.push(error.message));
+
+try {
+ const fingerprint = await assertFreshDemoBundle(page, ROOT);
+ const scenarios = {};
+ for (const scenario of DOC_SCREENSHOTS) {
+ const runtime = await prepareGoldenScenario(page, scenario);
+ if (scenario.expectDialog && !runtime.dialog)
+ throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
+ const clip = scenario.capture === 'room-card'
+ ? await roomCardClip(page)
+ : await goldenClip(page, scenario.capture);
+ const image = await page.screenshot({
+ ...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
+ });
+ writeFileSync(resolve(OUTPUT, scenario.file), image);
+ scenarios[scenario.id] = {
+ file: scenario.file,
+ viewport: scenario.viewport,
+ theme: scenario.theme,
+ language: scenario.language,
+ sourceSha256: fingerprint,
+ imageSha256: sha256(image),
+ };
+ console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
+ }
+ if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
+ const manifest = {
+ version: DOC_SCREENSHOT_VERSION,
+ fixture: 'synthetic-only',
+ sourceFingerprint: fingerprint,
+ captureScriptSha256: sha256(readFileSync(SCRIPT)),
+ command: 'npm run docs:capture',
+ scenarios,
+ };
+ writeFileSync(resolve(OUTPUT, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
+} finally {
+ await browser.close();
+}
diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs
index f6b1c698..8fa4811a 100644
--- a/demo/golden/harness.mjs
+++ b/demo/golden/harness.mjs
@@ -1,7 +1,16 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
-const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
+const fixtureFor = (name) => {
+ if (name === 'large') return makeLargeHouseFixture();
+ if (name === 'empty') {
+ return {
+ config: { spaces: [], markers: [], settings: {} },
+ layout: {}, devices: {}, entities: {}, states: {}, areas: {},
+ };
+ }
+ return makeVisualMatrixFixture();
+};
const themeVars = {
dark: {
@@ -120,7 +129,8 @@ export function prepareGoldenFixture(scenario) {
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
- || typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
+ || typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
+ || scenario.roomMetrics) {
const space = requireSpace();
space.settings = {
...(space.settings || {}),
@@ -129,6 +139,9 @@ export function prepareGoldenFixture(scenario) {
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
+ ...(scenario.roomMetrics ? {
+ label_temp: true, label_hum: true, label_lqi: true, label_light: true,
+ } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
@@ -276,7 +289,7 @@ export async function prepareGoldenScenario(page, scenario) {
}
const host = document.getElementById('host');
const cardConfig = {
- type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
+ type: 'custom:houseplan-card', title: scenario.title || `Golden ${scenario.id}`, icon_size: 3.4,
language: scenario.language || 'en',
...(scenario.kiosk ? { kiosk: true } : {}),
};
@@ -285,7 +298,7 @@ export async function prepareGoldenScenario(page, scenario) {
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
devices: fixture.devices || {}, entities: fixture.entities || {},
areas: fixture.areas || {}, states: fixture.states || {},
- floors: {
+ floors: scenario.noFloors ? {} : {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
@@ -538,6 +551,21 @@ export async function prepareGoldenScenario(page, scenario) {
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the complete device light-source controls');
}
+ if (scenario.devicePresentationPreview) {
+ const dialog = card.renderRoot.querySelector('hp-dialog');
+ const body = dialog?.querySelector('.body');
+ const preview = dialog?.querySelector('hp-device-preview');
+ await preview?.updateComplete;
+ if (!body || !preview) throw new Error('golden device presentation preview is missing');
+ const bodyRect = body.getBoundingClientRect();
+ const previewRect = preview.getBoundingClientRect();
+ body.scrollTop += previewRect.top - bodyRect.top - 180;
+ await frame();
+ const visibleBody = body.getBoundingClientRect();
+ const visiblePreview = preview.getBoundingClientRect();
+ if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
+ throw new Error('golden viewport does not show the device presentation preview');
+ }
if (scenario.openHelp) {
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
@@ -557,6 +585,15 @@ export async function prepareGoldenScenario(page, scenario) {
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
+ } else if (scenario.dialog === 'device-info') {
+ const device = card._devices.find((item) => item.id === scenario.deviceId);
+ if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
+ card._infoCard = device;
+ card.requestUpdate();
+ await card.updateComplete;
+ await frame();
+ if (!card.renderRoot.querySelector('hp-dialog'))
+ throw new Error(`golden device info did not open: ${scenario.deviceId}`);
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
const full = scenario.dialog === 'backup-full';
card._backupImportDialog = {
diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js
index eac6a210..52716caf 100755
--- a/demo/srv/assets/houseplan-card.js
+++ b/demo/srv/assets/houseplan-card.js
@@ -1,4 +1,4 @@
-globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1";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 n=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 o=(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 n(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 n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.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"),p.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:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(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"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),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 n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),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),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(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}),(p.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,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(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;iI(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!==G&&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){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=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 mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)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 n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{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._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.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){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):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(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.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=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.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._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.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(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j`
+globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a";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 n=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 o=(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 n(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 n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.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"),p.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:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(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"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),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 n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),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),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(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}),(p.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,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(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;iI(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!==G&&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){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=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 mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)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 n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{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._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.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){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):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(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.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=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.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._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.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(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j`
${this.icon?j``:G}
${this.title}`;return this._useHaDialog?j`{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 n(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 n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.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"),p.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:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(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"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),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 n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),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),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(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}),(p.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,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(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;iI(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!==G&&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){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=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 mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)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 n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{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._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.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){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):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(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.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=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.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._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.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(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j`
+globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a";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 n=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 o=(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 n(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 n("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:d,getPrototypeOf:u}=Object,p=globalThis,_=p.trustedTypes,m=_?_.emptyScript:"",g=p.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"),p.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:n}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const o=s?.call(this);n?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=u(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"),n=t.litNonce;void 0!==n&&s.setAttribute("nonce",n),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 n=(void 0!==i.converter?.toAttribute?i.converter:v).toAttribute(e,i.type);this._$Em=t,null==n?this.removeAttribute(s):this.setAttribute(s,n),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),n="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:v;this._$Em=s;const o=n.fromAttribute(e,t.type);this[s]=o??this._$Ej?.get(s)??o,this._$Em=null}}requestUpdate(t,e,i,s=!1,n){if(void 0!==t){const o=this.constructor;if(!1===s&&(n=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(n,e)||i.useDefault&&i.reflect&&n===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:n},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==n||void 0!==o)||(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}),(p.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,I=Array.isArray,E="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,O=/>/g,N=RegExp(`>|${E}(?:([^\\s"'>=/]+)(${E}*=${E}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,H=/"/g,q=/^(?:script|style|textarea|title)$/i,B=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),j=B(1),W=B(2),U=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=P.createTreeWalker(P,129);function Y(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const X=(t,e)=>{const i=t.length-1,s=[];let n,o=2===e?"":3===e?"":"")),s]};class Z{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let n=0,o=0;const r=t.length-1,a=this.parts,[l,c]=X(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;iI(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!==G&&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){I(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const n of t)s===e.length?e.push(i=new tt(this.O(R()),this.O(R()),this,this.options)):i=e[s],i._$AI(n),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const n=this.strings;let o=!1;if(void 0===n)t=J(this,t,e,0),o=!A(t)||t!==this._$AH&&t!==U,o&&(this._$AH=t);else{const s=t;let r,a;for(t=n[0],r=0;r{const s=i?.renderBefore??e;let n=s._$litPart$;if(void 0===n){const t=i?.renderBefore??null;s._$litPart$=n=new tt(e.insertBefore(R(),t),t,void 0,i??{})}return n._$AI(t),n},ct=globalThis;let ht=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=lt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return U}};ht._$litElement$=!0,ht.finalized=!0,ct.litElementHydrateSupport?.({LitElement:ht});const dt=ct.litElementPolyfillSupport;dt?.({LitElement:ht}),(ct.litElementVersions??=[]).push("4.2.2");const ut=2,pt=t=>(...e)=>({_$litDirective$:t,values:e});let _t=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 mt={},gt=pt(class extends _t{constructor(){super(...arguments),this.ot=mt}render(t,e){return e()}update(t,[e,i]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((t,e)=>t===this.ot[e]))return U}else if(this.ot===e)return U;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,i)}}),{I:ft}=rt,vt=t=>t,yt=()=>document.createComment(""),bt=(t,e,i)=>{const s=t._$AA.parentNode,n=void 0===e?t._$AB:e._$AA;if(void 0===i){const e=s.insertBefore(yt(),n),o=s.insertBefore(yt(),n);i=new ft(e,o,t,t.options)}else{const e=i._$AB.nextSibling,o=i._$AM,r=o!==t;if(r){let e;i._$AQ?.(t),i._$AM=t,void 0!==i._$AP&&(e=t._$AU)!==o._$AU&&i._$AP(e)}if(e!==n||r){let t=i._$AA;for(;t!==e;){const e=vt(t).nextSibling;vt(s).insertBefore(t,n),t=e}}}return i},wt=(t,e,i=t)=>(t._$AI(e,i),t),kt={},$t=(t,e=kt)=>t._$AH=e,xt=t=>{t._$AR(),t._$AA.remove()},St=(t,e,i)=>{const s=new Map;for(let n=e;n<=i;n++)s.set(t[n],n);return s},Mt=pt(class extends _t{constructor(t){if(super(t),t.type!==ut)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 n=[],o=[];let r=0;for(const e of t)n[r]=s?s(e,r):r,o[r]=i(e,r),r++;return{values:o,keys:n}}render(t,e,i){return this.dt(t,e,i).values}update(t,[e,i,s]){const n=(t=>t._$AH)(t),{values:o,keys:r}=this.dt(e,i,s);if(!Array.isArray(n))return this.ut=r,o;const a=this.ut??=[],l=[];let c,h,d=0,u=n.length-1,p=0,_=o.length-1;for(;d<=u&&p<=_;)if(null===n[d])d++;else if(null===n[u])u--;else if(a[d]===r[p])l[p]=wt(n[d],o[p]),d++,p++;else if(a[u]===r[_])l[_]=wt(n[u],o[_]),u--,_--;else if(a[d]===r[_])l[_]=wt(n[d],o[_]),bt(t,l[_+1],n[d]),d++,_--;else if(a[u]===r[p])l[p]=wt(n[u],o[p]),bt(t,n[d],n[u]),u--,p++;else if(void 0===c&&(c=St(r,p,_),h=St(a,d,u)),c.has(a[d]))if(c.has(a[u])){const e=h.get(r[p]),i=void 0!==e?n[e]:null;if(null===i){const e=bt(t,n[d]);wt(e,o[p]),l[p]=e}else l[p]=wt(i,o[p]),bt(t,n[d],i),n[e]=null;p++}else xt(n[u]),u--;else xt(n[d]),d++;for(;p<=_;){const e=bt(t,l[_+1]);wt(e,o[p]),l[p++]=e}for(;d<=u;){const t=n[d++];null!==t&&xt(t)}return this.ut=r,$t(t,l),U}}),Ct=new WeakMap;let Dt=0;class Tt extends ht{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._overlays=[],this._titleId="hp-dialog-title-"+ ++Dt,this._focusInitial=()=>{const t=this._focusableElements(),e=t.find(t=>t.hasAttribute("autofocus"))||t[0]||(this._useHaDialog?null:this.renderRoot.querySelector(".close"))||this.renderRoot.querySelector(".surface")||this.renderRoot.querySelector("ha-dialog");e?.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){t.preventDefault(),t.stopImmediatePropagation(),this._pruneOverlays();const e=this._overlays[this._overlays.length-1];return e?void this._closeOverlay(e,"escape"):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(),n=i[0],o=i[i.length-1];!t.shiftKey||s!==n&&i.includes(s)?t.shiftKey||s!==o||(t.preventDefault(),n.focus()):(t.preventDefault(),o.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=Ct.get(t)||{dialogs:new Set,opener:this._opener};e.dialogs.add(this),Ct.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._overlays];this._overlays=[];for(const e of t.reverse())e.close("disconnect");const e=this._focusRoot,i=this._opener;this._opener=null,this._focusRoot=null;const s=e?Ct.get(e):void 0;s?.dialogs.delete(this),super.disconnectedCallback(),e&&s&&requestAnimationFrame(()=>{const t=Ct.get(e);if(!t)return;if(!t.dialogs.size)return t.opener?.isConnected&&t.opener.focus({preventScroll:!0}),void Ct.delete(e);const s=i?.closest("hp-dialog");i?.isConnected&&s&&t.dialogs.has(s)&&i.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(","),e=[],i=new Set,s=n=>{if(!i.has(n)){if(i.add(n),n instanceof HTMLElement){if(n.matches(t)&&e.push(n),n instanceof HTMLSlotElement){for(const t of n.assignedNodes({flatten:!0}))s(t);return}if(n.shadowRoot){for(const t of n.shadowRoot.childNodes)s(t);return}}for(const t of n.childNodes)s(t)}};for(const t of this.childNodes)s(t);const n=this.overlayPortal();return n&&s(n),e.filter(t=>{let e=t;for(;e;){const t=getComputedStyle(e);if(e.hidden||e.inert||"true"===e.getAttribute("aria-hidden")||"none"===t.display||"hidden"===t.visibility)return!1;if(e=e.assignedSlot||e.parentElement||(e.getRootNode()instanceof ShadowRoot?e.getRootNode().host:null),e===this)break}return!0})}_pruneOverlays(){this._overlays=this._overlays.filter(t=>t.owner.isConnected)}_closeOverlay(t,e){const i=this._overlays.findIndex(e=>e.token===t.token);i>=0&&this._overlays.splice(i,1),t.close(e)}registerOverlay(t){this._pruneOverlays();const e=this._overlays.find(e=>e.owner===t.owner);e&&this._overlays.splice(this._overlays.indexOf(e),1);const i=t.group||"transient";for(const t of[...this._overlays].reverse())t.group===i&&this._closeOverlay(t,"exclusive");const s={...t,group:i,token:Symbol("hp-overlay")};this._overlays.push(s);let n=!1;return()=>{if(n)return;n=!0;const t=this._overlays.findIndex(t=>t.token===s.token);t>=0&&this._overlays.splice(t,1)}}closeTransientOverlays(t="outside"){this._pruneOverlays();const e=[...this._overlays].filter(t=>"transient"===(t.group||"transient"));for(const i of e.reverse())this._closeOverlay(i,t);return e.length>0}overlayPortal(){return this.renderRoot.querySelector(".overlay-portal")}render(){const t=j`
${this.icon?j``:G}
${this.title}`;return this._useHaDialog?j`95% goal); mypy strict.
-5. The demo harness lives in /tmp/demo (synthetic home: demo.html + capture.mjs) —
- rebuildable from this repo + docs/DEVELOPMENT.md notes; frames → PIL → GIF.
+5. Roadmap: phases 7–10 are DONE (v1.12.0 quality scale, v1.13.0 universality,
+ v1.13.1 distribution). Next candidates: measure backend coverage (>95% goal);
+ mypy strict.
+6. The public-doc screenshot harness is versioned in `demo/docs/capture.mjs` and
+ reuses the production component plus deterministic golden fixtures.
## How to resume work in a fresh session (checklist)
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
new file mode 100644
index 00000000..00f4ce5a
--- /dev/null
+++ b/docs/USER-GUIDE.md
@@ -0,0 +1,580 @@
+# House Plan — complete user guide
+
+Current for **v1.64.0**. This guide describes the interface implemented by the
+current source. [Русская версия](USER-GUIDE.ru.md).
+
+House Plan installs two Lovelace cards together:
+
+- `custom:houseplan-card` — the live plan, editors, state and actions;
+- `custom:houseplan-space-card` — an inert rendering of one space with a link
+ to the full plan.
+
+Configuration, uploaded files and shared positions stay inside Home Assistant.
+House Plan does not use a House Plan cloud service.
+
+> **Input support.** View and kiosk are supported on phones, tablets and wall
+> touch panels. Create and maintain a plan on a desktop browser with a mouse
+> and keyboard. Touch editing is best effort: an operation may be awkward,
+> limited or absent, but it must not corrupt data or trigger an accidental
+> Home Assistant action. The authority is [TOUCH-SUPPORT.md](TOUCH-SUPPORT.md).
+
+## Contents
+
+1. [Data model and terms](#1-data-model-and-terms)
+2. [Installation and access](#2-installation-and-access)
+3. [Adding a card](#3-adding-a-card)
+4. [Quick start](#4-quick-start)
+5. [Interface modes](#5-interface-modes)
+6. [Navigation, zoom and input](#6-navigation-zoom-and-input)
+7. [Spaces](#7-spaces)
+8. [Rooms and walls](#8-rooms-and-walls)
+9. [Doors, windows, gates and locks](#9-doors-windows-gates-and-locks)
+10. [Devices](#10-devices)
+11. [Tap actions](#11-tap-actions)
+12. [Device visual states](#12-device-visual-states)
+13. [Room fills and light](#13-room-fills-and-light)
+14. [Background editor](#14-background-editor)
+15. [Sun background and window rays](#15-sun-background-and-window-rays)
+16. [Robot vacuums](#16-robot-vacuums)
+17. [Kiosk](#17-kiosk)
+18. [Static space card](#18-static-space-card)
+19. [Plan maintenance](#19-plan-maintenance)
+20. [Storage, multiple cards and backups](#20-storage-multiple-cards-and-backups)
+21. [Current limitations](#21-current-limitations)
+22. [Troubleshooting](#22-troubleshooting)
+
+
+
+## 1. Data model and terms
+
+Settings form a hierarchy. Global settings provide defaults; a space may
+override them; a room may override its space; a marker may override its room.
+
+| Level | Meaning | Stored data |
+|---|---|---|
+| Card | One dashboard instance | Initial space, language, icon size, value/LQI display, live state, kiosk and cycle |
+| Global settings | Defaults for all spaces | Fill palette, background, Glow radius, north, sun, weather and icon rules |
+| Space | Floor, yard, garage or building | Plan image, scale, rooms, walls, openings, decor and display settings |
+| Room | A closed outline | Name, optional HA area, temperature/humidity source and local fill |
+| Wall | A side of a room outline | Optional physical thickness and virtual spans on a shared boundary |
+| Opening | Door, window or gate on a wall | Size, orientation, contact and optional lock |
+| Marker | A device shown on the plan | HA binding, room, action, presentation, light role and attachments |
+| Background item | Visual context | Line, shape, text, furniture or plan-image transform |
+
+A room is the spatial unit that owns area and an optional HA area. A partition
+or column affects the physical rendering and light but does not create a room.
+
+
+
+## 2. Installation and access
+
+### Requirements
+
+| Component | Requirement |
+|---|---|
+| Home Assistant | 2024.6.0 or newer |
+| Installation | Any installation that supports custom integrations |
+| Browser | Web Components, SVG and Pointer Events |
+| Editing permission | Administrators by default |
+
+### HACS
+
+1. Add `https://github.com/Matysh/houseplan-card` to HACS as a custom
+ **Integration** repository.
+2. Install House Plan and restart Home Assistant.
+3. Open **Settings → Devices & services → Add integration → House Plan**.
+4. Keep “administrators only” enabled unless other users must edit the plan.
+
+The integration registers its Lovelace resource. With YAML-managed resources,
+add:
+
+```yaml
+resources:
+ - url: /houseplan_files/houseplan-card.js
+ type: module
+```
+
+Do not use `/custom_components/houseplan/frontend/houseplan-card.js`; it is an
+on-disk path, not the JavaScript URL served by Home Assistant.
+
+### Manual installation
+
+Copy the release folder to `config/custom_components/houseplan`, restart Home
+Assistant, add the integration, then add the resource above only if Lovelace
+resources are YAML-managed.
+
+### Permissions
+
+Every signed-in user can view the plan. Home Assistant permissions still govern
+device service calls. With the default integration option, only administrators
+can edit configuration or upload files. Plan optimization and its undo always
+require an administrator.
+
+## 3. Adding a card
+
+Minimal configuration:
+
+```yaml
+type: custom:houseplan-card
+title: House plan
+```
+
+| Field | Default | Purpose |
+|---|---:|---|
+| `title` | empty | Card title |
+| `default_floor` | first/last opened | Initial space for this card |
+| `language` | HA language | `auto`, `ru` or `en` |
+| `icon_size` | `2.5` | Base marker size, 1–6% of plan width |
+| `show_temperature` | `true` | Compact temperature and humidity values |
+| `live_states` | `true` | Work/open/unavailable presentation and activity; alarms remain visible |
+| `show_signal` | `true` | Base LQI display, overridable by a space |
+| `kiosk` | `false` | Full plan without editors or header |
+| `cycle` | `0` | Kiosk auto-cycle interval in seconds; `0` disables it |
+
+The legacy card-level `tap_action` is ignored. Each marker owns its action.
+
+
+
+## 4. Quick start
+
+Use this order for a first working room:
+
+1. Add a space with **+**, or import Home Assistant floors.
+2. Upload SVG/PNG/JPG/WebP, reuse an uploaded plan, or choose no image.
+3. Set the real size of a grid cell. It controls wall, opening, furniture and
+ area measurements.
+4. In Plan choose **Room outline**, click the vertices, then click the first
+ point to close the outline.
+5. Name the room and bind it to an HA area, or use **No area**.
+6. Add wall thickness and openings if needed.
+7. In Device, position auto-discovered markers and choose their actions.
+8. In Background, align the plan image and add labels, lines or furniture.
+9. Return to View and confirm live values and actions.
+
+
+
+
+
+Do all plan creation on desktop. Phone, tablet and wall-panel View remain full
+product surfaces after setup.
+
+
+
+## 5. Interface modes
+
+View is the state with no editor open. Close the active editor to return to it.
+
+| Mode | Devices | Geometry | Background/openings |
+|---|---|---|---|
+| View | Live and actionable | Read-only; room hover shows summary | Visible according to space settings |
+| Plan | Hidden | Rooms, walls, columns and openings editable | Openings always visible |
+| Device | Draggable; click opens settings | Read-only background | Read-only background |
+| Background | Dimmed and inert | Dimmed | Background objects editable |
+| Kiosk | Actionable as in View | Read-only | Read-only; no editors |
+| Static card | Not live or interactive | Render only | Render only |
+
+Each editor has a stable primary toolbar. Tool parameters and selected-object
+actions appear in a context tray over the top of the canvas. On a narrow screen
+the tray scrolls horizontally instead of shrinking the plan.
+
+
+
+## 6. Navigation, zoom and input
+
+The canvas grows with actual content. **Fit all** frames rooms and any distant
+objects; each space keeps its local View viewport.
+
+| Scenario | Mouse | Touch View | Touch editors | Keyboard |
+|---|---|---|---|---|
+| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
+| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
+| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
+| Draw or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` cancels the operation |
+| Editor history | Undo/Redo controls | Not applicable | Controls may work; no gesture guarantee | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
+| Kiosk sizing | — | Hold empty space for 3 seconds | — | — |
+
+At zoom above 1:1 a horizontal kiosk gesture pans instead of changing space.
+Any manual kiosk operation pauses auto-cycle for 60 seconds.
+
+### Cancel and undo
+
+- `Esc` cancels an unfinished path, current drag/resize/rotation, or the top
+ dialog without undoing an already committed action.
+- `Ctrl/Cmd+Z` undoes an editor command; redo is `Ctrl/Cmd+Shift+Z` or `Ctrl+Y`.
+- Undo/Redo stores up to 50 named commands for Plan and Background.
+
+
+
+## 7. Spaces
+
+A space may represent a floor, garden, garage or separate structure. It stores
+its image, grid scale, geometry, Background layer and display overrides.
+
+### Plan source
+
+- upload SVG, PNG, JPG or WebP;
+- select a previously uploaded plan;
+- choose no image and draw the geometry by hand.
+
+The plan image keeps its proportions initially. Background can later move,
+scale or rotate it. Detaching a plan never deletes its server file; deletion
+requires an explicit user action.
+
+### Display settings
+
+A space can show/hide room borders, names, LQI, Background and openings. It can
+override fill mode, Glow, day-cycle background, north, sun rays, room-card font
+scale and which room metrics are visible.
+
+Room cards are positioned and scaled on the plan. View renders only the metrics
+enabled for that space.
+
+
+
+
+
+## 8. Rooms and walls
+
+### Create a room
+
+Select **Room outline** and click grid nodes. The current segment shows its real
+length. Clicking the first point closes the outline and opens the room dialog.
+Rooms may share boundaries but may not overlap. A closed room has area; an open
+wall path does not.
+
+Existing segment endpoints and lines appear above walls while drawing. An
+endpoint grows when the next click will join it. A point on a line shows where
+the click will create a valid junction.
+
+### Plan tools at a glance
+
+| Tool | Result | Room area | Light and shadow | Main limit |
+|---|---|---|---|---|
+| Room outline | Closed room or persisted open wall segments | Only a closed saved outline has area | Physical segments block light; openings pass it | Rooms cannot overlap; close on the first point |
+| Partition | Independent wall segment | Does not split or change area | Blocks light and casts a wall shadow | Does not create a room or HA-area binding |
+| Column | Square or circular support | Does not change area | Blocks light inside its shape | One shape/size/rotation; not a wall or room |
+| Boundary | Virtual span of a shared wall | Geometry and area stay unchanged | Light passes; no physical wall is painted | Only a shared boundary between adjacent rooms |
+| Opening | Door, window or gate | Does not change area | Door/gate passage follows state; window may cast sun | Must fit completely on a suitable wall segment |
+
+Other operations edit existing geometry:
+
+| Operation | Result |
+|---|---|
+| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area |
+| Split | Cuts a room from one wall to another; the larger part keeps the original room |
+| Resize | Moves a wall with shared geometry or scales a room by corner handles |
+| Thickness | Changes one physical span or every wall of a room |
+| Delete room | Deletes only the selected room after confirmation |
+
+
+
+Wall thickness is stored in real units. A room may have different thicknesses
+on different spans. Open wall branches and T-junctions are allowed; shared
+geometry remains joined without painted end caps.
+
+### HA area binding
+
+One HA area may be bound to one room. The binding drives automatic device
+placement, room LQI, light and aggregate climate. A room without an area may
+still contain manually assigned devices and explicit measurement sources.
+
+## 9. Doors, windows, gates and locks
+
+Choose Opening, select door/window/gate, and click a wall. Defaults are 90 cm,
+120 cm and 300 cm. The complete opening must fit on the wall.
+
+An opening may bind a contact; doors and gates may also bind a lock. View paints
+the moving leaf and state. A lock badge is green when locked and orange when
+unlocked. A plan tap never toggles a lock. The opening card provides a labelled
+lock/unlock control, with confirmation before unlocking.
+
+Openings may slide along joined wall corners. A double click in Plan opens
+properties. Thick walls keep visible jambs and align the symbol to the correct
+face.
+
+
+
+## 10. Devices
+
+House Plan reads Home Assistant device, entity and area registries. A device in
+a bound HA area receives an automatic marker. Service-only records, bridges and
+other non-spatial records are filtered; a light group may replace its members.
+Newly discovered devices get a red dot until first opened in Device.
+
+### Bindings
+
+| Binding | Use |
+|---|---|
+| HA device | Uses its related entities and resolves a primary function |
+| HA entity | Exact entity binding after enabling “Show entities” |
+| Virtual device | Label/icon/description only, with no live active state |
+
+The same binding cannot be used by two markers.
+
+### Device editor
+
+- drag a marker to save its server-side position;
+- click it to edit name, binding, room, tap action and presentation;
+- **Add** creates a virtual marker or picks a binding manually;
+- **Hidden and disabled** reveals user-hidden and HA-disabled records only in
+ the editor;
+- **Icon rules** edits the first-match regular-expression list.
+
+The dialog shows binding provenance, exact next tap result, skipped targets and
+a live presentation preview. A saved missing source is shown as missing rather
+than silently replaced.
+
+
+
+
+
+Hidden markers keep configuration and may still contribute to area aggregates.
+An HA-disabled binding is excluded from rendering, state, actions, light and
+aggregates until the same ID becomes active again.
+
+## 11. Tap actions
+
+| Gesture | View | Device editor |
+|---|---|---|
+| Short click/tap | Configured action | Open marker settings |
+| Hold 600 ms | House Plan device card | No device control |
+| Right click | Native HA more-info for the primary entity | Browser/editor context |
+
+| Action | Behaviour | Safety |
+|---|---|---|
+| Device card | House Plan card with entities, model, description, links and files | No state change |
+| HA more-info | Native dialog for the exact primary entity | No state change |
+| Toggle state | Toggles the exact binding, supported device function or configured light-source group | Locks, alarm panels and protective garage/door/gate targets are no-op; confirmation is optional |
+| Run | Runs an automation, script or scene | Explicit target; confirmation is optional |
+
+A light defaults to Toggle; other devices default to the House Plan card. An
+unsupported Toggle remains a visible no-op and is never changed into another
+action behind the user's back.
+
+
+
+
+
+## 12. Device visual states
+
+Presentation has three independent layers: stable marker background, icon or
+value, and optional activity pulse. Priority is **alarm → working → open →
+neutral**.
+
+| State | Meaning | Examples |
+|---|---|---|
+| Red alarm | Critical condition, even with live states disabled | Smoke, gas, CO, leak, tamper/problem/safety, triggered alarm |
+| Yellow | Device is doing its main job | Light/switch/fan on, active climate, vacuum cleaning, known appliance work |
+| Orange | Physically open or unlocked | Door/window contact, unlocked lock, opening valve |
+| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
+| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
+
+Activity may be a finite three-wave event, persistent presence, a travelling
+transition, or persistent work. `prefers-reduced-motion` replaces ordinary
+motion with a compact indicator while the alarm remains clear.
+
+The four display choices are icon + state; icon + state + activity; value +
+state; and always-static icon. A separate value badge can show an entity state,
+useful attribute, average LQI or linked light state on any side of the marker.
+
+Virtual devices use the ordinary neutral/hover background with a dashed outer
+circle. They cannot have active state or pulse. Unavailable keeps the ordinary
+presentation with the standard icon opacity reduction. Zigbee LQI remains a
+separate badge.
+
+## 13. Room fills and light
+
+Space fill modes include user colour, temperature comfort range and LQI. Room
+settings may override the space. Glow is independent from the base fill.
+
+A light source may come from automatic classification, an explicit Always
+role, or a controlled source group. Walls, partitions and columns occlude Glow;
+open passages transmit it. When a configured light source disappears or loses
+its valid binding, its contribution is removed instead of keeping stale light.
+
+Overlapping Glow pools add brightness and colour where browser SVG blending is
+supported; otherwise House Plan uses a safe normal blend without changing the
+saved setting.
+
+
+
+## 14. Background editor
+
+The authoritative technical interaction contract is
+[DECOR-EDITOR.md](DECOR-EDITOR.md).
+
+| Tool | Create | Edit |
+|---|---|---|
+| Select | Select an item | Move, scale, rotate; double click properties; Delete/Backspace removes |
+| Backdrop | Available when a plan image exists | Move, corner-scale, rotate; double click numeric size/angle |
+| Line | Drag endpoints | Colour/opacity, physical thickness and solid/dashed style |
+| Rectangle | Drag diagonal; Shift makes a square | Stroke plus independent fill, size and angle |
+| Oval | Drag bounds; Shift makes a circle | Stroke plus independent fill, radii and angle |
+| Text | Click to open dialog | Multiline text, HA tokens, colour, physical size and angle |
+| Furniture | Pick symbol, then click | Symbol, size, colour, outline and wall magnet |
+| Erase | Click an item | Confirmed deletion, undoable |
+
+Creation and transform snap to the grid plus nearby room/background anchors.
+The plan image is interactive only with Backdrop selected. Undo/Redo shares the
+50-command editor history.
+
+
+
+Live text accepts `{sensor.entity}` and
+`{climate.entity:current_temperature}` tokens. Missing, unavailable or complex
+object values render as `—`; long values are shortened.
+
+## 15. Sun background and window rays
+
+These are independent features. **Follow the sun** changes the background from
+day through golden hour to night and can fall back to browser time. Window rays
+require `sun.sun`, a configured north direction and suitable exterior windows.
+Weather cloud cover may reduce ray intensity.
+
+Rays remain visual only: they do not change Home Assistant state. Walls and
+physical obstacles clip them; changing north or window geometry recalculates
+the result.
+
+## 16. Robot vacuums
+
+The dock marker stays at its saved location while a live puck and path follow a
+supported map source. Calibration maps source coordinates to plan coordinates.
+Automatic room-name matching is a starting point; manual drag/stretch corrects
+it. A diagnostic source picker reports missing or incompatible sources instead
+of silently rebinding.
+
+Multiple maps are represented as distinct sources/calibrations. A vacuum is
+shown only in the space whose saved mapping currently matches the active map;
+the dock remains in its configured space. See [VACUUM.md](VACUUM.md) for the
+source dialect and calibration contract.
+
+## 17. Kiosk
+
+Set `kiosk: true` on a card in a Panel view. Editors and the ordinary header are
+removed. Pinch/drag navigate, double-tap resets, and a 1:1 horizontal swipe
+changes space. Holding empty space for three seconds opens per-display icon and
+text sizing. `cycle` enables automatic space changes; interaction pauses it for
+60 seconds.
+
+## 18. Static space card
+
+`custom:houseplan-space-card` renders one configured space without live states,
+hover, drag, more-info or actions. A footer button opens the full plan. Use it
+for compact dashboard navigation, not for home control.
+
+```yaml
+type: custom:houseplan-space-card
+space: ground
+```
+
+## 19. Plan maintenance
+
+Optimization compacts old off-grid geometry and unused layout records while
+preserving rooms, bindings and supported settings. It does not delete plan
+images or attachments merely because nothing currently references them.
+
+Optimization creates one server-side undo point. Any later edit makes that undo
+stale, so create a Home Assistant backup before a large maintenance operation.
+
+
+
+## 20. Storage, multiple cards and backups
+
+### Portable JSON backup
+
+**Global settings → Backup and transfer** exports either the complete House
+Plan model or the current space. Import first shows a server-side preview with
+type, versions, object counts, source and content-link state; nothing is written
+until confirmation.
+
+A full import replaces the model and creates one undo point. A space import
+assigns new internal IDs and adds the copy without replacing global settings.
+Internal uploaded files are not embedded in JSON; an import to another HA
+instance must explicitly detach those links.
+
+### Storage locations
+
+| Data | Location |
+|---|---|
+| Spaces, geometry, Background and marker settings | HA storage `houseplan.config` |
+| Marker and room-card positions | HA storage `houseplan.layout` |
+| Plan files | `config/houseplan/plans` |
+| Marker attachments | `config/houseplan/files` |
+| Vacuum path | Separate House Plan HA storage |
+| Cache, viewport and kiosk scale | That browser's `localStorage` |
+
+### Several cards and clients
+
+Several `custom:houseplan-card` instances may use different `default_floor`
+values. This is the supported way to start one wall display on the ground floor
+and another on a garage or upper floor.
+
+- configuration, rooms, Background and device layout are shared server data;
+- `default_floor` is an initial choice for that card;
+- current mode, selected space, zoom/pan and kiosk sizing are local;
+- WebSocket broadcasts saved changes and revision checks reject a stale write;
+- avoid editing the same object in two browsers: the second save may need a
+ refresh and manual reapplication.
+
+### Files and quotas
+
+Plan files accept SVG/PNG/JPG/WebP up to 8 MB, with a 200-file/256-MB total.
+Marker attachments accept PDF/PNG/JPG/WebP/TXT up to 50 MB, with a
+1000-file/1-GB total and 50 links per marker. Writes are also refused below
+512 MB free disk space. Detached files remain until explicitly deleted.
+
+
+
+## 21. Current limitations
+
+| Limitation | Practical effect |
+|---|---|
+| Rooms must be closed | An open path is walls, not a room with area |
+| One HA area per room | To split one HA area visually, assign devices manually |
+| Boundary requires adjacent rooms | It cannot open an exterior wall or branch from empty space |
+| Editors are desktop-first | Touch editing may be awkward, limited or absent |
+| Room details use hover in View | A touch-only user may need an editor or another visible metric |
+| Sun has no exterior 3D model | It cannot know shadows from trees, awnings or neighbouring structures |
+| Icon rules use regular expressions | First matching rule wins and invalid expressions are rejected |
+
+Storage guards allow up to 50 spaces, 400 rooms per space, 2,000 markers, 500
+openings, 1,000 Background items, 500 wall records and 500 virtual spans per
+space. The configuration package is limited to 2 MB.
+
+
+
+## 22. Troubleshooting
+
+### Card does not load
+
+| Symptom | Check |
+|---|---|
+| `Custom element doesn't exist: houseplan-card` | Integration loaded; resource is `/houseplan_files/houseplan-card.js` with type `module`; hard-refresh |
+| MIME `text/plain` | Replace `/custom_components/...` with `/houseplan_files/...` |
+| Integration missing | Folder is exactly `custom_components/houseplan`; restart HA; inspect import errors |
+
+### Devices or values are missing
+
+- confirm the room's HA-area binding and the device/entity registry area;
+- open Device → Hidden and disabled;
+- verify the selected source still exists and is available;
+- remember that a virtual marker has no active state;
+- inspect the dialog's exact target and skipped-target explanation.
+
+### Geometry looks wrong
+
+- confirm grid cell size before judging physical dimensions;
+- use Fit all to include distant objects;
+- check wall thickness and whether a shared span is a virtual Boundary;
+- use desktop for precise nodes, Split and Resize.
+
+### Vacuum does not move
+
+- confirm the active map source and map identity;
+- check calibration anchors and current source availability;
+- verify that the active map is assigned to this space;
+- open source diagnostics instead of deleting/recreating the marker.
+
+When reporting a problem, include House Plan version, HA version, browser,
+console/server errors and reproducible steps. Replace private entity IDs and
+plans with synthetic equivalents.
diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md
index 5baaf194..fef6f08a 100644
--- a/docs/USER-GUIDE.ru.md
+++ b/docs/USER-GUIDE.ru.md
@@ -1,6 +1,7 @@
# House Plan — полное руководство пользователя
-Актуально для **v1.60.0**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
+Актуально для **v1.64.0**. Руководство составлено по исходному коду и
+фактическому интерфейсу этой версии. [English version](USER-GUIDE.md).
House Plan — локальная интеграция и две Lovelace-карточки для Home Assistant:
@@ -27,7 +28,7 @@ House Plan — локальная интеграция и две Lovelace-кар
6. [Навигация, масштаб и жесты](#6-навигация-масштаб-и-жесты)
7. [Пространства](#7-пространства)
8. [Комнаты и стены](#8-комнаты-и-стены)
-9. [Двери, окна и замки](#9-двери-окна-и-замки)
+9. [Двери, окна, ворота и замки](#9-двери-окна-ворота-и-замки)
10. [Устройства](#10-устройства)
11. [Действия по нажатию](#11-действия-по-нажатию)
12. [Визуальные состояния устройств](#12-визуальные-состояния-устройств)
@@ -42,6 +43,8 @@ House Plan — локальная интеграция и две Lovelace-кар
21. [Ограничения текущей версии](#21-ограничения-текущей-версии)
22. [Диагностика](#22-диагностика)
+
+
## 1. Модель данных и терминология
Настройки образуют иерархию: общие значения задают базовый вид, пространство может их переопределить, затем отдельная комната, затем устройство.
@@ -59,6 +62,8 @@ House Plan — локальная интеграция и две Lovelace-кар
Важное ограничение версии: стена пока не существует отдельно от комнаты. Она выводится из границы замкнутого контура. Незамкнутые и свободно стоящие стены пока не поддерживаются.
+
+
## 2. Установка и доступ
### Требования
@@ -128,6 +133,8 @@ title: План дома
Карточечный `tap_action` из старых конфигураций игнорируется. Действие задаётся отдельно для каждого устройства.
+
+
## 4. Быстрый старт
Рекомендуемый порядок настройки:
@@ -148,6 +155,12 @@ title: План дома
последующего просмотра и управления домом, но не гарантированной средой
редактирования.
+
+
+
+
+
+
## 5. Режимы интерфейса
Обычный просмотр — не отдельная вкладка: это состояние, в которое возвращает крестик активного редактора.
@@ -173,6 +186,8 @@ title: План дома
область и не меняет масштаб плана. На узком экране действия прокручиваются
горизонтально внутри самой суб-панели.
+
+
## 6. Навигация, масштаб и жесты
Холст практически бесконечен: элементы можно рисовать и размещать за пределами исходного квадрата плана. Команда **Показать всё** подбирает вид по фактическому содержимому.
@@ -182,15 +197,14 @@ Touch-жесты просмотра и киоска из таблицы ниже
desktop: для точного рисования, Resize, модификаторов клавиатуры и свойств по
двойному клику используйте компьютер.
-| Действие | Мышь/клавиатура | Сенсорный экран |
-|---|---|---|
-| Масштаб | Колесо, кнопки `−`/`+` | Щипок двумя пальцами |
-| Панорама | Перетаскивание пустого места | Перетаскивание |
-| Показать всё | Центральная кнопка масштаба | Та же кнопка вне киоска |
-| Привязка объектов | Выполняется автоматически и всегда | Выполняется автоматически и всегда |
-| Сброс киоск-масштаба | — | Двойной тап |
-| Следующее пространство в киоске | — | Горизонтальный свайп при масштабе 1:1 |
-| Локальный размер киоска | — | Удерживать пустое место 3 секунды |
+| Сценарий | Мышь | Touch в просмотре | Touch в редакторах | Клавиатура |
+|---|---|---|---|---|
+| Масштаб и панорама | Колесо; drag пустого места; кнопки `−`/`+` | Щипок; drag; двойной тап сбрасывает киоск | Работает, но точность не гарантируется | — |
+| Переключение пространства | Клик по вкладке | Клик; в киоске свайп при масштабе 1:1 | Клик по вкладке | — |
+| Устройство | Клик/двойной клик по правилам режима | Tap; безопасные действия как на desktop | Drag и свойства — best effort | `Esc` закрывает верхнюю поверхность |
+| Рисование и точный drag | Полный контракт | Не применяется | Best effort; для Resize и точных точек используйте мышь | `Shift` меняет магнит/угол; `Esc` отменяет операцию |
+| История редактора | Кнопки Undo/Redo | Не применяется | Кнопки могут работать, жест не гарантирован | `Ctrl/Cmd+Z`, `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` |
+| Киоск-размеры | — | Удерживать пустое место 3 секунды | — | — |
Особенности:
@@ -218,6 +232,8 @@ desktop: для точного рисования, Resize, модификато
кнопку или объект, которыми диалог был открыт. При открытии вложенного окна
возврат сначала происходит в родительский диалог.
+
+
## 7. Пространства
Пространство подходит не только для этажа: это может быть двор, гараж, баня или отдельное строение.
@@ -243,6 +259,11 @@ desktop: для точного рисования, Resize, модификато
| Отображение | Скрыть проёмы | Скрывает только символы дверей, окон и ворот; свет, солнечные лучи и датчики проёмов продолжают работать |
| Карточка комнаты | Температура/влажность/LQI/свет | Добавляет выбранные показатели под названием комнаты |
| Карточка комнаты | Общий размер шрифта | Масштабирует все комнатные карточки пространства, 50–300% |
+
+Карточка комнаты остаётся частью плана: её можно перемещать и масштабировать в
+редакторе, а в просмотре она собирает только включённые метрики.
+
+
| Стиль | Цвет и прозрачность | Общий цвет границ и названий |
| Фон | Наследовать/статический/день-ночь | Управляет фоном вокруг плана |
| Солнце | Север и лучи | Локально переопределяет общие настройки |
@@ -251,6 +272,8 @@ desktop: для точного рисования, Resize, модификато
Удаление пространства удаляет его комнаты и разметку после подтверждения. Файл подложки при этом автоматически не удаляется: им можно управлять через список уже загруженных планов.
+
+
## 8. Комнаты и стены
### Создание комнаты
@@ -299,18 +322,27 @@ T-соединение входит в проходящую стену без в
### Инструменты плана
-| Инструмент | Что выбирать | Результат и ограничения |
-|---|---|---|
-| Контур комнаты | Точки контура | Создаёт комнату; незаконченный контур сохраняется и допускает разную толщину сегментов |
-| Перегородка | Две точки | Создаёт одну независимую стену с текущей толщиной; комнату и HA-зону не делит |
-| Колонна | Одну точку | Создаёт квадратную колонну с текущим размером; форму, размер и поворот можно изменить в свойствах |
-| Объединить | Две соседние комнаты | Объединяет геометрию; в диалоге выбирается, чья идентичность/название/HA-зона сохранится |
-| Split | Комнату, старт на стене, путь внутри, финиш на стене | Делит комнату; большая часть сохраняет исходную комнату, меньшая получает новое имя/HA-зону |
-| Resize | Ручку стены или комнату | Двигает стену с общей геометрией; по клику на комнату даёт угловую рамку масштабирования |
-| Проём | Сначала тип в подменю, затем точку на стене | Создаёт окно 120 см, дверь 90 см или ворота 300 см; клик существующего проёма открывает его настройки |
-| Граница | Две точки общей стены или существующий пунктир | Два клика делают участок общей стены виртуальным; один клик по пунктиру целиком восстанавливает физическую стену |
-| Толщина | Реальный участок стены | Задаёт толщину выбранному участку или всем стенам комнаты |
-| Удалить комнату | Точку внутри комнаты | После подтверждения удаляет только эту комнату; стены не запускают другие скрытые действия |
+#### Инструменты плана в короткой таблице
+
+| Инструмент | Что создаёт | Площадь комнаты | Свет и тени | Главное ограничение |
+|---|---|---|---|---|
+| Контур комнаты | Замкнутую комнату или сохраняемые отрезки незавершённой стены | Только замкнутый сохранённый контур имеет площадь | Физические сегменты блокируют свет; проёмы пропускают его | Комнаты не могут перекрываться; контур замыкается на первой точке |
+| Перегородка | Независимый отрезок стены | Не делит и не меняет площадь | Блокирует свет и отбрасывает тень как стена | Не создаёт комнату и не связывается с HA-зоной |
+| Колонна | Квадратную или круглую опору | Не меняет площадь комнаты | Блокирует свет внутри своей формы | Имеет одну форму/размер/поворот, но не является стеной или комнатой |
+| Граница | Виртуальный участок общей стены | Не меняет геометрию и площадь | Свет проходит, физическая стенка не рисуется | Работает только на общей границе соседних комнат |
+| Проём | Дверь, окно или ворота на стене | Не меняет площадь | Дверь/ворота пропускают свет по состоянию; окно может давать солнечный луч | Должен целиком помещаться на подходящем отрезке стены |
+
+Остальные операции меняют уже созданную геометрию:
+
+| Операция | Результат |
+|---|---|
+| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
+| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
+| Resize | Двигает стену с общей геометрией либо масштабирует выбранную комнату угловой рамкой |
+| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
+| Удалить комнату | После подтверждения удаляет только выбранную комнату |
+
+
### Merge
@@ -449,6 +481,8 @@ Glow и солнечные лучи, но при Resize комнаты оста
Солнце проходит только через наружные окна. Внутренние окна солнечных лучей не создают.
+
+
## 10. Устройства
### Как маркеры появляются автоматически
@@ -479,6 +513,10 @@ House Plan читает реестры устройств, сущностей и
| **Скрытые и деактивированные** | Показывает пользовательски скрытые и отключённые в HA устройства служебными маркерами только в этой вкладке |
| **Правила иконок** | Открывает приоритетный список регулярных выражений для «имя + модель» |
+
+
+
+
### Основные настройки маркера
| Поле | Назначение |
@@ -610,6 +648,10 @@ HA. «Показать» заблокировано до активации. П
получает ровно показанное доступное подмножество; собственная сущность
контроллера не подставляется вместо исчезнувших настроенных целей.
+
+
+
+
## 12. Визуальные состояния устройств
В динамических режимах система состоит из трёх независимых слоёв:
@@ -816,6 +858,8 @@ marker на плане при этом не меняются.
Цвет пятна выбирается по приоритету: RGB-состояние лампы → цветовая температура → общий цвет света. Яркость влияет на интенсивность, но имеет минимальный визуальный порог 15%.
+
+
## 14. Редактор подложки
### Инструменты
@@ -842,6 +886,8 @@ marker на плане при этом не меняются.
показывает «Свойства» и «Удалить». Палитра мебели использует расширенный вариант
той же поверхности и также не меняет высоту или масштаб рабочей области.
+
+
Редактор подложки использует тот же именованный Undo/Redo на 50 команд, что и геометрия плана. `Esc` отменяет только незавершённое рисование или текущий drag/resize/поворот; после отпускания указателя используйте `Ctrl+Z`/`Cmd+Z`. Повтор — `Ctrl+Shift+Z` либо `Ctrl+Y`.
### Двойной клик в «Выбрать»
@@ -1087,6 +1133,8 @@ show_signal: true
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Новый edit делает резервную копию оптимизации устаревшей.
+
+
## 20. Хранение, совместная работа и резервные копии
### Переносимая JSON-копия House Plan
@@ -1133,6 +1181,22 @@ JSON хранит названия, идентификаторы HA и точн
- Масштаб просмотра, последнее открытое пространство и киоск-размеры локальны браузеру.
- Если открыт несохранённый диалог и Lovelace быстро пересобирает карточку, текущая версия пытается восстановить черновик в течение короткого окна.
+### Несколько карточек и стартовые пространства
+
+На одном или разных дашбордах можно разместить несколько
+`custom:houseplan-card` и задать каждой собственный `default_floor`. Это
+поддерживаемый способ открыть, например, первый этаж на настенном планшете, а
+гараж — на отдельном экране.
+
+- конфигурация, комнаты, декор и layout устройств общие и серверные;
+- `default_floor` применяется как стартовый выбор конкретной карточки;
+- текущий режим, выбранное пространство, масштаб/панорама и киоск-размеры
+ локальны браузеру или экземпляру карточки;
+- WebSocket доставляет сохранённые изменения другим клиентам, а серверные
+ ревизии не позволяют молча перезаписать более новую модель;
+- два пользователя не должны одновременно редактировать один и тот же объект:
+ второй save может потребовать обновить состояние и повторить изменение.
+
### Файлы и квоты
| Объект | Форматы | Лимит одного файла | Общий лимит |
@@ -1142,6 +1206,8 @@ JSON хранит названия, идентификаторы HA и точн
Запись также отклоняется, если на разделе HA остаётся меньше 512 МБ свободного места. Файлы не удаляются только из-за возраста. Подложка, отсоединённая от пространства, остаётся на сервере до явного удаления. Незавершённые временные загрузки очищаются автоматически.
+
+
## 21. Ограничения текущей версии
| Ограничение | Практическое следствие |
@@ -1158,6 +1224,8 @@ JSON хранит названия, идентификаторы HA и точн
Технические пределы защиты хранилища: до 50 пространств, 400 комнат на пространство, 2000 маркеров, 500 проёмов, 1000 элементов декора, 500 записей стен и 500 виртуальных участков на пространство. Конфигурационный пакет ограничен 2 МБ.
+
+
## 22. Диагностика
### Карточка не загрузилась
diff --git a/docs/external-link-allowlist.json b/docs/external-link-allowlist.json
new file mode 100644
index 00000000..24a5374b
--- /dev/null
+++ b/docs/external-link-allowlist.json
@@ -0,0 +1,9 @@
+{
+ "transientHosts": [
+ "demo.houseplan.tech",
+ "github.com",
+ "hacs.xyz",
+ "my.home-assistant.io",
+ "t.me"
+ ]
+}
diff --git a/docs/images/01-view-desktop.png b/docs/images/01-view-desktop.png
new file mode 100644
index 00000000..d0ca9b17
Binary files /dev/null and b/docs/images/01-view-desktop.png differ
diff --git a/docs/images/02-onboarding-empty.png b/docs/images/02-onboarding-empty.png
deleted file mode 100644
index 622e5b38..00000000
Binary files a/docs/images/02-onboarding-empty.png and /dev/null differ
diff --git a/docs/images/02-view-touch.png b/docs/images/02-view-touch.png
new file mode 100644
index 00000000..9e8eb69a
Binary files /dev/null and b/docs/images/02-view-touch.png differ
diff --git a/docs/images/03-space-create.png b/docs/images/03-space-create.png
new file mode 100644
index 00000000..4454b778
Binary files /dev/null and b/docs/images/03-space-create.png differ
diff --git a/docs/images/03-space-dialog.png b/docs/images/03-space-dialog.png
deleted file mode 100644
index c630f90f..00000000
Binary files a/docs/images/03-space-dialog.png and /dev/null differ
diff --git a/docs/images/04-markup.png b/docs/images/04-markup.png
deleted file mode 100644
index 468dd74f..00000000
Binary files a/docs/images/04-markup.png and /dev/null differ
diff --git a/docs/images/04-room-contour-close.png b/docs/images/04-room-contour-close.png
new file mode 100644
index 00000000..4a5c2016
Binary files /dev/null and b/docs/images/04-room-contour-close.png differ
diff --git a/docs/images/05-plan-context-tray.png b/docs/images/05-plan-context-tray.png
new file mode 100644
index 00000000..d50586c6
Binary files /dev/null and b/docs/images/05-plan-context-tray.png differ
diff --git a/docs/images/05-room-dialog.png b/docs/images/05-room-dialog.png
deleted file mode 100644
index 414775ba..00000000
Binary files a/docs/images/05-room-dialog.png and /dev/null differ
diff --git a/docs/images/06-device-display-preview.png b/docs/images/06-device-display-preview.png
new file mode 100644
index 00000000..eb845281
Binary files /dev/null and b/docs/images/06-device-display-preview.png differ
diff --git a/docs/images/06-device-editor.png b/docs/images/06-device-editor.png
new file mode 100644
index 00000000..5910d291
Binary files /dev/null and b/docs/images/06-device-editor.png differ
diff --git a/docs/images/06-edit.png b/docs/images/06-edit.png
deleted file mode 100644
index 455df9d6..00000000
Binary files a/docs/images/06-edit.png and /dev/null differ
diff --git a/docs/images/07-background-editor.png b/docs/images/07-background-editor.png
new file mode 100644
index 00000000..9735302d
Binary files /dev/null and b/docs/images/07-background-editor.png differ
diff --git a/docs/images/07-marker-dialog.png b/docs/images/07-marker-dialog.png
deleted file mode 100644
index e8976b45..00000000
Binary files a/docs/images/07-marker-dialog.png and /dev/null differ
diff --git a/docs/images/08-info.png b/docs/images/08-info.png
deleted file mode 100644
index e11c99f1..00000000
Binary files a/docs/images/08-info.png and /dev/null differ
diff --git a/docs/images/08-room-card.png b/docs/images/08-room-card.png
new file mode 100644
index 00000000..15062db0
Binary files /dev/null and b/docs/images/08-room-card.png differ
diff --git a/docs/images/09-device-info.png b/docs/images/09-device-info.png
new file mode 100644
index 00000000..67cb0895
Binary files /dev/null and b/docs/images/09-device-info.png differ
diff --git a/docs/images/09-zoom.png b/docs/images/09-zoom.png
deleted file mode 100644
index 78e1c3b4..00000000
Binary files a/docs/images/09-zoom.png and /dev/null differ
diff --git a/docs/images/demo-synthetic-old.gif b/docs/images/demo-synthetic-old.gif
deleted file mode 100644
index 6378c61d..00000000
Binary files a/docs/images/demo-synthetic-old.gif and /dev/null differ
diff --git a/docs/images/demo.gif b/docs/images/demo.gif
deleted file mode 100644
index 75b3813b..00000000
Binary files a/docs/images/demo.gif and /dev/null differ
diff --git a/docs/images/manifest.json b/docs/images/manifest.json
new file mode 100644
index 00000000..5fd929fb
--- /dev/null
+++ b/docs/images/manifest.json
@@ -0,0 +1,119 @@
+{
+ "version": 1,
+ "fixture": "synthetic-only",
+ "sourceFingerprint": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "captureScriptSha256": "a0022c98553c5baae8335d7ba26f64129699c4ef6162dd1d88df9f70226ec404",
+ "command": "npm run docs:capture",
+ "scenarios": {
+ "view-desktop": {
+ "file": "01-view-desktop.png",
+ "viewport": {
+ "width": 1180,
+ "height": 900
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
+ },
+ "view-touch": {
+ "file": "02-view-touch.png",
+ "viewport": {
+ "width": 390,
+ "height": 760
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
+ },
+ "space-create": {
+ "file": "03-space-create.png",
+ "viewport": {
+ "width": 900,
+ "height": 850
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
+ },
+ "room-contour-close": {
+ "file": "04-room-contour-close.png",
+ "viewport": {
+ "width": 1180,
+ "height": 900
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "59fe5b9df034d255840e7ae6984efb675d39e84ad80dee6f9ad9394c54fb9cda"
+ },
+ "plan-context-tray": {
+ "file": "05-plan-context-tray.png",
+ "viewport": {
+ "width": 1180,
+ "height": 900
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "997bba208897b219051131dc51254ee3c76ed9f73597fde33070ac8cefdcd745"
+ },
+ "device-editor": {
+ "file": "06-device-editor.png",
+ "viewport": {
+ "width": 1180,
+ "height": 1100
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
+ },
+ "device-display-preview": {
+ "file": "06-device-display-preview.png",
+ "viewport": {
+ "width": 1180,
+ "height": 1100
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
+ },
+ "background-editor": {
+ "file": "07-background-editor.png",
+ "viewport": {
+ "width": 1180,
+ "height": 900
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
+ },
+ "room-card": {
+ "file": "08-room-card.png",
+ "viewport": {
+ "width": 1180,
+ "height": 900
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
+ },
+ "device-info": {
+ "file": "09-device-info.png",
+ "viewport": {
+ "width": 1000,
+ "height": 900
+ },
+ "theme": "dark",
+ "language": "en",
+ "sourceSha256": "7f726b212839add4f9fc4491fd23f053b09bf82e38ea380a7cc84d1dc2bcf81a",
+ "imageSha256": "655873ba709a2a702fde533b33ef109343ce0c6ca85ff985fdd7c4d41c877b33"
+ }
+ }
+}
diff --git a/docs/specs/035-current-ux-docs.md b/docs/specs/035-current-ux-docs.md
index d03d6511..c8d59a12 100644
--- a/docs/specs/035-current-ux-docs.md
+++ b/docs/specs/035-current-ux-docs.md
@@ -1,8 +1,8 @@
# ТЗ #35 — Документация текущего пользовательского опыта
- Issue: https://github.com/Matysh/houseplan-card/issues/35
-- Приоритет: P1
-- Статус ТЗ: ready for implementation
+- Приоритет: P2
+- Статус ТЗ: implemented
- Тип: docs-only, без изменения поведения
## Цель и аудитория
diff --git a/package.json b/package.json
index 56fe4b9e..e419add4 100755
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"golden:verify": "node demo/golden/run.mjs --mode=verify",
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
"golden:accept": "node demo/golden/accept.mjs",
+ "docs:capture": "npm run build && node demo/docs/capture.mjs",
+ "docs:check": "node scripts/check-docs.mjs",
"release:check": "node scripts/release-prerelease.mjs --check",
"release:prerelease": "node scripts/release-prerelease.mjs",
"prepare": "node scripts/install-hooks.mjs"
diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs
new file mode 100644
index 00000000..bb24cc47
--- /dev/null
+++ b/scripts/check-docs.mjs
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+import { createHash } from 'node:crypto';
+import { existsSync, readFileSync, statSync } from 'node:fs';
+import { dirname, extname, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { sourceFingerprint } from './source-fingerprint.mjs';
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const EXTERNAL = process.argv.includes('--external');
+const PUBLIC_DOCS = [
+ 'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
+ 'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md',
+];
+const EXPECTED_SCREENSHOTS = [
+ 'view-desktop', 'view-touch', 'space-create', 'room-contour-close',
+ 'plan-context-tray', 'device-editor', 'device-display-preview', 'background-editor',
+ 'room-card', 'device-info',
+];
+const errors = [];
+const warnings = [];
+const externalUrls = new Set();
+const sha256 = (value) => createHash('sha256').update(value).digest('hex');
+const canonicalText = (path) => readFileSync(path, 'utf8').replace(/\r\n?/g, '\n');
+
+const withoutFences = (text) => text.replace(/^```[^\n]*\n[\s\S]*?^```\s*$/gm, '');
+const slug = (heading) => heading
+ .trim().toLowerCase()
+ .replace(/<[^>]+>/g, '')
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-');
+
+const headingsFor = (path) => {
+ const seen = new Map();
+ const headings = new Set();
+ for (const line of canonicalText(path).split('\n')) {
+ const match = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
+ if (!match) continue;
+ const base = slug(match[1]);
+ const count = seen.get(base) || 0;
+ seen.set(base, count + 1);
+ headings.add(count ? `${base}-${count}` : base);
+ }
+ return headings;
+};
+
+const rootPrefix = `${ROOT}${sep}`.toLowerCase();
+for (const relative of PUBLIC_DOCS) {
+ const path = resolve(ROOT, relative);
+ if (!existsSync(path)) {
+ errors.push(`${relative}: required public document is missing`);
+ continue;
+ }
+ const text = withoutFences(canonicalText(path));
+ const links = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
+ for (const match of text.matchAll(links)) {
+ const image = match[1] === '!';
+ const alt = match[2].trim();
+ const raw = match[3].trim().replace(/\s+"[^"]*"$/, '');
+ if (image && !alt) errors.push(`${relative}: image has empty alt text (${raw})`);
+ if (/^(?:https?:)?\/\//i.test(raw)) {
+ try {
+ const url = new URL(raw.startsWith('//') ? `https:${raw}` : raw);
+ if (url.protocol !== 'https:') errors.push(`${relative}: external link must use https (${raw})`);
+ externalUrls.add(url.href);
+ } catch {
+ errors.push(`${relative}: invalid external URL (${raw})`);
+ }
+ continue;
+ }
+ if (/^(?:mailto:|#|\/api\/|\/houseplan_files\/)/.test(raw)) {
+ if (raw.startsWith('#')) {
+ const anchor = decodeURIComponent(raw.slice(1));
+ if (anchor && !headingsFor(path).has(anchor))
+ errors.push(`${relative}: missing local heading #${anchor}`);
+ }
+ continue;
+ }
+ const hashAt = raw.indexOf('#');
+ const filePart = decodeURIComponent(hashAt >= 0 ? raw.slice(0, hashAt) : raw);
+ const anchor = hashAt >= 0 ? decodeURIComponent(raw.slice(hashAt + 1)) : '';
+ const target = resolve(dirname(path), filePart || '.');
+ const targetLower = target.toLowerCase();
+ if (targetLower !== ROOT.toLowerCase() && !targetLower.startsWith(rootPrefix)) {
+ errors.push(`${relative}: link escapes repository (${raw})`);
+ continue;
+ }
+ if (!existsSync(target)) {
+ errors.push(`${relative}: missing relative target (${raw})`);
+ continue;
+ }
+ if (image && !statSync(target).isFile()) errors.push(`${relative}: image target is not a file (${raw})`);
+ if (anchor && extname(target).toLowerCase() === '.md' && !headingsFor(target).has(anchor))
+ errors.push(`${relative}: missing heading ${filePart}#${anchor}`);
+ }
+}
+
+const sectionMarkers = (relative) => [...canonicalText(resolve(ROOT, relative))
+ .matchAll(//g)].map((match) => match[1]);
+for (const [en, ru, required] of [
+ ['README.md', 'README.ru.md', ['overview', 'features', 'first-run', 'installation', 'support']],
+ ['docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md', ['model', 'installation', 'first-run', 'modes', 'input', 'spaces', 'plan-tools', 'devices', 'visual-states', 'background', 'multiple-cards', 'limits', 'diagnostics']],
+]) {
+ const enMarkers = sectionMarkers(en);
+ const ruMarkers = sectionMarkers(ru);
+ if (JSON.stringify(enMarkers) !== JSON.stringify(ruMarkers))
+ errors.push(`${en} / ${ru}: docs-section markers differ or are ordered differently`);
+ for (const marker of required) {
+ if (!enMarkers.includes(marker)) errors.push(`${en} / ${ru}: missing required section marker ${marker}`);
+ }
+}
+
+const staleTerms = [
+ [/\bMarkup (?:tab|mode|editor)\b/gi, 'Plan'],
+ [/(?:вкладка|режим|редактор) «Разметка»/gi, '«План»'],
+ [/\bDecor editor\b/gi, 'Background editor'],
+];
+for (const relative of PUBLIC_DOCS.slice(0, 4)) {
+ const text = withoutFences(canonicalText(resolve(ROOT, relative)));
+ for (const [pattern, replacement] of staleTerms) {
+ const matches = text.match(pattern) || [];
+ if (matches.length) errors.push(`${relative}: stale term “${matches[0]}”; use ${replacement}`);
+ }
+}
+
+const manifestPath = resolve(ROOT, 'docs/images/manifest.json');
+if (!existsSync(manifestPath)) {
+ errors.push('docs/images/manifest.json: missing screenshot manifest');
+} else {
+ const manifest = JSON.parse(canonicalText(manifestPath));
+ if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
+ if (manifest.sourceFingerprint !== sourceFingerprint(ROOT)) errors.push('screenshot source fingerprint is stale; run npm run docs:capture');
+ const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
+ if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
+ errors.push('screenshot capture script changed; run npm run docs:capture');
+ const ids = Object.keys(manifest.scenarios || {});
+ if (JSON.stringify(ids.sort()) !== JSON.stringify([...EXPECTED_SCREENSHOTS].sort()))
+ errors.push('screenshot manifest scenario set is incomplete');
+ for (const [id, scenario] of Object.entries(manifest.scenarios || {})) {
+ const imagePath = resolve(ROOT, 'docs/images', scenario.file || '');
+ if (!existsSync(imagePath)) errors.push(`screenshot ${id}: missing ${scenario.file}`);
+ else if (scenario.imageSha256 !== sha256(readFileSync(imagePath)))
+ errors.push(`screenshot ${id}: image hash does not match manifest`);
+ if (scenario.sourceSha256 !== manifest.sourceFingerprint)
+ errors.push(`screenshot ${id}: source SHA does not match manifest`);
+ if (!scenario.viewport?.width || !scenario.viewport?.height || !scenario.theme || !scenario.language)
+ errors.push(`screenshot ${id}: capture metadata is incomplete`);
+ }
+}
+
+if (EXTERNAL) {
+ const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json')));
+ const transientHosts = new Set(allowlist.transientHosts || []);
+ for (const href of [...externalUrls].sort()) {
+ const url = new URL(href);
+ try {
+ const response = await fetch(url, {
+ method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(8000),
+ headers: { 'user-agent': 'houseplan-docs-check/1' },
+ });
+ if (response.status >= 200 && response.status < 400) continue;
+ if ([403, 408, 425, 429].includes(response.status) || response.status >= 500) {
+ if (transientHosts.has(url.hostname)) {
+ warnings.push(`transient external response ${response.status}: ${href}`);
+ continue;
+ }
+ }
+ errors.push(`external link returned ${response.status}: ${href}`);
+ } catch (error) {
+ if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`);
+ else errors.push(`external link failed: ${href} (${error.message})`);
+ }
+ }
+}
+
+for (const warning of warnings) console.warn(`WARN ${warning}`);
+if (errors.length) {
+ for (const error of errors) console.error(`ERROR ${error}`);
+ process.exitCode = 1;
+} else {
+ console.log(`Documentation checks passed (${PUBLIC_DOCS.length} files, ${externalUrls.size} external links).`);
+}