`}_cardEntities(t){const e=this._planHass,i=[],s=new Set,n=t=>{if(!t||s.has(t)||!e.states[t])return;const n=e.entities[t];if("config"===n?.entity_category||"diagnostic"===n?.entity_category)return;s.add(t);const o=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(o)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(o)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(o)&&i.push({eid:t,kind:"value"})};for(const i of Qs(e,[t]))n(i.eid);t.primary&&n(t.primary);for(const e of t.entities)n(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this._planEntityAvailable(t)&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?wi(this.hass,t.primary)?.text??e.state:null,s=(t.controls??t.marker?.controls??[]).filter(ji).filter(t=>this._planEntityAvailable(t));return W``}_cardEntities(t){const e=this._planHass,i=[],s=new Set,n=t=>{if(!t||s.has(t)||!e.states[t])return;const n=e.entities[t];if("config"===n?.entity_category||"diagnostic"===n?.entity_category)return;s.add(t);const o=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(o)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(o)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(o)&&i.push({eid:t,kind:"value"})};for(const i of nn(e,[t]))n(i.eid);t.primary&&n(t.primary);for(const e of t.entities)n(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this._planEntityAvailable(t)&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?Mi(this.hass,t.primary)?.text??e.state:null,s=(t.controls??t.marker?.controls??[]).filter(Vi).filter(t=>this._planEntityAvailable(t));return W`this._infoCard=null}>
`}_cardEntities(t){const e=this._planHass,i=[],s=new Set,n=t=>{if(!t||s.has(t)||!e.states[t])return;const n=e.entities[t];if("config"===n?.entity_category||"diagnostic"===n?.entity_category)return;s.add(t);const o=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(o)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(o)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(o)&&i.push({eid:t,kind:"value"})};for(const i of Qs(e,[t]))n(i.eid);t.primary&&n(t.primary);for(const e of t.entities)n(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this._planEntityAvailable(t)&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?wi(this.hass,t.primary)?.text??e.state:null,s=(t.controls??t.marker?.controls??[]).filter(ji).filter(t=>this._planEntityAvailable(t));return W``}_cardEntities(t){const e=this._planHass,i=[],s=new Set,n=t=>{if(!t||s.has(t)||!e.states[t])return;const n=e.entities[t];if("config"===n?.entity_category||"diagnostic"===n?.entity_category)return;s.add(t);const o=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(o)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(o)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(o)&&i.push({eid:t,kind:"value"})};for(const i of nn(e,[t]))n(i.eid);t.primary&&n(t.primary);for(const e of t.entities)n(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this._planEntityAvailable(t)&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?Mi(this.hass,t.primary)?.text??e.state:null,s=(t.controls??t.marker?.controls??[]).filter(Vi).filter(t=>this._planEntityAvailable(t));return W`this._infoCard=null}>
`}_cardEntities(t){const e=this._planHass,i=[],s=new Set,n=t=>{if(!t||s.has(t)||!e.states[t])return;const n=e.entities[t];if("config"===n?.entity_category||"diagnostic"===n?.entity_category)return;s.add(t);const o=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(o)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(o)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(o)&&i.push({eid:t,kind:"value"})};for(const i of Qs(e,[t]))n(i.eid);t.primary&&n(t.primary);for(const e of t.entities)n(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this._planEntityAvailable(t)&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?wi(this.hass,t.primary)?.text??e.state:null,s=(t.controls??t.marker?.controls??[]).filter(ji).filter(t=>this._planEntityAvailable(t));return W``}_cardEntities(t){const e=this._planHass,i=[],s=new Set,n=t=>{if(!t||s.has(t)||!e.states[t])return;const n=e.entities[t];if("config"===n?.entity_category||"diagnostic"===n?.entity_category)return;s.add(t);const o=t.split(".")[0];["light","switch","fan","humidifier","siren","input_boolean"].includes(o)?i.push({eid:t,kind:"toggle"}):["cover","valve","lock","climate","media_player","vacuum","water_heater"].includes(o)?i.push({eid:t,kind:"open"}):["sensor","binary_sensor","number","select"].includes(o)&&i.push({eid:t,kind:"value"})};for(const i of nn(e,[t]))n(i.eid);t.primary&&n(t.primary);for(const e of t.entities)n(e);return i.slice(0,12)}_cardToggle(t){const e=t.split(".")[0];"lock"!==e&&"alarm_control_panel"!==e&&this._planEntityAvailable(t)&&this.hass.callService("homeassistant","toggle",{entity_id:t}).catch(t=>this._showToast(this._t("toast.error",{err:this._errText(t)})))}_renderInfoCard(){const t=this._infoCard,e=t.primary?this.hass.states[t.primary]:void 0,i=e?Mi(this.hass,t.primary)?.text??e.state:null,s=(t.controls??t.marker?.controls??[]).filter(Vi).filter(t=>this._planEntityAvailable(t));return W`this._infoCard=null}>
- ${this._renderCardPreview(Ti(this._curSpaceCfg).cardFontScale,this._roomNameScale,this._roomLabelScale)}
+ ${this._renderCardPreview(Ii(this._curSpaceCfg).cardFontScale,this._roomNameScale,this._roomLabelScale)}
- `}}mc.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},mc.ZOOM_MAX=8,mc.ZOOM_MIN=1/3,mc._touchSeen=!1,mc._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,mc.styles=[_t,Sl],customElements.get("houseplan-card")||customElements.define("houseplan-card",mc),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${Ul} `,"background:#3ea6ff;color:#04121f;font-weight:700","");
+ `}}Mc.properties={_hdrH:{state:!0},_booting:{state:!0},_bootFading:{state:!0},_bootSoft:{state:!0},_tapConfirm:{state:!0},hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_hoverRoom:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_wallDialog:{state:!0},_drawWallField:{state:!0},_activeDraftId:{state:!0},_physicalSel:{state:!0},_physicalDialog:{state:!0},_physicalDrag:{state:!0},_physicalRotate:{state:!0},_duplicateColumnId:{state:!0},_rszSel:{state:!0},_rszLive:{state:!0},_opMeasure:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_openWallAnchor:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorEraseConfirm:{state:!0},_decorTextDialog:{state:!0},_decorShapeDialog:{state:!0},_backdropDialog:{state:!0},_furnPalette:{state:!0},_bdDrag:{state:!0},_dtBox:{state:!0},_dtDrag:{state:!0},_kioskDialog:{state:!0},_vacFit:{state:!0},_vacAllCamerasFor:{state:!0},_vacCalConfirm:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_alignDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},Mc.ZOOM_MAX=8,Mc.ZOOM_MIN=1/3,Mc._touchSeen=!1,Mc._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,Mc.styles=[yt,Nl],customElements.get("houseplan-card")||customElements.define("houseplan-card",Mc),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info(`%c HOUSEPLAN-CARD %c v${Ql} `,"background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8bed601f..9f370ef2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# House Plan architecture
-Updated: 2026-08-09 (v1.60.3). The repository = a HACS integration (category **Integration**)
+Updated: 2026-08-09 (v1.61.0-beta.1). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
@@ -121,6 +121,23 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
source-glow fill mode: a light pool is spatial information, not a replacement
for the universal working-state plate.
+### Vacuum telemetry authority
+
+`src/vacuum.ts` owns pure normalization and arbitration. Telemetry paths are
+always `Pt[][]`; non-drawable segments are discarded before the 64-segment and
+4000-point budgets, and the renderer emits one SVG path with independent `M`
+commands. `resolveCurrentVacPath()` is the only integration → server → local
+priority decision. `resolveVacSource()` is sticky for saved sources and limits
+automatic selection to compatible entities on the same HA device; the card
+adds registry status through the shared `resolveHaBindingStatus()` authority.
+
+Room auto-calibration uses the same shoelace `areaCentroid()` for plan polygons
+and robot outlines. Residuals are converted through resolved grid pitch and
+cell centimetres; matrices above the 40 cm threshold remain proposals until an
+explicit UI decision. `trails.py` owns persistent current/previous runs and a
+refresh-time `(marker, source)` health state whose missing/disabled reason is
+mutable and warning-deduplicated.
+
## Sizes
`icon_size` in the config = **% of the visible plan area width** (default 2.5). Implementation:
@@ -180,6 +197,22 @@ they have no discovery source.
## Server-side configuration (current shape, v1.51+)
+### Persisted colour boundary
+
+Every colour stored in Houseplan config has exactly one representation:
+`#RRGGBB` (case-insensitive hexadecimal digits, no whitespace or CSS
+functions). `src/color.ts` owns the frontend resolver and
+`custom_components/houseplan/validation.py::_COLOR` owns the write schema.
+Resolvers apply a safe default again at render time because an old, imported or
+manually edited store is returned without a destructive read migration.
+
+Home Assistant `rgb_color` is live state rather than persisted user input. It
+is accepted only as three finite numeric channels, clamped/rounded to 0–255 and
+emitted by the application as canonical `rgb(R, G, B)`. The final inline-style
+boundary accepts only stored hex or that generated form. Supporting arbitrary
+CSS colour syntax would require a separate product/security decision; it must
+not be added to an individual sink.
+
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","plan_aspect",
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index d82c5ec7..f0f05c04 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,26 @@
## Unreleased
+## v1.61.0-beta.1 — 2026-08-09
+
+- Stored plan colours now use one strict `#RRGGBB` contract at both API and
+ render boundaries. Malformed legacy/imported values safely fall back instead
+ of being able to add CSS declarations; Home Assistant RGB light colours stay
+ supported through a separately generated numeric form.
+- Vacuum integration coverage is now explicit and diagnosable. One sticky,
+ order-independent source resolver supports same-device discovery and a lazy
+ picker for registry-less map cameras, distinguishes missing, disabled,
+ unavailable and limited-permission states, and never leaks stale disabled
+ telemetry onto the plan.
+- Xiaomi Cloud Map Extractor multi-subpath trails retain real gaps; path
+ arbitration, point budgets and map IDs are deterministic across frontend and
+ backend. Room calibration uses area centroids on both sides, keeps bbox-only
+ integration dialects through a final bbox-centre fallback, and a physical
+ error above 40 cm now requires Apply or manual fitting before config changes.
+- Server trail health reports one deduplicated warning per missing/disabled
+ source incident and records recovery. Documentation now states the verified
+ Dreame/XCME/Valetudo capability matrix; Roomba remains Stage 2.
+
## v1.60.3 — 2026-08-09
- Editor toolbars now keep their working area stable: selection actions,
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index c7ae9900..6e24e99f 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,28 @@
## Не выпущено
+## v1.61.0-beta.1 — 2026-08-09
+
+- Сохраняемые цвета плана теперь используют единый строгий формат `#RRGGBB` на
+ границах API и рендера. Некорректные старые или импортированные значения
+ безопасно заменяются штатным цветом и не могут добавить CSS declarations;
+ динамические RGB-цвета ламп Home Assistant продолжают работать через
+ отдельную числовую генерацию.
+- Покрытие интеграций пылесосов стало явным и диагностируемым. Единый
+ закрепляемый и независимый от порядка resolver автоматически находит
+ источник в устройстве и позволяет лениво выбрать registry-less камеру карты,
+ различает удаление, деактивацию, недоступность и ограниченные права и не
+ выводит на план старую телеметрию деактивированного источника.
+- Многосегментный путь Xiaomi Cloud Map Extractor сохраняет настоящие разрывы;
+ арбитраж пути, лимиты точек и ID карты детерминированы во фронтенде и
+ бэкенде. Автокалибровка использует площадные центроиды с обеих сторон и
+ сохраняет bbox-only диалекты через последний fallback на центр bbox, а
+ физическая ошибка более 40 см требует явного применения или ручной подгонки
+ до изменения настроек.
+- Серверный recorder выдаёт одно дедуплицированное предупреждение на инцидент
+ удаления/деактивации источника и фиксирует восстановление. Документация
+ содержит честную матрицу Dreame/XCME/Valetudo; Roomba остаётся этапом 2.
+
## v1.60.3 — 2026-08-09
- Панели редакторов больше не меняют рабочую область: действия выделения,
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 10682f38..0efb8825 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -21,17 +21,17 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
-| Version | **v1.60.3** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the v1.60.3 beta line after the complete local release gate |
-| Current local cycle | v1.60.3 stabilises editor context trays, touch pinch protection, warm tab returns and thick-wall opening tunnels. The final fixes keep furniture previews inside their buttons, centre the editor Close control and remove SVG hairlines between atomic opening-tunnel strips. HP-PERF-01 uses same-runner base-vs-candidate reports and a blocking CI gate; HP-QA-01 covers the adaptive tray surfaces in its deterministic matrix. |
+| Version | **v1.61.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — pre-release candidate with Stage 1 vacuum integration coverage and the strict stored-colour contract |
+| Current local cycle | Stable v1.60.3 is published. v1.61.0-beta.1 implements deterministic multi-subpath vacuum telemetry, sticky source resolution and XCME picker/diagnostics, physical calibration confirmation, owner-approved bbox-only final room-anchor fallback and backend source-health lifecycle. Issue #21 adds one strict persisted-colour contract and render-time protection for legacy/imported config. The targeted pre-release gate is green; publication waits for the mandatory exact-SHA GitHub Validate run. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
-| CI | v1.60.3 passed the complete local frontend, pure-backend and 118-scenario browser gate. Exact-SHA Ubuntu Validate remains mandatory for the full HA harness and broader matrix; `release.yml` withholds assets until every matching run finishes green |
+| CI | v1.61.0-beta.1 passed its targeted local gate: 198 frontend tests, 114 pure-backend tests, both vacuum browser smokes plus decor/device-preview/static-icon regression smokes, and a production build whose three bundle snapshots have the same SHA-256. Exact-SHA Ubuntu Validate remains mandatory for the full HA harness and broader matrix; `release.yml` withholds assets until every matching run finishes green |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: Node unit (`npm test`: frontend pure modules + tooling policy), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch. All three editors are desktop-first; touch editing is best effort and may be awkward, reduced or absent when parity is expensive. `docs/TOUCH-SUPPORT.md` defines the non-negotiable safety floor and documentation/test rules |
-| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+| Vacuums | Live puck, server-side trails and fit calibration are shipped. The local v1.61 Stage 1 contract in docs/VACUUM.md adds explicit Dreame/XCME/Valetudo coverage, registry-less source selection, capability diagnostics, path-gap preservation and source-health warnings. Roomba remains Stage 2 |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand`. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | `docs/SCOPE.md` is the feature guard rail; `docs/TOUCH-SUPPORT.md` is the input-support contract — check both before accepting interaction work |
diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md
index 72bc0607..565e20b9 100644
--- a/docs/USER-GUIDE.ru.md
+++ b/docs/USER-GUIDE.ru.md
@@ -779,16 +779,36 @@ Glow и заливка «Свет» продолжают работать по
## 16. Роботы-пылесосы
-Живая позиция доступна для маркера пылесоса, если одна из его сущностей отдаёт `vacuum_position` или `robot_position`. Поддерживаются распространённые соглашения Xiaomi Cloud Map Extractor, dreame-vacuum и Valetudo-подобных интеграций.
+Живая позиция доступна для маркера пылесоса, если источник отдаёт конечные
+координаты `vacuum_position` или `robot_position`. Поддерживаются Xiaomi Cloud
+Map Extractor, dreame-vacuum (Tasshack) и Valetudo-подобные камеры. Roomba со
+строковым атрибутом `position` в текущий этап не входит.
+
+В настройках устройства блок «Живая позиция» показывает источник, интеграцию,
+статус, наличие позиции, число комнат, путь и ID карты. По умолчанию источник
+выбирается среди сущностей того же устройства независимо от порядка в реестре.
+Если XCME-камера не относится к устройству, откройте «Выбрать источник» → «Все
+камеры» и выберите её явно. Глобальные камеры никогда не выбираются
+автоматически.
+
+Сохранённый источник закреплён: при удалении, деактивации, временной
+недоступности или недостаточных правах он не подменяется другой сущностью.
+Деактивированный/недоступный источник не может вывести на план старые
+атрибуты. После восстановления той же сущности работа возобновляется сама.
+
+Для Xiaomi Cloud Map Extractor включите атрибуты `vacuum_position`, `rooms`,
+`path` и `map_name`; точная YAML-подсказка показывается в самом диалоге.
### Калибровка
| Режим | Требования | Результат |
|---|---|---|
-| Автоматическая | В карте робота и плане совпадают минимум три названия комнат | Вычисляется аффинное преобразование и показывается ошибка совмещения |
+| Автоматическая | В карте робота и плане совпадают минимум три названия комнат | Вычисляется аффинное преобразование; при ошибке до 40 см оно сохраняется сразу |
| Ручная | Доступна карта/контуры робота | Полупрозрачную карту можно двигать, масштабировать, поворачивать на 90° и зеркалить |
Для каждого `map_id` хранится собственная калибровка, поэтому многоэтажный робот может работать с несколькими пространствами.
+Если максимальное расхождение больше 40 см, настройки не меняются до выбора
+«Применить». Можно вместо этого открыть ручную подгонку или отменить операцию.
### Позиция и путь
@@ -804,7 +824,10 @@ Glow и заливка «Свет» продолжают работать по
- Путь хранится на сервере, переживает перезагрузку карточки и виден другим клиентам.
- Хранятся текущая и предыдущая уборка; сервер ограничивает сырой путь 2000 точками с прореживанием, клиентский живой буфер — 600 точками.
- Позиция старше 60 секунд визуально считается устаревшей.
-- Разрыв данных более 10 секунд не соединяется ложной длинной линией.
+- Разрывы integration path сохраняются отдельными участками и не соединяются
+ ложной линией. Хранятся максимум 64 рисуемых участка и 4000 точек.
+- Для текущего пути приоритет таков: путь интеграции → серверный текущий run →
+ локальный буфер. Пустой или одноточечный участок приоритет не перехватывает.
## 17. Киоск-режим
diff --git a/docs/VACUUM.md b/docs/VACUUM.md
index 9e0604a3..772b13df 100644
--- a/docs/VACUUM.md
+++ b/docs/VACUUM.md
@@ -1,166 +1,151 @@
-# Live robot vacuums on the plan — the spec (source of truth)
+# Live robot vacuums on the plan
-Status: approved by the owner 2026-07-31. Scope decisions final: all
-three Tier-A adapters in P1, the trail ships in P1, the marker's placed
-position IS the dock, and tap-to-clean is out — display only, no
-commands (owner: «не нужно вообще»).
+Status: implemented contract for the v1.61 development cycle. Stage 1 covers
+Tier-A integrations. Roomba string-position support remains a separate Stage 2
+issue and is not claimed here.
-## Principle
+## What the user sees
-The device marker «Пылесос» NEVER moves: it stands where the user
-placed it — that is the base/dock — with its normal badge, states and
-tap actions. While the robot cleans, a SECOND visual appears: a round
-puck (the vacuum icon in a circle), no badge plate, with a soft
-pulse, driving around the plan on live coordinates. Owner's exact
-wording: «Иконка движущегося пылесоса должна быть круглой, без
-подложки и с легкой пульсацией. Основное устройство "Пылесос" при этом
-остается всегда на своем месте (база)». The puck is not clickable UI
-chrome duplicating the device — a tap on it opens the same more-info as
-the base marker. When the robot docks, the puck drives home and
-dissolves into the base marker.
+The placed vacuum marker is the dock and never moves. While the vacuum is in
+`cleaning`, `returning` or `on`, a second round puck follows the live position.
+Clicking the puck opens the vacuum's HA more-info dialog. A hidden, deleted,
+HA-disabled or `static_icon` marker has no puck, trail or room overlay.
-## Data tiers (auto-detected, zero user input)
+## Integration coverage
-- **Tier A — coordinates + own calibration data.** `vacuum_position
- {x,y,angle}` (+ `calibration_points`, rooms, sometimes `path`):
- Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo camera
- (sca075). All three ship in P1.
-- **Tier B — bare coordinates.** Roomba core (`position {x,y,theta}`),
- raw Valetudo MQTT, template sensors. Works after manual calibration.
- P3.
-- **Tier C — current room only.** `current_room` / `current_segment`.
- The room being cleaned gets a soft fill pulse + a 🤖 badge in the
- room card; no puck. Segment→room matched by name, manual override in
- the dialog.
-- **Tier D — nothing.** Today's behaviour; yellow badge on the base
- marker while cleaning per the «yellow = working right now» principle.
+| Integration family | Position | Rooms / auto-calibration | Integration path | Map ID | Source discovery |
+|---|---:|---:|---:|---|---|
+| Xiaomi Cloud Map Extractor | Yes, when the attributes below are enabled | Yes | Yes, including `path.path` subpaths | `map_name` when exposed | Usually explicit camera selection |
+| dreame-vacuum (Tasshack) | Yes | Yes; explicit room `x/y` is the anchor | No | Vacuum `selected_map` fallback | Automatic on the same HA device |
+| Valetudo camera conventions | Yes | Yes when room data is exposed | No | Often `default`; no stable multi-floor promise | Automatic on the same HA device |
+| Roomba core `position` string | Not in Stage 1 | No | No | — | Stage 2 |
-Tiers degrade gracefully: coords gone → room highlight; that gone too →
-just the base marker.
+For Xiaomi Cloud Map Extractor the camera must expose:
-## Coordinate binding
+```yaml
+attributes:
+ - vacuum_position
+ - rooms
+ - path
+ - map_name
+```
-Affine transform (translate+rotate+scale+mirror), 6 numbers, least
-squares over ≥3 point pairs. Stored per robot map:
-`marker.vacuum.calibration[map_id]` — multi-floor robots get one matrix
-per map; an active map without a matrix falls back to Tier C.
+The card recognises finite `vacuum_position` or `robot_position` objects. A
+generic `position` string on an unrelated sensor or tracker is never treated as
+vacuum telemetry.
-- **Path 1, auto-calibration by rooms (the default):** Tier-A adapters
- expose the robot's room list with coordinates; our rooms carry HA
- area bindings. Match by name, take centroids of ≥3 matches, solve,
- show a live preview («робот сейчас здесь») — one click to confirm.
-- **Path 2, the fit panel (replaced the 3-point wizard, owner call
- 2026-07-31):** the robot's rooms render as a translucent dashed ghost
- over the plan; the user DRAGS the ghost into place and stretches it by
- its four corner handles (uniform scale about the opposite corner, like
- a graphics-editor frame). Rotation is quarter-turn buttons, mirror is
- one toggle — both re-anchor about the ghost centre. Mirror defaults ON:
- every robot map seen so far has Y flipped versus the screen. No numeric
- fields; the old park-the-robot-three-times wizard is gone entirely —
- it was the most fragile part of the feature.
-- The panel folds into the same stored 6-number matrix
- (S·R(rot)·mirror + offset); legacy matrices reopen in the panel with
- the rotation snapped to the nearest quarter.
+## Source resolution and diagnostics
-## Puck behaviour
+The device dialog has one diagnostic block and one source picker. It reports
+the selected entity, integration, status, position, room count, integration
+path and map ID. Automatic mode considers compatible entities attached to the
+same HA device. Candidate order cannot change the result; a compatible camera
+outranks a non-camera candidate. The collapsed **All cameras** section is
+scanned only when opened and is never used for automatic binding.
-Appears when the robot leaves `docked` AND live coords flow; CSS
-interpolation ~1.2 s between updates; a data gap over 10 s teleports
-without animation (no gliding through walls on sparse cloud updates).
-Soft pulse always while visible (respects prefers-reduced-motion: the
-pulse freezes, position still animates). Size: same --icon-size
-system as devices, circle, transparent background, no badge plate.
-Stale coords (>60 s while «cleaning»): puck freezes and dims. On
-`docked`/`idle`-at-base the puck rides home and fades out. Hidden
-markers render neither base nor puck (general hidden rules). Works in
-the full card, static card and kiosk; in editors no puck — the base
-marker is edited as usual.
+Choosing a candidate stores `marker.vacuum.source`. A stored source is pinned:
+it is never silently replaced when it becomes missing, disabled, unavailable,
+unverified or unsupported. Restoring the same HA entity restores operation
+without editing the plan.
-Deleting a vacuum marker is stronger than hiding it: its binding tombstone
-stops all plan rendering/aggregation, its layout is removed, and both stored
-server runs (current and previous) are erased. Re-adding starts uncalibrated at
-a fresh position; the old trail is not resurrected.
+| Status | Meaning and behaviour |
+|---|---|
+| `ok` | Valid live position is available |
+| `unsupported` | Entity exists but has no valid position; its rooms/path may still be usable |
+| `unavailable` | Exact HA entity exists but is currently unavailable; stale attributes are not rendered |
+| `disabled` | Entity is disabled in HA; stale attributes are not rendered |
+| `missing` | Authoritative registry and live states both prove that the saved entity is absent |
+| `unverified` | Current HA permissions cannot prove existence or removal; the pin is preserved |
+| `none` | No source was selected or found |
-## Trail (ships in P1)
+Registry-less YAML entities are valid: an exact live HA state is positive
+evidence even when a full entity-registry response has no row. A disabled row
+still wins. A selected camera without position data gets the XCME attribute
+hint; arbitrary unselected cameras do not.
-- Integration `path` when available (Map Extractor) — transform and
- draw as-is, includes history from before the card was opened.
-- Otherwise self-recorded: client-side ring buffer, ~600 points with
- Douglas-Peucker thinning, one SVG polyline.
-- Style fixed, no options: cartography casing — a dark translucent halo under a light core. Neutral (pure black/white alphas) and readable over any room fill; blend modes were rejected: each has a blind luminance where the line vanishes, and mix-blend-mode is costly on old kiosk WebViews. Lifecycle:
- appears on cleaning start, lives until dock + 10 min, dissolves;
- `docked → cleaning` clears the old trail. Recorded SERVER-SIDE by the integration itself (trails.py): it watches the source entity, so the path records with zero cards open and every screen sees the same line. Stored per marker: the current run and ONE previous run (owner call — users compare cleaned vs uncleaned). The previous run renders at 40% opacity even at rest; the current run trims its live tail while moving. Rotation on run start or map switch; 2000-point cap with decimation; store writes debounced 10 s; houseplan_trail_updated notifies live cards.
-- Marker option «Показывать след уборки», on by default where data
- exists.
+## Calibration
-## Setup UX
+The stored transform is a six-number affine matrix per map:
+`marker.vacuum.calibration[map_id]`. Existing matrices are not migrated.
-A «Живая позиция» section in the device dialog, vacuum markers only:
-status line (which source was found / nothing), the «Настроить
-автоматически» button (Tier A — the integration reports a room list),
-the «Подогнать вручную» button opening the fit panel (drag the ghost
-map, stretch by the corners, rotate/mirror), one «Живая позиция на
-плане» checkbox (on by default), the «Показывать путь робота» select
-(never / while cleaning / always), and for multi-map robots a list of
-calibrated maps. The source entity is discovered via the device
-registry — no YAML, no entity pickers. The section works before the
-marker is ever saved: the first vacuum edit materialises the marker
-itself (HP-1540-01).
+- **Automatic:** at least three room names must match. Robot anchors use
+ `cx/cy`, then `center.x/y`, then explicit `x/y`, then the polygon area
+ centroid of `outline`, and finally the centre of a complete `x0/y0/x1/y1`
+ bounding box. The bbox tier is a compatibility fallback for integrations
+ that expose no better room geometry. Plan rooms use the area-centroid
+ definition.
+- **Manual fit:** move and uniformly resize the translucent robot-room map;
+ quarter-turn and mirror controls re-anchor around its centre.
-## Storage & validation
+The automatic residual is the worst matched-room error converted to physical
+centimetres from the current grid. At `≤ 40 cm` the matrix is saved normally.
+At `> 40 cm` nothing is saved until the user explicitly chooses **Apply**.
+**Fit manually** opens the proposal in the fit overlay; **Cancel** leaves the
+saved configuration byte-for-byte unchanged.
-`marker.vacuum: { live, trail, room_highlight, source, calibration:
-{[map_id]: [6 numbers]}, segment_map: {[segment]: room_id} }` — all
-optional (old configs stay valid), matrices are 6 finite numbers,
-backend-validated. Calibration is server-side state shared by every
-screen, like the whole plan.
+Map ID uses one nullish chain and deliberately ignores volatile values such as
+`vacuum_json_id`:
-## Cases covered explicitly
+`map_name → current_map → source map_index → source selected_map → vacuum selected_map → default`
-Two vacuums (independent markers, calibrations, pucks); one robot on
-two floors (maps ↔ spaces, the puck only renders in the space whose map
-is active); robot outside the plan (±4 canvas bounds allow it, trail
-clipped by viewport); integration restart changes map_id (rebind by
-room-list match, else ask to recalibrate); user redraws rooms (the
-matrix does not depend on rooms); demo stand gets a scripted synthetic
-robot as the showcase.
+Numeric `0`, string `"0"` and an empty string are valid IDs.
-## Out of scope
+## Paths and trails
-Commands of any kind (owner decision: display only) — no tap-to-clean,
-no zone sending. No-go zones, cleaned-area polygons, cleaning history,
-multi-robot collision avoidance — v2 candidates.
+The current visible path has one authority:
-## Trail display modes
+1. drawable integration path;
+2. drawable current server run;
+3. drawable local runtime buffer;
+4. no path.
-`marker.vacuum.trail_mode`: `never` | `cleaning` (default — the line hides
-the instant the run ends) | `always` (the only mode that also draws the
-previous run, at 40% opacity). The legacy boolean `trail` still maps in
-(`false` → never). Recording is independent of the mode: the server always
-records, the mode only decides what is drawn.
+An integration path can contain several subpaths. They are transformed and
+thinned independently and rendered with separate SVG `M` commands, so a data
+gap never becomes a long false line. Invalid points and segments shorter than
+two points are discarded before limits are applied. The newest 64 drawable
+subpaths are kept, with at most 4000 total points; both endpoints of every kept
+subpath survive deterministic proportional thinning.
-The device-level `display: static_icon` is a stronger visual override: it hides
-the moving puck, current/previous trails and room highlight regardless of the
-vacuum trail mode. It does not delete calibration or server history; changing
-back to a dynamic device display restores the applicable live overlays.
+| Display mode | While moving | After movement stops |
+|---|---|---|
+| `never` | Hidden | Hidden |
+| `cleaning` (default) | Current path | Hidden immediately |
+| `always` | Current path | Current integration path or stored current/previous runs |
-The last segment is a rAF-driven tip line whose endpoint is glued to the
-puck's animated centre every frame, so the path pours out from under the
-icon instead of popping in when the next telemetry point lands.
+Server trails are recorded by `custom_components/houseplan/trails.py`, even
+with no card open. It stores current and one previous run in raw robot
+coordinates. Server recording is independent of the display mode. The source
+health monitor checks saved marker/source pairs on config refresh and restart:
+one warning is emitted for a missing/disabled incident, reason changes are
+deduplicated, and another warning is possible only after proven recovery.
+Detection is intentionally refresh/restart based in Stage 1; no extra entity
+registry subscription is installed.
-## Phases
+## Storage and lifecycle
-- **P1:** adapter framework + all three Tier-A adapters +
- auto-calibration by rooms + manual 3-point wizard + the puck + trail (both sources).
-- **P2 (next):** Tier C room highlight + the demo-stand scripted robot.
-- **P3:** Tier B zoo (Roomba, raw Valetudo MQTT), Deebot, multi-map
- polish by feedback.
+```text
+marker.vacuum = {
+ live?, trail?, trail_mode?, source?,
+ calibration?: { [map_id]: [a,b,c,d,e,f] },
+ room_highlight?, segment_map?
+}
+```
-## Shipped in P1
+All fields are optional and old plans remain readable. Hiding retains the
+configuration. Deleting a vacuum marker removes its layout and server trails,
+creates the normal removal tombstone and makes the HA device available for a
+fresh add without resurrecting old runs.
-Adapters for the three Tier-A integrations, auto-calibration by room
-names, the drag-and-stretch fit panel, the puck, server-side trails
-(current + previous run) with the three display modes. Verified against a
-live Dreame X50 Master: room centres arrive as plain x/y, the active map
-name lives on the vacuum entity (`selected_map`), and the robot's Y axis
-is flipped versus the screen — hence mirror-on by default.
+## Troubleshooting
+
+1. Open the vacuum's device settings and read the source diagnostics.
+2. If no same-device source is found, open **Choose source → All cameras** and
+ select the actual map camera.
+3. For XCME, enable the four attributes shown above and reload that entity.
+4. Ensure the active map has a calibration and the vacuum state is
+ `cleaning`, `returning` or `on`.
+5. A disabled source must be re-enabled in HA or replaced explicitly; House
+ Plan will not guess a replacement.
+
+Commands, zones/no-go polygons, cleaning-history UI and Roomba string parsing
+are outside Stage 1.
diff --git a/docs/specs/006-vacuum-xcme-path.md b/docs/specs/006-vacuum-xcme-path.md
new file mode 100644
index 00000000..249b76db
--- /dev/null
+++ b/docs/specs/006-vacuum-xcme-path.md
@@ -0,0 +1,51 @@
+# ТЗ #6 — XCME: многосегментный путь без ложных перемычек
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/6
+- Приоритет: P1
+- Статус ТЗ: реализовано по HP-VAC-02 rev.7; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Родительский контракт: `docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md`, §4.1
+
+## Цель
+
+Корректно принимать Xiaomi Cloud Map Extractor `attributes.path.path` как набор
+независимых траекторий. Разрыв между подмаршрутами не должен превращаться в
+нарисованный переезд робота.
+
+## Требования
+
+1. Канонический тип frontend — `VacPath = Pt[][]`, где каждая внутренняя
+ последовательность является отдельным drawable subpath.
+2. Парсер принимает плоский legacy path, `path.points` и `path.path`.
+3. Невалидные точки отбрасываются; subpath короче двух валидных точек не
+ участвует в отображении и арбитраже источников.
+4. Сначала фильтруются недорисовываемые сегменты, затем остаются последние 64.
+5. Общий бюджет — 4000 точек. У каждого subpath сохраняются обе крайние точки;
+ остаток распределяется largest-remainder, при равенстве выигрывает более
+ старый из оставшихся subpath.
+6. Рендер формирует один SVG `path` с отдельной командой `M` на каждый subpath,
+ без `L` между ними.
+7. Один resolver выбирает drawable integration path, затем server trail, затем
+ локальный runtime trail. Пустой/одноточечный integration path не блокирует
+ следующий источник.
+8. При скрытии terminal target изменяется только последний drawable subpath.
+
+## Совместимость и ограничения
+
+- Сохранённый server trail и формат backend не мигрируют.
+- Dreame/Valetudo integration path не заявляется без подтверждённого формата.
+- Никаких CSS-фильтров или соединительных пунктирных линий между subpath.
+
+## Проверки
+
+- unit: все поддерживаемые формы, невалидные точки, cap после фильтрации;
+- unit: точный бюджет, сохранение концов и old→new tie-break;
+- unit: drawable arbitration и обрезка последнего target;
+- browser: несколько `M`, отсутствие моста между удалёнными сегментами;
+- performance: 64/4000 остаются в текущем render budget.
+
+## Приёмка
+
+- XCME history виден полностью в пределах бюджета;
+- визуальных перемычек между разрывами нет;
+- fallback trail появляется, если integration path не рисуем;
+- flat legacy path выглядит как до изменения.
diff --git a/docs/specs/007-vacuum-valetudo-room-outlines.md b/docs/specs/007-vacuum-valetudo-room-outlines.md
new file mode 100644
index 00000000..1657ecdb
--- /dev/null
+++ b/docs/specs/007-vacuum-valetudo-room-outlines.md
@@ -0,0 +1,51 @@
+# ТЗ #7 — Valetudo: комнаты из outline-полигонов
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/7
+- Приоритет: P1
+- Статус ТЗ: реализовано по rev.7 с прямым owner override F6; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Родительский контракт: `docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md`, §4.2
+
+## Цель
+
+Использовать комнаты MQTT Vacuum Camera/Valetudo вида
+`{id: {name, outline: [[x,y], ...]}}` для ghost-предпросмотра и
+автокалибровки по названиям.
+
+## Геометрический контракт
+
+1. Приоритет якоря: атомарная пара `cx/cy` → `center.x/center.y` → `x/y` →
+ area centroid валидного outline → центр полного bbox `x0/y0/x1/y1`.
+2. Поля разных уровней никогда не смешиваются в одну пару.
+3. Центроид outline считается формулой shoelace. Повторённая замыкающая точка
+ удаляется перед вычислением.
+4. Для нулевой площади используется детерминированное среднее валидных вершин;
+ нечисловая вершина делает outline непригодным.
+5. Bbox вычисляется по вершинам или берётся из полной атомарной четвёрки
+ `x0/y0/x1/y1`; перепутанные min/max нормализуются.
+6. По решению владельца после code-review F6 bbox-center возвращён последним
+ compatibility fallback. Он не должен побеждать ни явный центр, ни
+ area-centroid outline, но bbox-only комнаты больше не пропускаются.
+7. Plan-side якорь считается тем же `areaCentroid()` по реальному room polygon,
+ включая legacy rectangle через `roomPoly()`.
+
+## UX и деградация
+
+- Автокалибровка доступна только при минимум трёх совпавших пригодных именах.
+- Диагностика показывает общее число robot rooms и число совпавших имён.
+- Непригодный outline не ломает остальные комнаты и не создаёт координаты 0/0.
+- High residual свыше 40 см остаётся предложением до явного подтверждения.
+
+## Проверки
+
+- L-образный polygon доказывает отличие area centroid от vertex average;
+- clockwise/counter-clockwise, закрытый/незакрытый, zero-area, butterfly;
+- атомарный приоритет пар и bbox-only fallback с нормализацией min/max;
+- browser: Valetudo ghost, matching names и confirm-flow high residual.
+
+## Приёмка
+
+- outline-only Valetudo rooms участвуют в fit и auto-calibration;
+- bbox-only комнаты сохраняют ghost и могут участвовать в auto-calibration;
+- форма ghost и bbox конечны;
+- плохая комната не блокирует хорошие;
+- конфиг не меняется до подтверждения грубой калибровки.
diff --git a/docs/specs/008-vacuum-support-docs-xcme-hint.md b/docs/specs/008-vacuum-support-docs-xcme-hint.md
new file mode 100644
index 00000000..b5975110
--- /dev/null
+++ b/docs/specs/008-vacuum-support-docs-xcme-hint.md
@@ -0,0 +1,51 @@
+# ТЗ #8 — Матрица поддержки пылесосов и подсказка XCME
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/8
+- Приоритет: P1
+- Статус ТЗ: реализовано по HP-VAC-02 rev.7; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Родительский контракт: HP-VAC-02 §5.1 и §5.3
+
+## Цель
+
+Пользователь должен до настройки понимать фактическое покрытие интеграции, а
+при найденной Xiaomi Cloud Map Extractor без нужных атрибутов — видеть точную
+инструкцию вместо общего «источник не найден».
+
+## Документация
+
+В `docs/VACUUM.md`, пользовательском руководстве и обзорном README должна быть
+одна непротиворечивая матрица:
+
+| Семейство | Position | Rooms/auto-fit | Integration path | Map id | Discovery |
+|---|---|---|---|---|---|
+| XCME | при включённых attributes | да | да, включая subpaths | `map_name` при наличии | явный picker допустим |
+| Dreame/Mova | да | да, `x/y` anchor | нет в Stage 1 | `selected_map` fallback | same-device |
+| Valetudo camera | да | при outline rooms | нет в Stage 1 | обычно `default` | same-device |
+| Roomba string pose | Stage 2 | нет room fit | нет | — | Stage 2 |
+
+## Диагностика и подсказка
+
+1. Диалог показывает source, integration/platform, статус, position, rooms,
+ совпадения имён, path и map id.
+2. Registry-confirmed same-device XCME с отсутствующей position показывает
+ hint независимо от того, выбран ли этот source.
+3. Явно выбранная camera без position также показывает hint.
+4. Глобальная невыбранная camera не должна случайно активировать подсказку для
+ другого устройства.
+5. Hint содержит готовый YAML-фрагмент `vacuum_position`, `rooms`, `path`,
+ `map_name` и ссылку на документацию.
+6. Documentation action видим при любом source status, включая missing.
+
+## Локализация и доступность
+
+- Весь текст ru/en; YAML не переводится.
+- Hint имеет текстовый заголовок, код можно выделить, ссылка открывается в новой
+ вкладке с `noopener`.
+- Capability не сообщается только цветом.
+
+## Приёмка
+
+- новый пользователь XCME получает конкретный путь исправления;
+- матрица не обещает неподдерживаемый path;
+- диагностика и реальная доступность auto-calibration используют один matcher;
+- ссылки и термины одинаковы в README/User Guide/VACUUM.
diff --git a/docs/specs/010-vacuum-roomba-live-position.md b/docs/specs/010-vacuum-roomba-live-position.md
new file mode 100644
index 00000000..09889af6
--- /dev/null
+++ b/docs/specs/010-vacuum-roomba-live-position.md
@@ -0,0 +1,75 @@
+# ТЗ #10 — Roomba string pose: полный Stage 2
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/10
+- Приоритет: P2
+- Статус ТЗ: draft, UX-решение предложено ниже
+- Зависимость: HP-VAC-02 Stage 1/#58 должен быть стабилен
+
+## Цель
+
+Для pose-capable Roomba отобразить live puck и server trail из vacuum entity
+без camera/rooms, не обещая координаты моделям, которые их не предоставляют.
+
+## Shared parser
+
+Каноническая grammar для `attributes.position`:
+
+1. только string;
+2. trim whitespace;
+3. удалить не более одной внешней пары `(...)`, если обе скобки присутствуют;
+4. split `,` ровно на три token;
+5. trim → `Number`, все три finite;
+6. результат `{x,y,theta}`, иначе `null` без исключения.
+
+Одинаковый JSON corpus используется `src/vacuum.ts` и `trails.py`. Corpus:
+valid whitespace/sign/exponent/zero; missing/extra token; partial parens;
+empty/NaN/Infinity/hex/locale comma/object. Backend parser обязателен, потому
+что trail записывается при закрытой карточке.
+
+## Source arbitration
+
+- Position-capable camera того же device всегда выше Roomba string pose.
+- Явно pinned camera остаётся sticky даже missing; string не делает silent
+ fallback вокруг сохранённого выбора.
+- В automatic mode string source допустим только у primary `vacuum.*` того же
+ marker и только при валидном pose.
+- `position:null`/invalid → Tier D: dock marker и обычное управление, без puck,
+ trail и calibration error.
+
+## Калибровка без комнат — рекомендуемый two-mark flow
+
+1. Пользователь запускает/ставит робота в точку A, нажимает «Зафиксировать A» и
+ кликает соответствующую точку плана.
+2. Робот физически перемещается минимум на configurable raw-distance; аналогично
+ фиксируются B и plan B.
+3. По двум векторам решается uniform scale + rotation + translation.
+4. Mirror не выводится из двух точек, поэтому preview имеет явный toggle
+ «Отразить ось Y», default off. Theta preview помогает выбрать вариант.
+5. Совпадающие/слишком близкие raw или plan points блокируют Apply.
+6. Matrix остаётся proposal до Apply; Cancel не меняет config.
+
+Калибровка хранится в существующем `marker.vacuum.calibration[mapId]`; map id
+для string pose — stable `roomba-default`, пока интеграция не даёт иной
+доказанный идентификатор.
+
+## Runtime и backend trail
+
+Theta преобразуется с rotation/mirror matrix и нормализуется для puck. Recorder
+читает position из vacuum state, применяет существующие teleport/stale/run
+правила и пишет тот же trail format. HA restart mid-run продолжает current run.
+
+## Field protocol и проверки
+
+- две отдельные уборки, разнесённые точки, повороты и возврат на dock;
+- restart HA mid-run; стабильность origin/scale и отсутствие ложного previous;
+- parser parity fixture TS/Python;
+- source priority camera/string/pinned missing;
+- calibration Cancel/Apply/mirror/degenerate;
+- модель без pose и временный null;
+- beta release только после реального pose-capable Roomba protocol.
+
+## Приёмка
+
+Live puck и trail совпадают с физическим движением после two-mark fit; камера
+не уступает string source; сервер пишет путь без открытой карточки; неподдержанная
+Roomba деградирует без ошибок и ложных координат.
diff --git a/docs/specs/011-vacuum-source-health.md b/docs/specs/011-vacuum-source-health.md
new file mode 100644
index 00000000..d0a718f9
--- /dev/null
+++ b/docs/specs/011-vacuum-source-health.md
@@ -0,0 +1,46 @@
+# ТЗ #11 — Health lifecycle сохранённого vacuum source
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/11
+- Приоритет: P2
+- Статус ТЗ: реализовано в Stage 1; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Родительский контракт: HP-VAC-02 rev.7 §5.2
+
+## Цель
+
+Переименование, удаление или отключение сохранённого source не должно молча
+останавливать server trail. Backend сообщает дедуплицированный incident, UI —
+статус и прямой путь перевыбора, не делая silent rebind.
+
+## Backend state machine
+
+Ключ health state — `(marker_id, source_entity_id)`, reason хранится как mutable
+поле `missing|disabled` и не входит в identity.
+
+- registry row disabled → `disabled`;
+- registry row enabled или exact live state, включая `unavailable` и entity без
+ vacuum attributes → доказанное существование/recovery;
+- авторитетный registry без row и без exact state → `missing`;
+- недоступный/ограниченный registry и отсутствие state → `unverified`.
+
+`unverified` нейтрален: не создаёт, не закрывает и не изменяет incident.
+Первый failure после healthy/recovery пишет один warning. Смена reason внутри
+того же incident не пишет новый warning. Recovery очищает incident с info log.
+Удаление marker/rebind удаляет старый ключ.
+
+Нормативные последовательности:
+
+- available→missing→disabled→missing→available→missing = 2 warnings;
+- available→disabled→unsupported-existing→disabled = 2 warnings;
+- missing→unverified→missing = 1 warning.
+
+## UI
+
+Resolver сохраняет pinned entity и показывает `missing`, `disabled` либо
+`unverified`; stale attributes не рисуют puck. Banner содержит «Выбрать
+источник», documentation остаётся доступна. Перевыбор немедленно обновляет
+marker и backend subscription после config event.
+
+## Приёмка
+
+Нет log spam на refresh/restart; transition tests проходят; source не меняется
+сам; re-pick восстанавливает trail; limited registry не создаёт ложный warning.
diff --git a/docs/specs/012-vacuum-room-cleaning-highlight.md b/docs/specs/012-vacuum-room-cleaning-highlight.md
new file mode 100644
index 00000000..c55041f5
--- /dev/null
+++ b/docs/specs/012-vacuum-room-cleaning-highlight.md
@@ -0,0 +1,66 @@
+# ТЗ #12 — Vacuum Tier C: подсветка убираемой комнаты
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/12
+- Приоритет: P2
+- Статус ТЗ: ready for review
+- Зависимости: Stage 1 resolver; room dialog #28 используется для текстового статуса
+
+## Цель
+
+Интеграции без координат, но с текущим segment/service-area, показывают место
+уборки на уровне комнаты без выдуманного puck или trail.
+
+## Normalized activity adapter
+
+Pure adapter registry возвращает:
+
+```ts
+type VacRoomActivity = {
+ sourceEntity: string;
+ rawSegments: string[];
+ state: 'cleaning' | 'inactive' | 'unknown';
+ integration: string | null;
+};
+```
+
+Stage 1 adapters: документированные Roborock segment attributes/services;
+Matter RVC добавляется только после подтверждения HA contract. Неизвестный
+attribute не угадывается по порядку/первому числу.
+
+## Mapping
+
+1. Сначала explicit `marker.vacuum.segment_map[rawSegment] = room_id`.
+2. Затем exact canonical name match, только если source предоставляет имя.
+3. Неоднозначный/удалённый room id не подсвечивается и получает диагностику.
+4. UI в vacuum section показывает observed segments и room dropdown; mapping
+ сохраняется только явным Save.
+5. Несколько активных segments могут подсветить несколько комнат.
+
+`room_highlight` становится поддерживаемым boolean: default on для Tier C,
+явный off скрывает визуал, но не очищает mapping.
+
+## Визуал и поведение
+
+- Только при semantic cleaning state: мягкий 3 s breathing fill внутри
+ существующей clean-floor path и небольшой robot badge в room label/dialog.
+- `prefers-reduced-motion`: статичный outline/badge без pulse.
+- Цвет не заменяет configured fill/Glow; overlay имеет отдельный class и
+ `pointer-events:none`.
+- Tier C не создаёт puck, path, fake coordinate, glow source или area split.
+- Hidden/removed/HA-disabled vacuum не влияет на комнаты.
+
+## Эдж-кейсы
+
+Room rename не ломает explicit id map; room deletion делает mapping stale;
+space mismatch блокирует highlight; segment меняется во время dialog; robot
+returning/paused/docked прекращает pulse; несколько vacuum в комнате дают один
+badge с count/accessible text.
+
+## Проверки и приёмка
+
+- adapter fixtures по интеграциям, invalid/array/string segment forms;
+- mapping priority, ambiguity, room delete/rename, disabled marker;
+- browser/golden обычный и reduced-motion;
+- room dialog сообщает «Пылесос убирает эту комнату»;
+- без координат пользователь получает честную room-level индикацию и никогда
+ не видит движущийся объект в вымышленной точке.
diff --git a/docs/specs/013-golden-open-context-tray.md b/docs/specs/013-golden-open-context-tray.md
new file mode 100644
index 00000000..8e25d773
--- /dev/null
+++ b/docs/specs/013-golden-open-context-tray.md
@@ -0,0 +1,41 @@
+# ТЗ #13 — Golden-сценарии открытой context tray
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/13
+- Приоритет: P2
+- Статус ТЗ: реализовано и проверено в v1.60.3; retrospective acceptance contract
+
+## Цель
+
+Pixel-regression gate должен видеть tray в materially different состояниях, а
+не только пустую toolbar или случайно перекрытый color popover.
+
+## Нормативная матрица
+
+`demo/golden/matrix.mjs` содержит детерминированные page captures:
+
+| Width | Language | Editor/state |
+|---|---|---|
+| wide 1180 | EN | Plan selection actions |
+| wide 1180 | RU | Plan tool parameters |
+| medium 760 | EN | Toolbar group submenu |
+| medium 760 | RU | Decor selection actions |
+| narrow 390 | EN | Furniture palette |
+| narrow 390 | RU | Decor tool parameters |
+
+Совокупность покрывает оба языка и все три adaptive width, не требуя
+декартова произведения из 12 почти одинаковых кадров.
+
+## Fixture и capture contract
+
+- `editorTray` — declarative scenario input, harness не кликает по координатам;
+- fixture выбирает реальный объект/tool и дожидается stable animation state;
+- capture = page, чтобы видеть primary toolbar, overlay tray и рабочую область;
+- caret/animations/fonts/theme/viewport фиксированы общей HP-QA-01 policy;
+- любое неизвестное tray id или невозможность открыть нужную модель — hard fail.
+
+## Приёмка
+
+- все шесть кадров присутствуют в matrix manifest и reviewed Linux baseline;
+- narrow tray не меняет высоту workspace/primary toolbar;
+- RU/EN labels не обрезают actions и close-editor остаётся на месте;
+- `golden:capture/verify/accept --reviewed` соблюдают freshness и complete-set gate.
diff --git a/docs/specs/019-glow-additive-blending.md b/docs/specs/019-glow-additive-blending.md
new file mode 100644
index 00000000..aabf31ba
--- /dev/null
+++ b/docs/specs/019-glow-additive-blending.md
@@ -0,0 +1,55 @@
+# ТЗ #19 — Аддитивное смешивание Glow-источников
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/19
+- Приоритет: P2
+- Статус ТЗ: performance-first draft
+- Связано: независимый Glow overlay #55 должен сохранить эту композицию
+
+## Цель
+
+Пересекающиеся radial pools смешиваются как свет, а не перекрывают друг друга
+по DOM order, без изменения tunnel glow и базового затемнения комнат.
+
+## Render architecture
+
+Внутри lighting layer создаётся отдельная isolated group только для pools:
+
+```css
+.glow-pools { isolation: isolate; }
+.glow-pool[data-blend='screen'] { mix-blend-mode: screen; }
+```
+
+`glow_base`, room/data fill, opening tunnel sectors и sun rays находятся вне
+этой group. Порядок остальных SVG layers не меняется. Pool opacity остаётся
+частью текущего color/gradient resolver; blend не удваивает base opacity.
+
+## Feature detection и fallback
+
+- Capability определяется один раз per document через
+ `CSS.supports('mix-blend-mode','screen')` и кешируется.
+- Unsupported engine рендерит текущий normal layering.
+- User-agent sniffing и polyfill запрещены.
+- Print/screenshot path использует тот же feature decision; reduced motion не
+ влияет на статичное blending.
+
+## Performance gate до включения
+
+Добавить deterministic large-light fixture: 1, 10, 30 и 60 overlapping pools.
+Сравнить baseline/branch в одном runner по `stateUpdate` p50/p95, render count,
+long tasks и screenshot time. Ship gate: p95 delta остаётся внутри действующего
+HP-PERF budget и нет устойчивого >10% regression на old kiosk reference
+WebView. При провале issue возвращается в research без hidden toggle.
+
+## Визуальная проверка
+
+- warm+cool overlap имеет цвет, отличимый от каждого входа;
+- две одинаковые dim лампы дают более светлый overlap без clipping;
+- isolated group не осветляет paper/backdrop/room base;
+- tunnel sector не screen-blendится с pool;
+- результат не зависит от порядка marker в config.
+
+## Приёмка
+
+Golden + sampled pixel assertions доказывают смесь и isolation; unsupported
+fallback совпадает с текущим baseline; performance gate зелёный; Glow #55
+повторно использует тот же group, не создавая второе смешивание.
diff --git a/docs/specs/020-glow-open-door-spill.md b/docs/specs/020-glow-open-door-spill.md
new file mode 100644
index 00000000..f7e4b0f6
--- /dev/null
+++ b/docs/specs/020-glow-open-door-spill.md
@@ -0,0 +1,137 @@
+# ТЗ #20 — Динамический проход Glow через открытые двери и ворота
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/20
+- Приоритет: P2 по GitHub Project v2
+- Статус ТЗ: готово к реализации
+- Связано: #19 additive Glow, #36 room Glow override, #55 independent Glow overlay
+
+## Цель
+
+Состояние привязанного к проёму контакта управляет только проходом Glow через
+этот проём: открытая дверь или ворота пропускают свет в соседнюю комнату,
+закрытая — не создаёт световой сектор. Окна по-прежнему не пропускают Glow;
+их световая модель остаётся частью солнечных лучей.
+
+## Что происходит сейчас
+
+`_renderGlowLayer()` в `src/houseplan-card.ts` считает passages как все проёмы,
+кроме окон, и добавляет `doorSector()` для каждого подходящего проёма независимо
+от `_openingAmt()`. Поэтому визуально закрытая по контакту дверь продолжает
+пропускать Glow. Статические wall bodies уже имеют вырез проёма, а выход света
+в соседнюю комнату создаёт именно дополнительный sector.
+
+Следствие для реализации: не нужно на каждом HA tick перестраивать стеновые
+bodies или менять модель комнаты. Достаточно сделать состояние частью resolver
+проходов и генерировать sector только для ненулевой открытой части. Описанное в
+issue объединение interval понадобится лишь при будущем переходе к interval
+subtraction; в текущей SVG-модели отдельные clipPath children уже объединяются
+как union.
+
+## Канонический коэффициент открытия
+
+Ввести чистый resolver `openingLightAmount(opening, hass, bindingStatus)` с
+результатом `0..1`. Он должен использовать тот же источник состояния, что и
+визуал проёма, чтобы дверь не выглядела закрытой и одновременно не пропускала
+полный свет.
+
+Правила:
+
+| Случай | Коэффициент |
+|---|---:|
+| дверь/ворота без привязанного контакта | `1` — сохраняется нынешняя семантика постоянного проёма |
+| известное закрытое/неактивное состояние | `0` |
+| известное открытое/активное состояние | `1` |
+| `cover` с конечным `current_position` | `clamp(position / 100, 0, 1)` |
+| тот же источник при `invert: true` | `1 - amount` |
+| привязка missing/disabled либо `unknown`/`unavailable` | семантика непривязанного проёма: `1` для двери/ворот |
+| окно при любом состоянии | `0` для Glow |
+
+Переходные `opening`/`closing` без числового прогресса не должны создавать
+выдуманный процент: используется последний известный стабильный amount в
+runtime-кеше, а если его нет — семантика непривязанного проёма. Runtime-кеш не
+сохраняется в config и очищается при смене привязки/пространства.
+
+## Геометрия прохода
+
+1. Из `passages` исключаются окна и проёмы с amount `<= 0`.
+2. Для `amount === 1` используется текущий `doorSector()` без изменения
+ геометрии и отсечения стеновыми откосами.
+3. Для `0 < amount < 1` рабочая апертура сужается до `rlen * amount` вокруг
+ центра проёма; `doorSector()` получает её новые концы и прежний tunnel depth.
+4. Сектор по-прежнему создаётся только когда `hasRoomBehind()` подтверждает
+ соседнюю комнату. Наружная дверь не освещает фон/бумагу вне дома.
+5. Физические стены, перегородки и колонны продолжают вычитаться существующим
+ `floorMinusBodies()`; новая логика не ослабляет их occlusion.
+6. Несколько пересекающихся секторов объединяются SVG clip union. Порядок
+ openings в config не должен менять результат.
+
+Сужение вокруг центра — сознательная v1-аппроксимация: модель не знает тип
+механики створки (сдвижная, одно- или двустворчатая). Специфическое смещение
+апертуры от края возможно только после появления типа открывания в модели.
+
+## Кеширование и обновление
+
+Статическая геометрия проёмов остаётся привязана к `_cfgEpoch`. Для Glow clip
+добавляется `openingStateSignature` текущего пространства:
+
+`opening-id:round(amount,3)` для дверей и ворот, отсортировано по id.
+
+Signature включается в `_glowClipCache` key; hass revision, время и состояния
+посторонних сущностей в key не входят. Изменение контакта должно дать новый clip
+в ближайшем обычном render tick. Существующий LRU limit сохраняется; отдельная
+полная очистка кеша на каждый HA update запрещена.
+
+## Совместимость и границы scope
+
+- Config/model/migration не меняются.
+- Непривязанные двери и ворота выглядят и освещают точно как до задачи.
+- Состояние контакта влияет только на Glow и существующий визуал проёма; room
+ fill, площадь, hover, tunnel fill и солнечные лучи не меняются.
+- #55 обязан использовать этот же resolver после отделения Glow от fill mode.
+- #19 смешивает уже рассчитанные pools и не меняет геометрию sectors.
+- Недоступный или деактивированный HA contact не превращает архитектурный проём
+ в стену и не блокирует сохранение/просмотр плана.
+
+## UX и диагностика
+
+Новых настроек не добавляется. В существующей информации о проёме допустимо
+показывать фактический процент только если HA действительно отдаёт
+`current_position`; бинарный contact остаётся «открыто/закрыто». Ошибка чтения
+контакта не должна создавать toast на каждый state tick.
+
+## Проверки
+
+### Unit
+
+- amount для unbound/open/closed/inverted/unknown/unavailable/disabled;
+- `cover.current_position`: 0, 1, 50, 100 и значения вне диапазона;
+- переходные состояния с/без last stable value;
+- narrowing endpoints и сохранение tunnel-depth clipping;
+- signature детерминирована и не зависит от порядка openings.
+
+### Geometry/render
+
+- лампа в A + закрытая дверь в B: Glow остаётся в A;
+- та же дверь открыта: sector появляется в B за один tick;
+- 50%: сектор уже полного и проходит между откосами толстой стены;
+- ворота повторяют дверь; окно не пропускает Glow;
+- наружная дверь не освещает фон;
+- две соседние/перекрывающиеся двери не создают тёмный шов;
+- перегородка или колонна за проёмом продолжает отсекать свет.
+
+### Regression/performance
+
+Golden: closed/open/50% на толстой стене, light и dark HA themes. Large-house
+fixture переключает 20 контактов; `stateUpdate` p95 остаётся внутри действующего
+HP-PERF budget, cache bounded, geometry counters не растут от посторонних HA
+updates.
+
+## Критерии приёмки
+
+- Закрытый привязанный проём не создаёт Glow sector, открытый создаёт.
+- Частичное открытие даёт пропорционально более узкий, корректно отсечённый
+ стеновыми откосами сектор.
+- Непривязанные проёмы и окна полностью сохраняют прежнее поведение.
+- Изменение состояния не требует config save и отображается за один render tick.
+- Model, room area, sun, opening tunnel fill и физическая occlusion не меняются.
+- Unit, golden, geometry regression и performance gate зелёные.
diff --git a/docs/specs/021-color-css-injection.md b/docs/specs/021-color-css-injection.md
new file mode 100644
index 00000000..e4b252e0
--- /dev/null
+++ b/docs/specs/021-color-css-injection.md
@@ -0,0 +1,86 @@
+# ТЗ #21 — безопасные пользовательские цвета
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/21
+- Приоритет: P2
+- Статус: реализовано; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Блокирует цветовые части #50 и #56; SVG sanitization из #51 остаётся отдельной задачей
+
+## Проблема
+
+Новые записи проходят backend-валидацию, но старый, импортированный или вручную
+изменённый store может попасть во frontend без повторной проверки. Подтверждённый
+разрыв: `marker.ripple_color` доходил до CSS custom property как часть строки
+inline-style. Lit не позволяет выйти из HTML-атрибута, но строка вида
+`red;position:fixed` могла добавить соседние CSS declarations.
+
+## Единый контракт
+
+Все сохраняемые цвета Houseplan имеют только вид `#RRGGBB`:
+
+- ровно `#` и шесть шестнадцатеричных цифр;
+- регистр букв любой;
+- без пробелов, короткого `#RGB`, имени цвета, `rgb()`, `var()`, `url()` и других
+ CSS-функций;
+- неверное значение не исправляется и не записывается автоматически: при чтении
+ используется безопасный штатный fallback.
+
+Это соответствует уже существующим color picker и backend-схеме. Расширять
+форматы цвета в рамках #21 не требуется.
+
+## Реализация
+
+### Frontend
+
+Один pure helper `safeStoredColor(value, fallback)` является общей границей для
+всех цветов из сохранённой конфигурации:
+
+- цвет и фон пространства;
+- глобальный фон и палитра заливок, включая стены и Glow;
+- обводка и заливка декоративных объектов и текста;
+- `marker.ripple_color`;
+- компонент выбора цвета.
+
+Проверка выполняется при проекции данных для рендера, поэтому защищает и старый
+store, записанный до этого правила. Невалидный пользовательский ripple не должен
+перекрывать безопасный динамический цвет лампы.
+
+HA может отдавать `rgb_color`. Это не сохраняемый пользовательский цвет:
+приложение принимает три конечных числа, ограничивает каждый канал диапазоном
+0–255, округляет и само создаёт каноническую строку `rgb(R, G, B)`. На последней
+границе inline-style разрешены только строгий `#RRGGBB` и именно такой
+сгенерированный `rgb()`.
+
+### Backend
+
+Все поля цвета используют один `_COLOR = ^#[0-9a-fA-F]{6}$`. Поведение API не
+меняется: корректный hex принимается, остальные формы отклоняются.
+
+### Совместимость
+
+- валидные планы визуально не меняются;
+- невалидный старый цвет заменяется только в runtime на штатный fallback;
+- исходный config не мутируется при чтении;
+- при следующем обычном редактировании color picker показывает безопасный
+ fallback, а сохранение записывает валидный цвет.
+
+## Обязательные проверки перед пре-релизом
+
+1. Unit corpus: валидный mixed-case hex; short hex, whitespace, named/rgb,
+ `;`, braces, comment, backslash, newline, `url()`, overlong и non-string.
+2. Проекция устройства: hostile `ripple_color` заменяется динамическим цветом
+ включённой RGB-лампы либо `null`, если такого цвета нет.
+3. Последняя style-граница не выводит hostile ripple declaration.
+4. Decor/space/fill resolvers используют fallback и не меняют входной объект.
+5. Backend одинаково отклоняет hostile значение во всех цветовых полях.
+6. Browser smoke на импортированном hostile config: нет overlay, внешнего
+ запроса, новой style declaration и ошибки рендера.
+
+## Критерии приёмки
+
+- Ни одно значение цвета из store не попадает в CSS/SVG без общей проверки.
+- Все новые записи принимают только `#RRGGBB`.
+- Динамический цвет HA остаётся рабочим и не может содержать произвольный CSS.
+- Визуальное поведение валидных существующих планов не изменено.
+- Целевые frontend/backend тесты и браузерные regression smokes выполнены в
+ gate v1.61.0-beta.1; issue закрывается после зелёного exact-SHA CI и проверки
+ release assets.
diff --git a/docs/specs/027-vacuum-external-source-picker.md b/docs/specs/027-vacuum-external-source-picker.md
new file mode 100644
index 00000000..5af696a1
--- /dev/null
+++ b/docs/specs/027-vacuum-external-source-picker.md
@@ -0,0 +1,51 @@
+# ТЗ #27 — Явный выбор внешнего vacuum source
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/27
+- Приоритет: P1
+- Статус ТЗ: реализовано по HP-VAC-02 rev.7; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Родительский контракт: HP-VAC-02 §4.4–4.5
+
+## Цель
+
+Позволить связать YAML/registry-less камеру XCME, которая не имеет DeviceInfo
+связи с vacuum device, без угадывания и последующего silent rebind.
+
+## Resolver
+
+1. Единственный pure resolver возвращает `{entityId, status, pinned,
+ candidates}` и используется renderer, dialog, fit, diagnostics и trail UI.
+2. Сохранённый `marker.vacuum.source` sticky при `ok`, `unsupported`,
+ `unavailable`, `disabled`, `unverified` и `missing`; автоматическая замена
+ запрещена.
+3. Automatic mode рассматривает только position-capable сущности того же HA
+ device; глобальные камеры никогда не выбираются автоматически.
+4. Порядок кандидатов детерминирован: capability score, camera выше прочего,
+ затем `entity_id`; выбранный source всегда видим первым.
+5. `missing` допустим только при авторитетном доказательстве. Незаполненный
+ status pure resolver трактует как `unverified`.
+
+## Picker
+
+- Основной список: automatic, same-device candidates и сохранённый source.
+- «Все камеры» закрыт по умолчанию и сканирует `camera.*` только при открытии.
+- Глобальный список — snapshot на одно открытие; HA ticks не перепарсивают его.
+- Повторное открытие делает новый snapshot; новый dialog не наследует старый.
+- Каждый кандидат показывает имя, entity id, integration и capabilities.
+- Missing/disabled/unverified banner содержит собственную кнопку выбора.
+- Выбор first-use source немедленно перестраивает devices; ожидание следующего
+ HA tick запрещено.
+
+## Эдж-кейсы
+
+- source удалён/переименован; registry ограничен правами; state unavailable;
+- camera без position, но с rooms/path; несколько одинаково подходящих камер;
+- первый edit ещё не материализовал marker; dialog закрыт во время scan;
+- два экземпляра карточки не делят UI snapshot.
+
+## Приёмка
+
+- внешняя XCME камера выбирается и сохраняется с первого клика;
+- reload не меняет выбранный source;
+- missing source остаётся видимым и исправимым;
+- глобальный scan ленивый и не влияет на автоматический resolver;
+- источник одинаков во всех vacuum consumers.
diff --git a/docs/specs/028-room-view-card.md b/docs/specs/028-room-view-card.md
new file mode 100644
index 00000000..60134c8f
--- /dev/null
+++ b/docs/specs/028-room-view-card.md
@@ -0,0 +1,59 @@
+# ТЗ #28 — Карточка комнаты в режиме View
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/28
+- Приоритет: P1
+- Статус ТЗ: draft, требуется UX-утверждение
+- Зависимости: #31 использует карточку как основную доступную точку комнаты
+
+## Цель
+
+Сделать информацию о комнате одинаково доступной мышью, touch и клавиатурой,
+не заменяя быстрый desktop hover и не меняя модель комнаты.
+
+## Нормативное поведение
+
+1. В View tap/click по свободной части чистого пола открывает компактный
+ `hp-dialog` «Комната».
+2. Диалог показывает:
+ - отображаемое имя либо локализованное «Без названия»;
+ - чистую площадь через существующий `_roomArea()`/clean-floor geometry;
+ - только доступные temperature, humidity, LQI и light stats;
+ - подписанную кнопку «Открыть зону в Home Assistant» только при `room.area`.
+3. Источники метрик и форматирование ровно те же, что у hover и room label:
+ `_roomTemp`, `_roomHum`, `_roomLqi`, `resolvedLightSources/Stats`.
+4. Hover tooltip остаётся без клика; открытый dialog убирает hover tooltip.
+5. Комната без area открывает карточку, но не показывает навигационное действие.
+6. События устройства, проёма, room label/link, vacuum puck, control, tooltip и
+ других интерактивных слоёв обязаны остановить propagation.
+
+## Gesture arbitration
+
+- Решение принимается на `pointerup`, только если pointerdown начался на той же
+ room hit-shape и движение не превысило существующий click threshold.
+- Pan, pinch, long press, drag и `_suppressClick` отменяют room activation.
+- Glow, sun rays, tunnel fills и hover outline остаются `pointer-events:none`.
+- Для вложенных комнат выигрывает верхняя/самая внутренняя реальная hit-shape;
+ отверстие пола родителя не является его hit-area.
+- Один жест может открыть только один dialog.
+
+## Доступность
+
+- Доступный trigger получает локализованное имя комнаты; Enter/Space открывают
+ тот же dialog через модель навигации #31.
+- Dialog наследует trap, initial/restore focus и Escape у `hp-dialog`.
+- Метрики представлены текстом, не только иконками/цветом.
+
+## Эдж-кейсы
+
+Толстые/скрытые стены, отверстия, перегородки и колонны используют уже
+вычисленную clean-floor path. Общая стена не принадлежит hit-area комнаты.
+Изменение HA state обновляет метрики открытой карточки. Удаление/смена
+пространства закрывает карточку без навигации.
+
+## Проверки и приёмка
+
+- unit: единая projection модели карточки из room + aggregates;
+- browser desktop/touch/keyboard: open/close, pan suppression, nested room;
+- browser: click по device/opening/link не проваливается в room;
+- площадь байт-в-байт совпадает с tooltip formatter;
+- mobile golden: длинное имя, все/нет метрик, стабильный footer.
diff --git a/docs/specs/029-device-inbox-lifecycle.md b/docs/specs/029-device-inbox-lifecycle.md
new file mode 100644
index 00000000..f365e6d9
--- /dev/null
+++ b/docs/specs/029-device-inbox-lifecycle.md
@@ -0,0 +1,65 @@
+# ТЗ #29 — Inbox и объяснимый жизненный цикл устройств
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/29
+- Приоритет: P1
+- Статус ТЗ: draft, требуется UX-утверждение
+- Связано: #44 переносит advanced-фильтры в этот интерфейс
+
+## Цель
+
+Заменить разрозненные «Добавить»/«Показать скрытые» одним read-only при
+открытии каталогом, который объясняет состояние каждой HA-привязки и даёт
+явные действия жизненного цикла.
+
+## Единая классификация
+
+Pure resolver получает registry snapshot, live states, markers и product
+filters и возвращает одну запись на canonical binding:
+
+| Раздел | Условие | Основные действия |
+|---|---|---|
+| Новые | активная допустимая binding без marker/tombstone | Добавить, скрыть |
+| На плане | live marker, `hidden !== true` | Найти, редактировать |
+| Скрытые | live marker `hidden:true` либо seed-кандидат filter | Показать/добавить, причина |
+| Доступные снова | tombstone `removed:true`, binding снова существует | Добавить заново |
+
+HA-disabled binding остаётся в своём lifecycle-разделе, но получает статус
+«Отключено в Home Assistant» и недоступное действие показа согласно текущему
+контракту disabled devices. Orphaned сохранённый marker остаётся «На плане» или
+«Скрытые» с предупреждением, а не превращается в «Новый».
+
+## Причины
+
+Причина — stable enum, локализованный в UI: `manual_hidden`, `ha_disabled`,
+`service_entry`, `excluded_integration`, `excluded_domain`, `grouped_light`,
+`represented_by_parent`, `duplicate_name_area`, `removed`, `orphaned`,
+`limited_registry`. Regex/id могут быть в раскрываемой диагностике, но не в
+основной фразе.
+
+## UX
+
+- Кнопка редактора устройств открывает wide `hp-dialog`/side sheet с поиском,
+ tabs/filters и счётчиками.
+- Просмотр, поиск и раскрытие причины ничего не пишут в config.
+- «Найти» переключает пространство, закрывает inbox и мягко выделяет marker.
+- «Добавить» открывает существующий device dialog с preselected binding.
+- «Скрыть» материализует `hidden:true`, но не `removed:true`.
+- «Добавить заново» заменяет tombstone одним live marker и не наследует старую
+ позицию, файлы или trail, уже удалённые подтверждённым Delete.
+- Все mutation actions получают Undo/confirmation согласно их текущей
+ семантике; bulk actions в v1 не входят.
+
+## Инварианты и конкуренция
+
+Canonical binding уникальна. Повторный save и конфликт revision не создают
+дубликат. Registry refresh обновляет список с сохранением tab/search, но не
+закрывает редактируемый dialog. Limited registry не выводит ложный «удалён».
+
+## Проверки и приёмка
+
+- матрица resolver по marker/hidden/removed/disabled/orphaned/filter;
+- два клиента и revision conflict;
+- re-add device/entity tombstones, virtual marker вне inbox;
+- поиск, keyboard navigation, narrow layout и ru/en golden;
+- любой кандидат находится ровно в одном разделе и имеет понятную причину;
+- открытие inbox не меняет config/layout/revisions.
diff --git a/docs/specs/030-dialog-information-architecture.md b/docs/specs/030-dialog-information-architecture.md
new file mode 100644
index 00000000..fb976a3d
--- /dev/null
+++ b/docs/specs/030-dialog-information-architecture.md
@@ -0,0 +1,58 @@
+# ТЗ #30 — Информационная архитектура длинных диалогов
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/30
+- Приоритет: P1
+- Статус ТЗ: ready for review
+- Ограничение: только UI/form-state; stored model и `hp-dialog` contract не меняются
+
+## Цель
+
+Разделить независимые пользовательские задачи в Space, Device и General
+Settings dialogs, сохранив единый draft и стабильный footer.
+
+## Адаптивный паттерн
+
+- `>= 720px` доступной ширины dialog: вертикальная side navigation слева,
+ активная section справа.
+- `< 720px`: один последовательный список ``/accordion, открыт ровно
+ один раздел; заголовки остаются видимыми в body.
+- Горизонтальные tabs с обрезанием/скроллом не используются.
+- Выбранная section локальна dialog session и не сохраняется в config.
+- При validation error интерфейс открывает нужную section, фокусирует поле и
+ связывает сообщение через `aria-describedby`.
+
+## Разделы
+
+| Диалог | Разделы |
+|---|---|
+| Пространство | Основа; Комнаты и подписи; Внешний вид; Окружение |
+| Устройство | Привязка; Действие; Состояние и свет; Внешний вид; Информация и файлы |
+| Общие настройки | Цвета; Окружение; Обслуживание; О продукте |
+
+Lifecycle actions Hide/Delete остаются в footer device dialog и не прячутся в
+section. Save/Cancel принадлежат всему draft, а не текущей section.
+
+## Условная видимость
+
+- controls/is_light/radius — только для on/off-capable binding либо явного
+ opt-in; сохранённые значения не стираются при временном скрытии section;
+- climate temperature — только при climate capability;
+- vacuum — только для vacuum device;
+- state/display preview — скрыт у полностью virtual marker, но appearance
+ остаётся;
+- integration provenance и files — только при наличии данных/поддержки;
+- смена binding пересчитывает visibility, не сбрасывая unrelated draft fields.
+
+## Архитектура
+
+Каждый dialog получает typed draft model и массив section descriptors
+`{id,label,visible,hasError,render}`. Навигационный компонент не знает schema и
+не пишет config. Это согласуется с поэтапной декомпозицией #34.
+
+## Проверки и приёмка
+
+- переключение section сохраняет все несохранённые поля;
+- error routing, first/restore focus, Esc и nested dialogs;
+- desktop/mobile, длинные ru/en labels, virtual/climate/vacuum matrices;
+- footer не меняет ширину/позицию и не получает горизонтальный scroll;
+- save старого config без правки даёт эквивалентный payload и визуал.
diff --git a/docs/specs/031-view-accessibility.md b/docs/specs/031-view-accessibility.md
new file mode 100644
index 00000000..6fb5f5be
--- /dev/null
+++ b/docs/specs/031-view-accessibility.md
@@ -0,0 +1,56 @@
+# ТЗ #31 — Доступная семантика режима View
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/31
+- Приоритет: P1
+- Статус ТЗ: draft, требуется UX-проверка screen reader
+- Зависимость: room dialog #28
+
+## Цель
+
+Обеспечить основные View-сценарии клавиатурой и screen reader без сотен
+tab-stop и без изменения pointer UX.
+
+## Рекомендуемая модель
+
+Над SVG существует одна визуально скрытая, но screen-reader доступная
+`PlanNavigator`-структура с тремя группами: Комнаты, Устройства, Проёмы.
+Внутри применяется roving tabindex: в общем Tab-порядке одна активная запись,
+стрелки перемещают активную запись, Home/End — края группы, Ctrl+стрелка —
+соседняя группа. SVG shapes остаются `aria-hidden`, чтобы не дублировать дерево.
+
+## Accessible projection
+
+Один pure resolver создаёт для объекта:
+
+- стабильный id и role (`button` для действия, `group` для read-only);
+- label: имя, тип, комната, локализованное состояние, основное действие;
+- description: доступные метрики/предупреждения;
+- action: room dialog, device action/more-info, opening status.
+
+`static_icon` влияет на визуал, но accessible label всё равно сообщает реальное
+HA-состояние. Disabled/orphaned объекты явно называются недоступными.
+
+## Keyboard и focus lifecycle
+
+- Enter/Space активирует основной action; контекстное more-info остаётся
+ отдельной подписанной командой в открывшейся карточке.
+- Смена пространства фокусирует PlanNavigator heading, затем первую комнату.
+- Закрытие room/device dialog возвращает фокус на исходную запись.
+- Исчезнувший объект возвращает фокус к ближайшему соседу/заголовку.
+- Kiosk сохраняет доступный View, но editor controls отсутствуют.
+
+## Нецветовые состояния
+
+Critical alarm получает alert glyph и текст; open/unlocked — outline/glyph;
+mechanical activity — motion glyph/accessible live text. Не добавлять постоянные
+satellite badges каждому marker. `prefers-reduced-motion` отключает pulse, но
+не текстовый state.
+
+## Проверки и приёмка
+
+- axe: name/role/value, landmark, focus order, dialog boundaries;
+- NVDA/Chrome и TalkBack ручной checklist;
+- keyboard: room, ordinary device, alarm, opening, disabled device;
+- 200 markers дают один Tab entry на plan navigator, не 200;
+- выключенный цвет/анимация не лишает пользователя состояния;
+- pointer hit-testing и golden View не меняются без осознанного glyph delta.
diff --git a/docs/specs/032-unified-danger-confirmation.md b/docs/specs/032-unified-danger-confirmation.md
new file mode 100644
index 00000000..4363f21d
--- /dev/null
+++ b/docs/specs/032-unified-danger-confirmation.md
@@ -0,0 +1,62 @@
+# ТЗ #32 — `hp-confirm` для опасных действий
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/32
+- Приоритет: P1
+- Статус ТЗ: ready for implementation
+- Основа: `src/hp-dialog.ts`
+
+## Цель
+
+Удалить runtime-вызовы browser `confirm()` и дать всем destructive/warning
+операциям единый доступный, локализованный и race-safe контракт.
+
+## Компонент и API
+
+`hp-confirm` строится поверх `hp-dialog` и принимает immutable request:
+
+```ts
+type ConfirmRequest = {
+ id: string;
+ kind: 'destructive' | 'warning';
+ title: string;
+ message: string;
+ objectName?: string;
+ confirmLabel: string;
+ cancelLabel: string;
+};
+confirm(request): Promise<'confirm' | 'cancel'>;
+```
+
+В корневом компоненте находится один controller/queue. Новый request отменяет
+предыдущий как `cancel`; stale promise не может применить callback. На
+disconnect все pending requests завершаются `cancel`.
+
+## UX
+
+- initial focus всегда Cancel; destructive action визуально отделён и идёт
+ последним в DOM;
+- Escape/scrim/close = cancel; Enter не подтверждает destructive автоматически;
+- заголовок называет операцию, body — объект и необратимые последствия;
+- unlock использует `warning`, отдельные тексты и action label, никогда тексты
+ удаления;
+- touch footer переносится строками без обрезания и horizontal scroll.
+
+## Мигрируемые пути
+
+Удаление draft, draft segment, room, marker/device, plan file и space; unlock.
+Каждый caller сначала получает решение, затем заново проверяет существование и
+revision объекта перед mutation. Cancel не создаёт undo point и не пишет store.
+
+## Edge cases
+
+Повторный double click, смена space/mode, удаление объекта другим клиентом,
+nested dialog, browser back, component disconnect. Повторное подтверждение
+одного request id не исполняет действие дважды.
+
+## Проверки и приёмка
+
+- unit controller: confirm/cancel/replacement/disconnect/stale callback;
+- browser cancel+accept для каждого класса destructive path и unlock;
+- keyboard/focus restore/mobile footer/ru+en long labels;
+- `rg "confirm\\(" src` не находит прямых runtime browser calls;
+- поведение данных и command stack остаётся прежним.
diff --git a/docs/specs/033-config-schema-lifecycle.md b/docs/specs/033-config-schema-lifecycle.md
new file mode 100644
index 00000000..3fc50643
--- /dev/null
+++ b/docs/specs/033-config-schema-lifecycle.md
@@ -0,0 +1,62 @@
+# ТЗ #33 — Единый registry схемы и lifecycle compatibility-полей
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/33
+- Приоритет: P1
+- Статус ТЗ: Stage A частично реализован; документ определяет завершение
+
+## Цель
+
+Сделать drift между TypeScript, UI, runtime и Voluptuous обнаруживаемым в CI,
+а судьбу каждого public/legacy/internal поля — явной и проверяемой.
+
+## Канонический registry
+
+Развить `scripts/config-field-registry.mjs` до полного manifest. Запись поля:
+
+```text
+path, owner, value kind, enum/range/default, inheritance,
+frontend type, backend schema, UI surface, runtime consumers,
+introduced, write policy, read-compat-until, migration, unknown-child policy
+```
+
+Manifest описывает все сохраняемые `config` и `layout` поля, а не только legacy.
+Для dynamic maps (`calibration`, layout ids) фиксируется shape значения и
+policy ключей. Секреты/контент в manifest не попадают.
+
+## Паритет и CI
+
+1. Скрипт извлекает/нормализует enum/ranges из frontend declarations и
+ backend schema adapters.
+2. Любой отсутствующий field decision или несовпадение enum/range ломает CI.
+3. `extra=ALLOW_EXTRA` сохраняет future fields, но не освобождает известное поле
+ от регистрации.
+4. Fixtures содержат oldest-supported, current и future-field config; load/save
+ без explicit optimization сохраняет неизвестные поля и визуальную семантику.
+
+## Локальный audit
+
+`scripts/config-audit.mjs` принимает экспортированный JSON локально, ничего не
+отправляет наружу и выдаёт counts по legacy fields, planned migrations и
+unknown paths без значений персональных данных. Exit codes различают clean,
+migration available и invalid.
+
+## Lifecycle
+
+- `read-only legacy`: читается, но никогда не пишется новым UI;
+- `migrate-on-explicit-optimize`: preview diff → atomic write → undo;
+- `deprecated`: имеет дату/версию окончания чтения и changelog;
+- `internal supported`: получает documented UI/default либо становится
+ фиксированным правилом и удаляется из storage;
+- неизвестное future field сохраняется losslessly.
+
+Первый decision set включает `tap_action`, display `ripple`, `show_all`,
+`weather_entity`, vacuum `room_highlight/segment_map`, `group_lights` и
+`exclude_integrations`; последние два координируются с #44.
+
+## Приёмка
+
+- 100% известных persisted paths зарегистрированы;
+- schema drift имеет понятный CI diff;
+- audit не выводит имена/id/координаты по умолчанию;
+- Optimize показывает точные изменения до записи и имеет безопасный undo;
+- обычное открытие/сохранение старого/future config не меняет визуал.
diff --git a/docs/specs/034-frontend-decomposition.md b/docs/specs/034-frontend-decomposition.md
new file mode 100644
index 00000000..1fa77970
--- /dev/null
+++ b/docs/specs/034-frontend-decomposition.md
@@ -0,0 +1,71 @@
+# ТЗ #34 — Поэтапная декомпозиция frontend
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/34
+- Приоритет: P1
+- Статус ТЗ: выполняется по slices
+- Тип изменения: refactor-only, без пользовательского поведения
+
+## Исходное состояние и цель
+
+`src/houseplan-card.ts` сейчас около 14,9 тыс. строк, `styles.ts` — около 2,8
+тыс. Корневой Lit-компонент должен стать composition/lifecycle shell, целевой
+ориентир — менее 3500 строк после серии independently releasable slices.
+
+## Правила slice
+
+1. Один PR/этап переносит одну законченную ответственность вместе с types,
+ pure tests и owned styles.
+2. Внешний DOM, i18n keys, stored payload, command names, pointer thresholds и
+ render order не меняются.
+3. Существующие geometry/device/sun/vacuum pure modules остаются authority;
+ вторая модель запрещена.
+4. Новый product code использует `unknown` + guards; `any` допустим только в
+ явно названном HA adapter boundary.
+5. До/после slice проходит одинаковый test/golden/performance set.
+
+## Целевая структура
+
+- `app/`: card composition, normalized store, navigation/viewport;
+- `editors/{plan,devices,decor}`: typed state machines и commands;
+- `render/`: immutable projections для rooms/walls/openings/lighting/devices/vacuum;
+- `dialogs/`: draft models, validation, serialization, render components;
+- `components/`: общие dialog/confirm/color/tray primitives.
+
+## Порядок
+
+### Slice 1 — малые dialog models
+
+Вынести partition/column property draft: `fromConfig`, validation,
+`toPatch`, equality. Root сохраняет callbacks и geometry commands.
+
+### Slice 2 — render-only layers
+
+По одному: room shapes/hover, wall body, lighting, device overlay, vacuum.
+Input — frozen projection; output — template + typed callbacks без store access.
+
+### Slice 3 — крупные dialog models
+
+Room/opening → space → device/settings. Не смешивать с IA #30.
+
+### Slice 4 — editor controllers
+
+Plan, Devices, Decor pointer/keyboard state machines; command stack остаётся
+общим интерфейсом.
+
+### Slice 5 — store/navigation/styles
+
+Revision/conflict/save orchestration, warm viewport, затем scoped styles.
+
+## Метрики и gate
+
+- root lines и доля TS монотонно уменьшаются;
+- feature module обычно <800 строк, исключение документируется;
+- `any` count не растёт;
+- circular import check, bundle size/perf budgets;
+- no-op config roundtrip, complete smoke и reviewed golden без delta.
+
+## Приёмка
+
+Каждый slice может быть отдельно reverted/released, не содержит feature work и
+имеет architectural note. Финал достигает целевой роли root и удаляет
+временные compatibility adapters.
diff --git a/docs/specs/035-current-ux-docs.md b/docs/specs/035-current-ux-docs.md
new file mode 100644
index 00000000..d03d6511
--- /dev/null
+++ b/docs/specs/035-current-ux-docs.md
@@ -0,0 +1,59 @@
+# ТЗ #35 — Документация текущего пользовательского опыта
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/35
+- Приоритет: P1
+- Статус ТЗ: ready for implementation
+- Тип: docs-only, без изменения поведения
+
+## Цель и аудитория
+
+README/HACS дают честный обзор и первый успешный путь; USER-GUIDE содержит
+полную инструкцию; тематические документы объясняют сложные подсистемы. RU и EN
+используют одинаковую структуру и актуальную терминологию.
+
+## Deliverables
+
+1. Детерминированные synthetic screenshots:
+ - View desktop и touch;
+ - создание пространства;
+ - создание/закрытие room contour;
+ - Plan editor с открытым context tray;
+ - Device editor с display preview/provenance;
+ - Background editor;
+ - room card #28 и device info card после их реализации.
+2. Короткая сравнительная таблица инструментов: Контур комнаты, Перегородка,
+ Колонна, Граница, Проём — результат, влияние на площадь/свет, ограничения.
+3. Матрица input: mouse, touch View, touch editor best-effort, keyboard.
+4. First-run путь: install → add card → create/import space → room → bind area →
+ place device → View.
+5. Несколько карточек: разные `default_floor`, общая server config/layout,
+ локальный viewport/mode, ограничения concurrent editing.
+
+## Информационная архитектура
+
+- README.ru/README: ценность, установка, 5–7 ключевых возможностей, first run,
+ ссылки на подробности; без длинных reference tables.
+- USER-GUIDE.ru и английский эквивалент: полные workflows и edge cases.
+- VACUUM, TOUCH-SUPPORT, DECOR-EDITOR и другие тематические docs — authority.
+- Changelog не используется как инструкция.
+
+## Производство изображений
+
+Только synthetic fixture без реальных entity ids/планов. Capture фиксирует
+viewport/theme/language, изображение хранится рядом с manifest (version,
+scenario, source SHA). Alt text обязателен. Старый screenshot удаляется только
+после проверки всех ссылок на него.
+
+## CI и качество
+
+- link checker для относительных файлов, headings/anchors и внешних canonical
+ links с allowlist transient failures;
+- terminology linter для старых названий кнопок;
+- проверка паритета обязательных RU/EN sections;
+- ручная сверка HACS rendering и mobile README.
+
+## Приёмка
+
+Ни один screenshot/текст не показывает отсутствующий control; пользователь
+создаёт первую комнату без changelog; все ссылки валидны; touch degradation
+описана честно; обновление screenshot имеет воспроизводимую команду.
diff --git a/docs/specs/036-room-glow-override.md b/docs/specs/036-room-glow-override.md
new file mode 100644
index 00000000..e823afd2
--- /dev/null
+++ b/docs/specs/036-room-glow-override.md
@@ -0,0 +1,59 @@
+# ТЗ #36 — Явный Glow override на уровне комнаты
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/36
+- Приоритет: P2
+- Статус ТЗ: draft, рекомендованное product decision зафиксировано ниже
+- Зависимость: foundation независимого overlay #55
+
+## Цель
+
+Комната может явно наследовать, включить или выключить Glow независимо от
+последующего изменения space default и data fill.
+
+## Рекомендуемое решение владельцу
+
+Room override влияет только на визуализацию внутри чистого пола комнаты и не
+меняет физическую проницаемость границ. Комната с Glow off не становится
+стеной: light transport через двери/виртуальные границы продолжается, но base
+и pools не рисуются в её clip. Если за ней находится Glow-on комната и
+геометрический путь света существует, свет может появиться там. Это сохраняет
+разделение «настройка представления» и «физическая модель».
+
+## Модель
+
+- `space.settings.glow_enabled?: boolean` — default overlay пространства;
+- `room.settings.glow?: boolean | null` — `null/absent` inherit, `true` on,
+ `false` off.
+- Effective resolver: `room.glow ?? space.glow_enabled ?? false`.
+- Поле `fill_mode` больше не определяет room Glow после миграции #55.
+
+## UX
+
+Room settings, раздел «Заливка и свет»: radio «Как у пространства / Включён /
+Выключен». Рядом read-only effective state. Выбор data fill находится отдельно.
+Переключение не стирает fill, light sources, radius или room geometry.
+
+## Нормативная матрица
+
+| Effective Glow комнаты | Base darkness | Local pools | Tunnel/transport |
+|---|---:|---:|---|
+| off | нет | нет | геометрия продолжает расчёт |
+| on | да | да | по существующим opening/virtual rules |
+
+Physical wall всегда блокирует; дверь/ворота используют текущий tunnel; окно не
+становится межкомнатным проходом. `show_borders:false` не меняет физику.
+
+## Edge cases
+
+Nested rooms получают собственный clean-floor clip; parent Glow не рисуется в
+hole. Перегородки/колонны вычитаются как сейчас. Glow under hover остаётся
+pointer-transparent. Комната без sources всё равно получает base, если Glow on.
+
+## Проверки и приёмка
+
+- resolver truth table inherit/on/off;
+- соседние rooms on→off→on через open boundaries;
+- doors, virtual walls, physical walls, nested/hole, partitions/columns;
+- room fill temp/LQI/custom одновременно с override;
+- migration/read compatibility #55;
+- один room override переживает смену space default и reload.
diff --git a/docs/specs/037-room-scale-system.md b/docs/specs/037-room-scale-system.md
new file mode 100644
index 00000000..5a9e9841
--- /dev/null
+++ b/docs/specs/037-room-scale-system.md
@@ -0,0 +1,62 @@
+# ТЗ #37 — Одна объяснимая система масштабов room card
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/37
+- Приоритет: P2
+- Статус ТЗ: draft, рекомендованная migration semantics зафиксирована
+
+## Цель
+
+Пользователь видит два понятных effective размера — название и метрики — с
+одним space default и room overrides. Скрытый третий layout multiplier больше
+не создаётся.
+
+## Каноническая формула
+
+После явной миграции:
+
+```text
+effectiveName = space.card_font_scale × (room.name_scale ?? 1)
+effectiveMeta = space.card_font_scale × (room.label_scale ?? 1)
+```
+
+Диапазон каждого stored multiplier остаётся 0.5..3; итоговый CSS size имеет
+отдельный safe clamp. Layout record `rl_.k` — только read compatibility.
+
+## UX
+
+- Space dialog показывает общий default и preview.
+- Room dialog показывает override «Наследовать» либо процент, рядом effective
+ процент; отдельный Reset для name и metrics.
+- Visual corner resize масштабирует оба effective room значения одним ratio:
+ materializes `name_scale` и `label_scale`, сохраняя их относительное отличие.
+ Он больше не пишет `layout.k`.
+- Context tray при выборе room label показывает два effective значения и Reset.
+
+## Legacy
+
+До Optimize renderer умножает старый `layout.k` как сегодня, поэтому нет
+скачка. «Оптимизировать планы» preview:
+
+1. `name_scale = clamp(existingName × k)`;
+2. `label_scale = clamp(existingLabel × k)`;
+3. удалить только поле `k`, сохранив x/y/s layout record;
+4. показать rooms, где clamp изменил точное значение;
+5. atomic write config+layout и one-deep undo.
+
+Обычный Save room/space не мигрирует `k` неявно. Если пользователь делает
+visual resize конкретной legacy card, migration только этой room является
+явным следствием жеста и сохраняет текущий visual до первого delta.
+
+## Edge cases
+
+Room без metrics всё равно хранит independent label_scale; будущие metrics его
+используют. Rename/delete/remap room обрабатывает layout owner. Zoom карточки не
+меняет stored scale. Несколько клиентов конфликтуют через существующие rev.
+
+## Проверки и приёмка
+
+- truth table defaults/overrides/k compatibility;
+- zero-delta first drag без скачка; ratio с разными name/meta;
+- Optimize preview/apply/undo/clamp;
+- room/space dialogs, context tray, ru/en narrow layout;
+- после Optimize нет `k`, а screenshot до/после совпадает в tolerance.
diff --git a/docs/specs/038-icon-rule-builder.md b/docs/specs/038-icon-rule-builder.md
new file mode 100644
index 00000000..efb2b887
--- /dev/null
+++ b/docs/specs/038-icon-rule-builder.md
@@ -0,0 +1,55 @@
+# ТЗ #38 — Конструктор правил иконок
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/38
+- Приоритет: P2
+- Статус ТЗ: draft, data-model proposal требует утверждения
+
+## Цель
+
+Обычный пользователь создаёт правило без regex, видит порядок и объяснение
+первого совпадения; существующие regex остаются lossless advanced rules.
+
+## Модель v2
+
+```ts
+type IconRule =
+ | { kind: 'simple'; domains?: string[]; device_classes?: string[];
+ name_words?: string[]; model_words?: string[]; icon: string }
+ | { kind: 'regex'; pattern: string; icon: string };
+```
+
+Внутри одного simple rule непустые группы соединяются AND, значения группы —
+OR. `name_words/model_words` — case-insensitive literal tokens, не regex.
+Пустое условие невалидно. Порядок массива — приоритет, первое совпадение
+побеждает. Default built-in rules идут после custom.
+
+Legacy `{pattern,icon}` читается как `kind:'regex'` и не переписывается до
+явного Save/Optimize. Backend принимает оба shapes на окно совместимости.
+
+## UX
+
+- Список sortable keyboard/pointer, номер и summary каждого правила.
+- Simple editor: domain multiselect, device_class multiselect, words chips для
+ имени/модели, HA icon picker.
+- «Расширенный режим» показывает regex, live validation и предупреждение, что
+ правило проверяется раньше нижележащих.
+- Test device picker использует registry projection и показывает: итоговую
+ icon, rule number/kind и human-readable reason по каждому matched field.
+- Draft reorder/edit не влияет на plan до Save; Cancel lossless.
+
+## Compiler
+
+`compileIconRules()` нормализует оба shapes в predicates и diagnostics.
+Invalid regex отключает только правило с объяснением, но Save блокируется.
+Limits: max rules/words/string length, regex length; no flags кроме `i`.
+Runtime evaluation остаётся bounded; catastrophic regex mitigated длиной и
+опциональным safe-regex check, а не async worker в v1.
+
+## Проверки и приёмка
+
+- simple AND/OR semantics, case/whitespace, order, fallback;
+- legacy regex byte-preserving roundtrip;
+- invalid/overlong regex and limits backend/frontend parity;
+- test device explanation совпадает с реальным `resolveIcon`;
+- reorder keyboard, unsaved Cancel, narrow dialog;
+- существующий config даёт те же icons до явного редактирования.
diff --git a/docs/specs/039-large-backdrops.md b/docs/specs/039-large-backdrops.md
new file mode 100644
index 00000000..4f24ec3c
--- /dev/null
+++ b/docs/specs/039-large-backdrops.md
@@ -0,0 +1,58 @@
+# ТЗ #39 — Безопасная работа с большими подложками
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/39
+- Приоритет: P2
+- Статус ТЗ: research-first, thresholds утверждаются по benchmark
+
+## Цель
+
+До upload предупреждать о raster, способном исчерпать память tablet/WebView,
+и предлагать уменьшенную копию без потери текущего плана при ошибке.
+
+## Локальная диагностика
+
+Для PNG/JPEG/WebP до сетевого запроса:
+
+- MIME/signature и file bytes;
+- decoded width×height через `createImageBitmap`, fallback isolated ``;
+- megapixels и minimum decoded RGBA memory `w*h*4`;
+- estimated working peak: bitmap + canvas + encoded output (conservative 2.5×);
+- рекомендуемый target dimensions.
+
+Object URLs всегда revoke; decode timeout/error не заменяет текущий backdrop.
+SVG получает file-size/security validation, но не raster memory/downscale.
+
+## Threshold protocol
+
+Перед code default провести matrix на reference low-end wall tablet WebView и
+desktop Chromium: 4/8/16/32/64 MP, transparency, JPEG/WebP. Ship constants
+документируются как `WARN_DECODED_BYTES` и `MAX_SAFE_DIMENSION`. Рекомендуемый
+начальный guard для исследования: warning при >32 MP либо estimated peak
+>128 MiB; это не становится product constant без результатов.
+
+## UX
+
+- Safe: обычный upload.
+- Warning: dialog показывает resolution, file size, estimated memory и действия
+ «Загрузить уменьшенную копию», «Оставить оригинал», Cancel.
+- Hard browser limit/decode failure: только Cancel и инструкция уменьшить файл
+ desktop tool; нельзя продолжать blind.
+- Downscale сохраняет aspect; longest side до benchmark target. PNG с alpha
+ остаётся PNG, opaque photo — JPEG/WebP с documented quality; metadata не
+ требуется.
+
+## Transaction
+
+Existing backdrop/config остаются до успешного upload+validation+config save.
+Downscaled Blob проходит тот же quota/copy-on-write backend path. При upload,
+save или decode failure staging очищается, старый ref не удаляется. Undo и
+explicit plan deletion работают как сейчас.
+
+## Проверки и приёмка
+
+- header fixtures без выделения гигантского canvas до warning;
+- alpha/aspect/orientation (EXIF), corrupt/truncated, decode timeout;
+- failed upload/save preserves previous plan and files;
+- memory benchmark report + documented thresholds;
+- mobile dialog без horizontal scroll;
+- SVG никогда автоматически не растеризуется.
diff --git a/docs/specs/040-floor-area-onboarding.md b/docs/specs/040-floor-area-onboarding.md
new file mode 100644
index 00000000..7a89ff32
--- /dev/null
+++ b/docs/specs/040-floor-area-onboarding.md
@@ -0,0 +1,56 @@
+# ТЗ #40 — Floors/Areas как направляемый onboarding
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/40
+- Приоритет: P2
+- Статус ТЗ: draft, требует подтверждения fallback при отсутствии floor registry
+
+## Цель
+
+При создании комнат помогать последовательно разметить HA areas выбранного
+floor, оставаясь read-only потребителем HA registries.
+
+## Space ↔ floor
+
+Space draft получает optional `floor_id`. Import/create wizard предлагает HA
+floor, но разрешает «Без этажа». Существующий space без floor продолжает
+работать. House Plan не создаёт, не переименовывает и не переносит floor/area.
+
+## Room dialog
+
+Area options группируются:
+
+1. неиспользованные areas выбранного floor;
+2. уже используемые areas этого floor (disabled для duplicate binding, с
+ указанием комнаты);
+3. areas без floor/других floors в раскрываемой группе «Другие зоны»;
+4. явное option «Без зоны».
+
+При выборе area новое room name предлагается из `area.name`, но вручную
+введённое имя не перезаписывается. «Без зоны» является валидным select value и
+не требует отдельной кнопки.
+
+## Progress
+
+В Plan editor/room create flow: «Размечено N из M зон этажа». `M` — active HA
+areas выбранного floor, `N` — unique room.area этого space. Disabled/deleted
+areas не увеличивают M; orphaned saved binding отображается отдельно и не
+исчезает из room config.
+
+## Registry lifecycle
+
+- limited registry: UI сообщает, что список может быть неполным, не объявляет
+ зоны удалёнными;
+- floor change space не переносит areas автоматически; preview показывает
+ несоответствия и требует подтверждения;
+- area moved to another floor обновляет группировку, но не меняет room;
+- одна area не может быть назначена двум live rooms через UI; imported
+ collision показывается как validation warning.
+
+## Проверки и приёмка
+
+- floor/no-floor/limited/disabled/orphaned matrices;
+- name suggestion не затирает user input;
+- progress unique count и registry refresh;
+- keyboard/mobile select groups;
+- никакой HA registry write/service call;
+- room без area сохраняется обычной Save и ведёт себя как сейчас.
diff --git a/docs/specs/041-keyboard-object-editing.md b/docs/specs/041-keyboard-object-editing.md
new file mode 100644
index 00000000..7ab0a1ce
--- /dev/null
+++ b/docs/specs/041-keyboard-object-editing.md
@@ -0,0 +1,61 @@
+# ТЗ #41 — Клавиатурное редактирование выбранных объектов
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/41
+- Приоритет: P2
+- Статус ТЗ: prototype-first draft
+- Scope: desktop editors; touch editing остаётся best-effort
+
+## Цель Stage 1
+
+Выбирать существующие объекты, открывать свойства, удалять и точно сдвигать их
+по сетке без мыши. Полное рисование arbitrary polygon клавиатурой не входит.
+
+## Focus model
+
+Каждый editor предоставляет один `EditorObjectNavigator` с roving tabindex по
+объектам текущего editable layer и стабильным spatial order: top→bottom,
+left→right, затем id. Tab входит/выходит из navigator один раз; стрелки при
+навигации выбирают соседний объект, а не прокручивают page.
+
+Режимы:
+
+- `navigate`: стрелки меняют selection;
+- `move` после Space или явной tray action: стрелки двигают selected object;
+ Escape отменяет gesture до command commit, Enter завершает.
+
+Так устраняется конфликт «стрелка выбирает или двигает». Pointer selection
+синхронизирует active descendant.
+
+## Команды
+
+- Enter — свойства выбранного объекта;
+- Delete/Backspace — только delete выбранного через #32 там, где требуется;
+- Space — начать/закончить keyboard move;
+- arrows move = один grid node; Shift+arrow = 10 nodes;
+- Escape — отмена move, затем selection/tool по существующему приоритету;
+- Ctrl+Z/Ctrl+Shift+Z — общий named command stack.
+
+Команды запрещены при focus в input/textarea/select/contenteditable/dialog.
+Browser scroll/pan shortcuts остаются вне navigator.
+
+## Geometry semantics
+
+Move применяет существующие snap/validation/command APIs объекта. Room vertices
+и wall resize не входят в Stage 1; целиком movable markers, openings вдоль
+wall, partitions, columns и decor входят по мере наличия безопасной команды.
+Невалидный move не commit-ится и объявляет причину.
+
+## Announcements
+
+`aria-live=polite`: выбран тип/имя; move start; координаты/длина/угол после
+шага; invalid reason; Undo/Redo command name. Частые key repeats throttled, но
+финальное значение всегда объявляется.
+
+## Проверки и приёмка
+
+- prototype доказывает отсутствие scroll/pan/form conflicts;
+- navigator order после add/delete/space change;
+- move every supported object, invalid/opening constraints, command undo;
+- focus restore после property/confirm dialog;
+- NVDA/Chrome manual checklist и browser keyboard smoke;
+- unsupported object имеет свойства/delete, но не ложно доступный Move.
diff --git a/docs/specs/042-backend-engineering-quality.md b/docs/specs/042-backend-engineering-quality.md
new file mode 100644
index 00000000..a3f18d5a
--- /dev/null
+++ b/docs/specs/042-backend-engineering-quality.md
@@ -0,0 +1,61 @@
+# ТЗ #42 — Измеряемое инженерное качество backend
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/42
+- Приоритет: P2
+- Статус ТЗ: ready for implementation by incremental gates
+- Тип: infra/tests, без изменения успешных пользовательских сценариев
+
+## Цель
+
+Зафиксировать честное покрытие, строгую типизацию и стабильный error contract
+backend integration без массового formatting rewrite.
+
+## Coverage
+
+- Добавить pinned `pytest-cov`; CI запускает Python 3.13 HA harness и pure tests
+ одним coverage combine workflow.
+- Baseline публикуется по каждому `custom_components/houseplan/*.py` с branch
+ coverage. Первое включение не скрывает skipped HA tests.
+- Gate вводится ступенями: не ниже baseline → 90% → минимум 95% executable
+ lines и согласованный branch threshold. Generated/frontend bundle исключён.
+- `coverage.xml` artifact и human summary; новые/изменённые строки требуют 100%
+ либо documented pragma для unreachable defensive branch.
+
+## Typing
+
+Pyright/mypy strict включается per-module allowlist:
+
+1. `validation.py`, `store.py`, auth/const;
+2. websocket request/result boundaries;
+3. repairs/diagnostics/system_health;
+4. trails/runtime.
+
+HA dynamic APIs изолируются typed Protocol/adapter, а не `Any` по всему модулю.
+Allowlist только уменьшается.
+
+## Lint/format
+
+Ruff (или один выбранный tool) с narrow rule set: errors/imports/bugbear и
+format-check только для новых/затронутых Python файлов. Отдельный mechanical
+PR может нормализовать остальное; feature diff не содержит repo-wide rewrite.
+
+## WebSocket error contract
+
+Все user-facing failures имеют stable code enum, safe developer message и
+optional structured details без персональных данных. Frontend mapping ru/en
+не сравнивает английские message strings. Unknown code получает общий fallback.
+
+## Quality Scale docs
+
+Добавить troubleshooting, examples и проверить manifest/quality_scale claims.
+Нельзя отмечать rule выполненным только наличием файла — acceptance следует HA
+rule text и CI evidence.
+
+## Приёмка
+
+- CI нельзя пройти с silently skipped HA harness;
+- coverage ≥95% executable lines после staged rollout;
+- strict module allowlist и lint gates зелёные;
+- WS tests проверяют code + frontend localization;
+- docs examples исполняемы/проверяемы;
+- изменения tooling не меняют stored data/runtime result.
diff --git a/docs/specs/043-private-support-report.md b/docs/specs/043-private-support-report.md
new file mode 100644
index 00000000..2b2ceb9a
--- /dev/null
+++ b/docs/specs/043-private-support-report.md
@@ -0,0 +1,60 @@
+# ТЗ #43 — Отчёт для поддержки без персональных данных
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/43
+- Приоритет: P2
+- Статус ТЗ: draft, privacy defaults нормативны
+
+## Цель
+
+Пользователь копирует воспроизводимый технический snapshot, предварительно
+видя весь текст. Default report не содержит данных, по которым можно
+восстановить дом, устройства или адреса.
+
+## Формат
+
+Versioned JSON/text envelope `houseplan_support_report: 1`:
+
+- card, integration и HA versions;
+- browser engine family/major и supported feature flags без user-agent string;
+- model/config/layout schema versions и revisions;
+- counts: spaces, rooms, room drafts, physical/open walls, partitions,
+ columns, openings по типу, decor по kind, markers по lifecycle/status;
+- read-only validation result: stable codes + counts;
+- optimizer/migration pending flags;
+- active House Plan Repair issue ids/codes без descriptions/user values;
+- registry authority level (`full|limited|unknown`) и last sync age bucket;
+- checksum только структуры/schema, не raw config.
+
+## Privacy allowlist
+
+Report строится исключительно из typed allowlist projection. Запрещены:
+space/room/device/entity names и ids, area/floor/config-entry ids, coordinates,
+polygons, URLs, filenames/paths, descriptions, templates/live values, IP/host,
+HA installation id, exact timestamps событий. Redaction после сериализации не
+считается защитой.
+
+Adversarial fixture заполняет каждое запрещённое поле уникальным sentinel;
+ни один sentinel/его URL-encoded/base64 form не встречается в report.
+
+## Архитектура
+
+Backend read-only `houseplan/support/report` выполняет authoritative schema,
+store и Repair projection; frontend добавляет card/browser flags. Command
+доступен authenticated user, не делает writes/services и возвращает stable
+error codes. Если backend старый, frontend создаёт reduced report и явно это
+пишет.
+
+## UX
+
+General Settings → Maintenance → «Скопировать отчёт для поддержки». Открывается
+wide `hp-dialog` с plain-text preview, предупреждением privacy и действиями
+Copy/Download/Cancel. Clipboard failure предлагает `.json` download. Никакой
+автоматической отправки/телеметрии.
+
+## Приёмка
+
+- sentinel privacy corpus зелёный frontend/backend;
+- preview полностью равен copied/downloaded bytes;
+- report работает при invalid config, limited registry и active Repairs;
+- copy/download доступен keyboard/touch;
+- документация перечисляет включённые и исключённые категории.
diff --git a/docs/specs/044-filter-grouping-policy.md b/docs/specs/044-filter-grouping-policy.md
new file mode 100644
index 00000000..7f9a3658
--- /dev/null
+++ b/docs/specs/044-filter-grouping-policy.md
@@ -0,0 +1,59 @@
+# ТЗ #44 — Явные фильтры и группировка устройств
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/44
+- Приоритет: P2
+- Статус ТЗ: policy proposal ready for owner review
+- Связано: device inbox #29, field registry #33
+
+## Цель
+
+Убрать скрытые настройки, влияющие на discovery. Каждый ключ либо получает
+поддерживаемый advanced UI и documented default, либо мигрирует в фиксированное
+product rule.
+
+## Решение v1
+
+Оба текущих ключа остаются поддерживаемыми и становятся видимыми в Advanced
+разделе inbox:
+
+### `group_lights`
+
+- default `true`;
+- label: «Объединять несколько светильников комнаты»;
+- preview показывает, какие bindings будут группой/отдельными marker;
+- переключение не применяется до Save и не удаляет explicit markers;
+- existing light-group marker сохраняет lifecycle; конфликт bindings preview.
+
+### `exclude_integrations`
+
+- default — текущий `EXCLUDED_DOMAINS`/product list;
+- UI — searchable multi-select интеграций, реально присутствующих в registry,
+ плюс reset to recommended defaults;
+- изменение влияет только на automatic candidates/seed, не скрывает explicit
+ live marker и не удаляет tombstone;
+- reason в inbox — «Исключена интеграция X».
+
+Название storage key `exclude_integrations` сохраняется для compatibility;
+семантика и default фиксируются в registry #33.
+
+## Исследование перед включением Save
+
+Локальный config-audit считает значения/отклонения от defaults без вывода ids.
+Synthetic fixtures моделируют реальные классы: group lights, parent device,
+service/bridge, explicit override, tombstone. Если audit показывает, что ключ
+невозможно объяснить без вредного поведения, owner может отдельным решением
+перевести его в fixed rule до реализации UI.
+
+## UX/transaction
+
+Preview diff: новые/скрытые/grouped candidates counts и конкретный список в
+локальной session. Apply пишет settings один раз, rebuild devices и создаёт
+named undo/config snapshot. Cancel no-op. Два клиента используют config rev.
+
+## Приёмка
+
+- ни один runtime discovery key не скрыт от пользователя/registry;
+- defaults совпадают frontend, backend, docs и fixtures;
+- explicit marker никогда не исчезает из-за filter change;
+- preview объясняет каждую изменившуюся binding;
+- old config даёт прежний result до user action.
diff --git a/docs/specs/050-config-export-import.md b/docs/specs/050-config-export-import.md
new file mode 100644
index 00000000..7360be10
--- /dev/null
+++ b/docs/specs/050-config-export-import.md
@@ -0,0 +1,80 @@
+# ТЗ #50 — Экспорт/импорт конфигурации и пространства
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/50
+- Приоритет: P1
+- Статус ТЗ: ready for review
+- Security scope: только владелец/admin согласно существующей write policy
+
+## Цель
+
+Дать переносимую резервную копию модели House Plan и безопасный перенос одного
+пространства между собственными HA instances без silent overwrite.
+
+## Формат v1
+
+Один UTF-8 JSON с envelope:
+
+```json
+{
+ "format": "houseplan-export",
+ "export_version": 1,
+ "kind": "full|space",
+ "created_at": "ISO-8601",
+ "card_version": "…",
+ "integration_version": "…",
+ "model_version": 0,
+ "payload": { "config": {}, "layout": {} },
+ "content_manifest": []
+}
+```
+
+Trails, optimizer undo/pending snapshots, revisions, signed URL tokens и
+runtime caches не экспортируются. Config и layout экспортируются вместе:
+backup без positions неполон.
+
+`content_manifest` описывает ссылки, но v1 JSON не содержит бинарные файлы.
+На той же instance существующие content refs продолжают работать. При
+cross-instance import локальная `/api/houseplan/content/...` ссылка помечается
+missing и пропускается/очищается только после явного подтверждения preview;
+external safe URLs сохраняются. Portable ZIP с assets — отдельный будущий этап.
+
+## Full export/import
+
+- Export содержит полные `spaces`, `markers`, `settings` и live layout records.
+- Import проходит envelope limits, JSON parse, migration/read compatibility,
+ `CONFIG_SCHEMA` и `LAYOUT_SCHEMA`, затем dry-run preview.
+- Preview: counts current→incoming, spaces replaced, markers/layout, orphaned
+ bindings, missing content, unknown future fields, incompatible version.
+- Apply — один admin-only backend command под `write_lock`: recheck expected
+ config/layout revisions, atomic two-store transaction/rollback, затем events.
+- Full import заменяет модель полностью; Cancel не пишет ничего.
+
+## Space export/import
+
+- Payload содержит один space целиком и только относящиеся к нему markers и
+ layout ids: marker ids, `rl_` и другие документированные owners.
+- Import всегда добавляет новое пространство. Новый safe id генерируется;
+ title conflict получает ` (2)`, ` (3)`.
+- Все внутренние room/opening/decor/draft ids remap при collision; references
+ (`open_to`, room_id, layout keys) переписываются по одной map.
+- HA binding/entity ids сохраняются. Не существующие на target становятся
+ orphaned через текущий lifecycle, import не угадывает замену.
+- Binding, уже занятая live marker target config, не дублируется молча: preview
+ предлагает импортировать marker как unbound virtual copy либо пропустить;
+ default — пропустить marker, geometry пространства сохранить.
+
+## Ограничения и безопасность
+
+Размер файла и collection caps не выше backend schema; prototype pollution
+keys и non-finite JSON невозможны. Export скачивается локально, без внешней
+телеметрии. Никакие HA services не вызываются при preview.
+
+## Проверки и приёмка
+
+- full roundtrip сохраняет normalized config+layout и unknown future fields;
+- atomic rollback при второй store failure и revision race;
+- remap всех space-owned ids/references, name/id collisions;
+- orphan/disabled/removed/virtual/vacuum calibration cases;
+- invalid/oversize/newer export не меняет stores и даёт stable error code;
+- mobile/desktop preview, explicit confirmation и локализованные итоги;
+- документация прямо говорит, что v1 JSON не переносит бинарные assets/trails.
diff --git a/docs/specs/051-custom-decor-images.md b/docs/specs/051-custom-decor-images.md
new file mode 100644
index 00000000..82e75420
--- /dev/null
+++ b/docs/specs/051-custom-decor-images.md
@@ -0,0 +1,76 @@
+# ТЗ #51 — Пользовательские изображения в декоративном слое
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/51
+- Приоритет: P2
+- Статус ТЗ: draft, security dependencies обязательны
+- Зависимости: color/CSS audit #21; large raster pipeline #39
+
+## Цель
+
+Размещать PNG/JPEG/WebP/SVG как decor element с теми же move/resize/rotate,
+opacity, layer order и per-space lifecycle, что у остальных объектов.
+
+## Модель
+
+```ts
+type ImageDecor = {
+ id: string; kind: 'image'; asset_id: string;
+ x: number; y: number; w: number; h: number;
+ angle?: number; opacity?: number;
+ preserve_aspect?: boolean;
+};
+```
+
+Config хранит stable opaque `asset_id`, не signed URL. Render запрашивает
+короткоживущий same-origin content URL существующим signer. Asset metadata
+backend: id, sanitized original name, MIME, bytes, width/height для raster,
+sha256, created/owner space. Blob не в config/layout.
+
+## Storage и transaction
+
+- Новый decor asset namespace под существующим House Plan content root;
+- admin/write permission, streaming limit default 2 MiB после преобразования;
+- upload staging → validate/sanitize → promote только при successful config save;
+- отменённый dialog/failed save очищает staging; referenced asset не удаляется;
+- explicit element delete предлагает удалить unreferenced asset; shared refs
+ считаются, inference/age deletion запрещены;
+- HA backup включает content folder автоматически.
+
+## Raster
+
+PNG/JPEG/WebP проходит #39 diagnostics. Oversized source downscale до safe
+target; existing plan/config не меняется до success. EXIF orientation
+нормализуется. Initial `w/h` сохраняют aspect и разумный размер относительно
+current view; пользователь может снять preserve-aspect в properties.
+
+## SVG security
+
+Не использовать regex sanitizer. XML parser запрещает DTD/entities, удаляет
+`script`, `foreignObject`, animation, event attributes, external/data URLs,
+`style` с unsafe constructs и неизвестные namespaces; локальные paint/geometry
+элементы allowlisted. После sanitization файл повторно парсится, сериализуется
+и обслуживается с `image/svg+xml`, `nosniff`, CSP sandbox. При невозможности
+гарантировать sanitizer SVG отклоняется, а не сохраняется как есть.
+
+## Editor UX
+
+- Background → Image → выбрать файл → preview → клик по плану;
+- selection frame/handles/context tray общие с rect/furniture;
+- double-click properties: replace asset, opacity, aspect lock, numeric size,
+ angle, layer actions;
+- replace transactional и не удаляет старый asset до successful save;
+- missing asset показывает bounded placeholder и repair action, не broken icon.
+
+## Export/import и lifecycle
+
+JSON export #50 перечисляет asset в manifest, но не переносит bytes. Будущий
+portable asset bundle использует stable id remap. Space/delete cleanup только
+явный и reference-aware.
+
+## Проверки и приёмка
+
+- all MIME signatures, spoofed extension, size/quota, corrupt and SVG corpus;
+- staging promote/rollback, shared references, delete/replace/reload;
+- transforms, undo/redo, copy/paste/optimization and unbounded canvas;
+- no external request/script/style breakout from SVG;
+- mobile View renders image; touch editing best-effort documented.
diff --git a/docs/specs/052-view-dimensions.md b/docs/specs/052-view-dimensions.md
new file mode 100644
index 00000000..09587c50
--- /dev/null
+++ b/docs/specs/052-view-dimensions.md
@@ -0,0 +1,60 @@
+# ТЗ #52 — Размеры стен и площади в View
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/52
+- Приоритет: P2
+- Статус ТЗ: ready for review
+- Зависимость для будущего PDF export
+
+## Цель
+
+Добавить read-only architectural annotation layer на основе существующей
+геометрии и `cell_cm`, не создавая второй системы измерений.
+
+## Настройка
+
+Per-space `settings.dimensions: 'off' | 'areas' | 'full'`, default `off`.
+Отдельный card/kiosk default: kiosk всегда скрывает dimensions, пока будущая
+явная card option не разрешит их. Editor preview использует effective setting.
+
+## Площади
+
+- `areas` и `full` показывают значение чистого пола через тот же
+ clean-floor/`geometryArea` resolver, что tooltip #28.
+- Единицы/округление — существующий `formatArea`; metric m², imperial ft².
+- Label anchor — сохранённый room label position либо interior visual point;
+ annotation не перемещает пользовательское room name.
+- Очень малая/узкая room скрывает annotation при отсутствии безопасного места,
+ tooltip/card всё равно содержит площадь.
+
+## Длины
+
+- `full` аннотирует unique physical wall centreline spans из текущей
+ normalized wall model. Общая стена рисуется один раз.
+- Virtual/open boundary не получает physical length annotation.
+- Doors/windows/gates не вычитаются из общей длины span; это размер стены по
+ оси, а не чистая кладка.
+- Partitions и physical segments сохранённых room drafts входят; columns не
+ имеют линейного размера. Толщина не меняет длину оси.
+- Collinear мусорные fragments сначала проходят существующую compaction;
+ annotation не объединяет через угол/разрыв.
+
+Label расположен параллельно span, читаемый текст никогда не upside-down;
+offset выбирается со стороны вне clean floor при возможности. Значение
+использует `segmentCm`/`formatLength` и space grid scale.
+
+## Collision/zoom
+
+Projection вычисляет candidates и детерминированно скрывает labels, чьи screen
+bbox пересекаются с более приоритетными (selected room, outer wall, longer
+span). Ни один label не становится меньше доступного font minimum. На малом
+zoom слой постепенно скрывает wall dimensions, затем areas; stored setting не
+меняется. `pointer-events:none`.
+
+## Проверки и приёмка
+
+- rectangles, diagonal/L/nested/shared/thick/open walls, partitions/drafts;
+- metric/imperial, different cell_cm, resize and optimize;
+- deterministic collision at several viewports/zooms;
+- View/kiosk/editor visibility and print-friendly golden;
+- area совпадает с room card, length с live ruler для того же span;
+- никакой config mutation от отображения слоя.
diff --git a/docs/specs/054-zigbee-topology-overlay.md b/docs/specs/054-zigbee-topology-overlay.md
new file mode 100644
index 00000000..98f29eb1
--- /dev/null
+++ b/docs/specs/054-zigbee-topology-overlay.md
@@ -0,0 +1,74 @@
+# ТЗ #54 — Диагностический Zigbee topology overlay
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/54
+- Приоритет: P2
+- Статус ТЗ: research + adapter contract; provider APIs проверяются до кода
+
+## Цель
+
+По явному запросу показать связи ZHA/Zigbee2MQTT между уже размещёнными
+устройствами поверх реальной геометрии, не превращая House Plan в редактор сети.
+
+## Stage 0 — capability research
+
+На поддерживаемых версиях HA зафиксировать официальные/фактические API:
+
+- ZHA topology/neighbour source, permissions, direction и LQI semantics;
+- Zigbee2MQTT networkmap request/response через HA MQTT integration либо
+ документированную exposed entity;
+- соответствие IEEE ↔ HA device registry `connections`;
+- timeout/rate limits и поведение sleeping end devices.
+
+Неподтверждённые command/topic не hardcode-ятся. Каждый provider имеет fixture
+с обезличенными ids и дату/версию проверенного контракта.
+
+## Normalized model
+
+```ts
+type ZigbeeTopology = {
+ provider: 'zha' | 'z2m'; capturedAt: number;
+ nodes: Array<{ key: string; deviceId?: string; role: 'coordinator'|'router'|'end' }>;
+ links: Array<{ from: string; to: string; lqi?: number; direction: 'one'|'both'|'unknown' }>;
+ warnings: string[];
+};
+```
+
+Provider adapters живут backend-side либо в изолированном HA adapter; render
+не знает API. Browser не подключается к MQTT напрямую и не хранит topology в
+House Plan config.
+
+## Fetch/cache/security
+
+- Overlay off по умолчанию; первый toggle запускает fetch с progress/cancel.
+- Shared per-provider cache TTL 60 s (уточняется Stage 0), in-flight dedupe и
+ hard timeout. Toggle off отменяет UI ожидание, но безопасный backend request
+ может завершить cache.
+- Refresh только явный или после TTL; HA state ticks не сканируют topology.
+- Read permission проверяется backend. Ошибка/unsupported не ломает plan и
+ показывает localized status.
+
+## Mapping и визуал
+
+Node сопоставляется marker только через device registry/explicit entity owner.
+На plan рисуются links, у которых оба конца имеют видимые live markers текущего
+space. Остальные nodes доступны в summary «Не размещено N», но не рисуются.
+
+Edges — pointer-transparent отдельный layer. Цвет/opacity/thickness отражают
+нормализованный LQI только при сопоставимой шкале provider; unknown — нейтральный
+пунктир. Direction optional arrow только при достоверных данных. Coordinator
+и router имеют legend. Geometry стен не интерпретируется как причина сигнала.
+
+## Multiple spaces и lifecycle
+
+Cross-space link не рисуется как линия через разные планы; endpoints получают
+badge/count и список другого пространства. Hidden/removed/disabled marker не
+рисуется. Rebind refreshes mapping без нового network scan.
+
+## Performance и проверки
+
+- 20/100/500 nodes, dense mesh; edge cap/viewport culling после Stage 0;
+- provider fixtures, timeout, malformed/cyclic/duplicate links;
+- mapping IEEE/device/entity, cross-space, hidden lifecycle;
+- lazy request/in-flight cache and no fetch on ordinary HA ticks;
+- golden LQI/unknown legend и performance budget;
+- network layer полностью исчезает при toggle off и не меняет config.
diff --git a/docs/specs/055-independent-glow-overlay.md b/docs/specs/055-independent-glow-overlay.md
new file mode 100644
index 00000000..5cf31b5d
--- /dev/null
+++ b/docs/specs/055-independent-glow-overlay.md
@@ -0,0 +1,66 @@
+# ТЗ #55 — Glow как независимый overlay поверх data fill
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/55
+- Приоритет: P2
+- Статус ТЗ: ready for review
+- Связано: room override #36; additive pools #19
+
+## Цель
+
+Разделить две независимые функции: data/static room fill и световой Glow.
+Пользователь может видеть temperature/LQI/light/custom color и Glow одновременно.
+
+## Модель
+
+- `space.settings.fill_mode`: `none|lqi|light|temp|custom`;
+- `space.settings.glow_enabled?: boolean`;
+- `room.settings.fill_mode`: прежний inherit/override + `custom` после #56;
+- `room.settings.glow?: boolean|null` по #36.
+
+Effective projection возвращает `{fill, glow}` двумя полями. Ни renderer, ни
+opening tunnel не выводят Glow из fill mode после migration.
+
+## Read compatibility и migration
+
+Old `space.settings.fill_mode:'glow'` читается как
+`fill_mode:'none', glow_enabled:true` без записи. Old room
+`settings.fill_mode:'glow'`, если встречается future/legacy config, читается как
+`fill inherit, room.glow:true`.
+
+Новый UI при первом Save не обязан мигрировать untouched fields. Явное
+«Оптимизировать планы» показывает conversion и atomic undo. Backend окно чтения
+регистрируется в #33; новые writes не используют `fill_mode:'glow'` после
+начала migration phase.
+
+## Render order
+
+1. paper/backdrop;
+2. resolved data/static room fill и matching opening tunnel floor fill;
+3. Glow base darkness в clips effective-Glow rooms;
+4. tunnel light sectors и radial pools;
+5. sun/interactive layers согласно текущему contract.
+
+Glow base не заменяет data fill: compositing/opacity должен сохранять читаемый
+underlay. Radial pools используют isolated additive group #19, если feature
+доступна. Все Glow shapes pointer-transparent.
+
+## UX
+
+Space dialog: отдельные controls «Заливка комнаты» и «Свечение источников».
+Global palette разделяет data colors и Glow colors. Room dialog показывает
+independent fill override и tri-state Glow #36. Preview обновляет оба без Save.
+
+## Edge cases
+
+Room Glow off, nested holes, doors/gates, virtual/physical walls,
+partitions/columns, no sources, source-glow device status, hidden/removed light,
+show_borders false. Отключение overlay не меняет light aggregates/controls.
+
+## Проверки и приёмка
+
+- compatibility truth table old/new space+room values;
+- все data fills одновременно с Glow, tunnel colors и hover;
+- Optimize preview/apply/undo и future field preservation;
+- golden dark/light, temp+Glow, custom+Glow, mixed room overrides;
+- старый config до migration выглядит без pixel regression;
+- модель больше не требует выбрать Glow вместо полезной заливки.
diff --git a/docs/specs/056-static-room-color.md b/docs/specs/056-static-room-color.md
new file mode 100644
index 00000000..a45eeb05
--- /dev/null
+++ b/docs/specs/056-static-room-color.md
@@ -0,0 +1,60 @@
+# ТЗ #56 — Статичная пользовательская заливка комнаты
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/56
+- Приоритет: P2
+- Статус ТЗ: ready after security gate #21
+- Совместимо: независимый Glow #55
+
+## Цель
+
+Добавить обычную выбранную пользователем room color как fill option на уровне
+пространства и комнаты, не смешивая её с border/name color.
+
+## Модель
+
+- `fill_mode:'custom'` добавляется frontend/backend enum;
+- `space.settings.custom_fill?: {c:string,a:number}` — default пространства;
+- `room.settings.custom_fill?: {c:string,a:number}|null` — explicit override;
+- effective color при room `fill_mode:'custom'`: room value → space value →
+ documented default `#607d8b` с alpha 0.18.
+
+Если room наследует fill mode пространства, его custom color всё равно может
+быть заранее сохранён и используется только когда effective mode custom.
+Смена режима не стирает color.
+
+## Security
+
+Каждый color проходит render-time `safeColor()` #21; backend применяет общий
+defence-in-depth validator. Invalid old value не ломает room и получает safe
+default без silent config rewrite. Opacity finite/clamp 0..1.
+
+## UX
+
+- Space dialog fill options: Нет, LQI, Свет, Температура, Свой цвет.
+- При «Свой цвет» появляется общий `hp-color-opacity`.
+- Room dialog: inherit/modes; для custom — «Цвет пространства» либо explicit
+ color+opacity с Reset.
+- Live preview использует тот же `resolveEffectiveRoomFill` projection.
+- Border/name `room_color` остаётся отдельным control с ясной подписью.
+
+## Render integration
+
+Custom entry добавляется в единый fill resolver и автоматически используется
+room floor, clean-floor holes и thick-wall opening tunnel fill. Hover сохраняет
+effective color. Glow #55 рисуется поверх, но не изменяет stored custom alpha.
+
+## Edge cases
+
+Transparent alpha 0 равен визуально no fill, но mode сохраняется. Nested rooms
+имеют независимые colors; parent не красит hole. Light/dark theme, borders off,
+partitions/columns и openings используют текущую geometry. Import future modern
+color, не поддержанный browser, безопасно fallback-ится.
+
+## Проверки и приёмка
+
+- resolver inheritance/mode/color/alpha truth table;
+- frontend/backend enum and range parity #33;
+- hostile/invalid/modern color corpus #21;
+- room/tunnel/hover/nested visual golden, custom+Glow;
+- Save/reload/reset/Cancel и old config no-op roundtrip;
+- пользователь может назначить устойчивый цвет комнате без влияния HA state.
diff --git a/docs/specs/058-vacuum-stage1.md b/docs/specs/058-vacuum-stage1.md
new file mode 100644
index 00000000..04f51299
--- /dev/null
+++ b/docs/specs/058-vacuum-stage1.md
@@ -0,0 +1,52 @@
+# ТЗ #58 — HP-VAC-02 Stage 1: покрытие vacuum-интеграций
+
+- Issue: https://github.com/Matysh/houseplan-card/issues/58
+- Приоритет: P1
+- Статус ТЗ: реализовано с owner override F6; целевой gate v1.61.0-beta.1 пройден, ожидается публикация
+- Полная нормативная спецификация: `docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md`
+
+## Назначение документа
+
+Этот файл связывает каноническую issue с утверждённой rev.7 и фиксирует
+release boundary. Формулы, состояния и тестовые векторы не дублируются: при
+расхождении источником истины является полная нормативная спецификация.
+
+## Scope Stage 1
+
+- Dreame/Mova, Xiaomi Cloud Map Extractor и MQTT Vacuum Camera/Valetudo;
+- sticky source resolver и внешний camera picker;
+- multi-subpath path без мостов, 64 сегмента/4000 точек;
+- room area-centroid, bbox-center как последний compatibility fallback,
+ auto-fit по пригодным совпавшим именам;
+- confirm до записи matrix при residual >40 см;
+- стабильный map id без nonce `vacuum_json_id`;
+- capability diagnostics и deduplicated backend source health;
+- общие fixture TS/Python, unit/backend/browser tests и ru/en docs.
+
+## Не входит
+
+- Roomba string pose — #10, отдельный Stage 2;
+- path для Dreame/Valetudo без подтверждённого контракта;
+- новая модель сохранённых matrix/trails.
+
+## Дочерние задачи
+
+- #6 — XCME multi-subpath;
+- #7 — Valetudo outlines;
+- #8 — support matrix и XCME setup hint;
+- #11 — source health lifecycle;
+- #27 — explicit source picker.
+
+## Release gate
+
+1. Targeted unit + backend transitions + оба vacuum smoke.
+2. Production build и синхронные bundle snapshots.
+3. Beta/RC до stable согласно promotion rule.
+4. Issue и дочерние задачи закрываются только после зелёного exact-SHA CI и
+ проверки release asset.
+
+## Приёмка
+
+Приёмка равна чек-листу rev.7: resolver не делает silent rebind, subpaths не
+соединяются, outline rooms калибруются, high residual не пишет config, map-id
+не рвётся от nonce, health warnings дедуплицированы, старые данные не мигрируют.
diff --git a/docs/specs/README.md b/docs/specs/README.md
new file mode 100644
index 00000000..660f4d34
--- /dev/null
+++ b/docs/specs/README.md
@@ -0,0 +1,61 @@
+# Спецификации задач P1 и P2
+
+Актуально на 2026-08-09.
+
+GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub.
+
+Статусы ТЗ:
+
+- **готово к реализации** — продуктовые решения зафиксированы, остаётся техническая работа;
+- **черновик решения** — в ТЗ дана рекомендация, но перед реализацией требуется принять отмеченные продуктовые решения;
+- **в реализации** — работа уже ведётся в рамках текущего этапа;
+- **реализовано** — ТЗ сохранено как проверяемый acceptance contract.
+
+## P1
+
+| Issue | ТЗ | Статус ТЗ |
+|---|---|---|
+| [#6](https://github.com/Matysh/houseplan-card/issues/6) Vacuum XCME path segments | [006-vacuum-xcme-path.md](006-vacuum-xcme-path.md) | в реализации |
+| [#7](https://github.com/Matysh/houseplan-card/issues/7) Valetudo room outlines | [007-vacuum-valetudo-room-outlines.md](007-vacuum-valetudo-room-outlines.md) | в реализации |
+| [#8](https://github.com/Matysh/houseplan-card/issues/8) Vacuum support docs and XCME hint | [008-vacuum-support-docs-xcme-hint.md](008-vacuum-support-docs-xcme-hint.md) | в реализации |
+| [#27](https://github.com/Matysh/houseplan-card/issues/27) External vacuum source picker | [027-vacuum-external-source-picker.md](027-vacuum-external-source-picker.md) | в реализации |
+| [#28](https://github.com/Matysh/houseplan-card/issues/28) Room View card | [028-room-view-card.md](028-room-view-card.md) | черновик решения |
+| [#29](https://github.com/Matysh/houseplan-card/issues/29) Device inbox lifecycle | [029-device-inbox-lifecycle.md](029-device-inbox-lifecycle.md) | черновик решения |
+| [#30](https://github.com/Matysh/houseplan-card/issues/30) Dialog information architecture | [030-dialog-information-architecture.md](030-dialog-information-architecture.md) | готово к ревью |
+| [#31](https://github.com/Matysh/houseplan-card/issues/31) View accessibility | [031-view-accessibility.md](031-view-accessibility.md) | черновик: требуется a11y-проверка |
+| [#32](https://github.com/Matysh/houseplan-card/issues/32) Unified danger confirmation | [032-unified-danger-confirmation.md](032-unified-danger-confirmation.md) | готово к реализации |
+| [#33](https://github.com/Matysh/houseplan-card/issues/33) Config schema lifecycle | [033-config-schema-lifecycle.md](033-config-schema-lifecycle.md) | в реализации |
+| [#34](https://github.com/Matysh/houseplan-card/issues/34) Frontend decomposition | [034-frontend-decomposition.md](034-frontend-decomposition.md) | в реализации |
+| [#35](https://github.com/Matysh/houseplan-card/issues/35) Current UX documentation | [035-current-ux-docs.md](035-current-ux-docs.md) | готово к реализации |
+| [#50](https://github.com/Matysh/houseplan-card/issues/50) Config export/import | [050-config-export-import.md](050-config-export-import.md) | готово к ревью |
+| [#58](https://github.com/Matysh/houseplan-card/issues/58) Vacuum integration coverage — Stage 1 | [058-vacuum-stage1.md](058-vacuum-stage1.md) | в реализации |
+
+## P2
+
+| Issue | ТЗ | Статус ТЗ |
+|---|---|---|
+| [#10](https://github.com/Matysh/houseplan-card/issues/10) Roomba live position | [010-vacuum-roomba-live-position.md](010-vacuum-roomba-live-position.md) | черновик: требуется UX-решение |
+| [#11](https://github.com/Matysh/houseplan-card/issues/11) Vacuum source health | [011-vacuum-source-health.md](011-vacuum-source-health.md) | в реализации |
+| [#12](https://github.com/Matysh/houseplan-card/issues/12) Room cleaning highlight | [012-vacuum-room-cleaning-highlight.md](012-vacuum-room-cleaning-highlight.md) | готово к ревью |
+| [#13](https://github.com/Matysh/houseplan-card/issues/13) Golden open context tray | [013-golden-open-context-tray.md](013-golden-open-context-tray.md) | реализовано |
+| [#19](https://github.com/Matysh/houseplan-card/issues/19) Additive Glow blending | [019-glow-additive-blending.md](019-glow-additive-blending.md) | черновик: performance gate |
+| [#20](https://github.com/Matysh/houseplan-card/issues/20) Glow through open doors | [020-glow-open-door-spill.md](020-glow-open-door-spill.md) | готово к реализации |
+| [#21](https://github.com/Matysh/houseplan-card/issues/21) Safe color CSS variables | [021-color-css-injection.md](021-color-css-injection.md) | готово к реализации |
+| [#36](https://github.com/Matysh/houseplan-card/issues/36) Room Glow override | [036-room-glow-override.md](036-room-glow-override.md) | черновик продуктового решения |
+| [#37](https://github.com/Matysh/houseplan-card/issues/37) Room scale system | [037-room-scale-system.md](037-room-scale-system.md) | черновик migration semantics |
+| [#38](https://github.com/Matysh/houseplan-card/issues/38) Icon rule builder | [038-icon-rule-builder.md](038-icon-rule-builder.md) | черновик data model |
+| [#39](https://github.com/Matysh/houseplan-card/issues/39) Large backdrops | [039-large-backdrops.md](039-large-backdrops.md) | research-first |
+| [#40](https://github.com/Matysh/houseplan-card/issues/40) Floors/Areas onboarding | [040-floor-area-onboarding.md](040-floor-area-onboarding.md) | черновик fallback-политики |
+| [#41](https://github.com/Matysh/houseplan-card/issues/41) Keyboard object editing | [041-keyboard-object-editing.md](041-keyboard-object-editing.md) | prototype-first draft |
+| [#42](https://github.com/Matysh/houseplan-card/issues/42) Backend engineering quality | [042-backend-engineering-quality.md](042-backend-engineering-quality.md) | готово к реализации |
+| [#43](https://github.com/Matysh/houseplan-card/issues/43) Private support report | [043-private-support-report.md](043-private-support-report.md) | черновик privacy defaults |
+| [#44](https://github.com/Matysh/houseplan-card/issues/44) Filtering/grouping policy | [044-filter-grouping-policy.md](044-filter-grouping-policy.md) | готово к product review |
+| [#51](https://github.com/Matysh/houseplan-card/issues/51) Custom decor images | [051-custom-decor-images.md](051-custom-decor-images.md) | черновик: security dependencies |
+| [#52](https://github.com/Matysh/houseplan-card/issues/52) Dimensions in View | [052-view-dimensions.md](052-view-dimensions.md) | готово к ревью |
+| [#54](https://github.com/Matysh/houseplan-card/issues/54) Zigbee topology overlay | [054-zigbee-topology-overlay.md](054-zigbee-topology-overlay.md) | research + adapter contract |
+| [#55](https://github.com/Matysh/houseplan-card/issues/55) Independent Glow overlay | [055-independent-glow-overlay.md](055-independent-glow-overlay.md) | готово к ревью |
+| [#56](https://github.com/Matysh/houseplan-card/issues/56) Static room color | [056-static-room-color.md](056-static-room-color.md) | готово после security gate #21 |
+
+## Правило актуализации
+
+При изменении продуктового решения сначала обновляется соответствующее issue, затем ТЗ. Реализация не считается завершённой только по наличию кода: нужны выполненные acceptance criteria, предусмотренная ТЗ проверка и актуальный статус Project v2.
diff --git a/docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md b/docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md
new file mode 100644
index 00000000..09fd8823
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md
@@ -0,0 +1,285 @@
+# HP-VAC-02 — Покрытие интеграций роботов-пылесосов (ревизия 7)
+
+Статус: **ревизия 7 — утверждено владельцем; Stage 1 реализован, целевой gate
+v1.61.0-beta.1 пройден, ожидается публикация**
+Дата: 2026-08-09 (р.1–р.7)
+Каноническая задача: [GitHub Issue #58](https://github.com/Matysh/houseplan-card/issues/58).
+Область (уточнена по B12): **`src/vacuum.ts`** (source-классификация, телеметрия, нормализация пути), **`src/houseplan-card.ts`** (source resolver, диагностика, multi-subpath рендер), **`custom_components/houseplan/trails.py`** (health источника; во 2-м этапе — строковый парсер), **i18n ru/en, фикстуры Node/Python/смоков, VACUUM.md/USER-GUIDE/README/CHANGELOG**. Модель сохранения плана не меняется.
+
+## 0. Ответ на ревью R1
+
+| # | Вердикт | Решение |
+|---|---|---|
+| B1 nonce | **Принято, факт верифицирован** (ValetudoMap.js:41 `crypto.randomUUID()`) | `vacuum_json_id` полностью исключён; §4.3 заменён защитным контрактом стабильности map-id |
+| B2 XCME вне устройства | **Принято** | Явный выбор источника из отобранных кандидатов (§4.5); отступление от принципа «no entity pickers» вынесено владельцу (§10.1) |
+| B3 Roomba недостаточен | **Принято, вариант 1 поэтапно** | Roomba перенесён в Этап 2 отдельной бетой с ПОЛНЫМ скоупом: оба парсера (TS+Python), калибровка с масштабом, полевой протокол из 2 уборок + рестарт (§7) |
+| B4 silent rebind | **Принято** | `VacSourceResolution` со статусом и sticky-семантикой (§4.4) |
+| B5 capability-обещание | **Принято** | Матрица возможностей вместо «всё для всех» (§2) |
+| B6 null в Pt[] | **Принято** | Нормативный `VacPath = Pt[][]` + правила (§4.1) |
+| B7 центроид L-комнаты | **Принято частично, со спором** | Разделение якоря и bbox принято. Point-on-surface ОТКЛОНЁН: якорь автокалибровки не обязан лежать внутри комнаты — он должен быть ОДИНАКОВО определён с обеих сторон матча. Наши plan-комнаты дают area-центроид; тот же area-центроид для outline даёт согласованные пары даже для вогнутых форм (обе точки «выпадают» из выреза согласованно, least squares это устраивает, резидуал-порог ловит вырожденные случаи). Acceptance «лежит внутри» удалён, заменён точными координатами по формуле (§4.2, §8.2) |
+| B8 недетерминированный приоритет | **Принято** | Классификатор с явным скорингом, `parseVacSourceCandidate(entityId, state, registryEntry)` (§4.4) |
+| B9 хрупкий regex | **Принято** | Парсер-грамматика вместо regex, общая для TS/Python, shared-фикстура (Этап 2: §6/§7.6) |
+| B10 диагностика-капабилити | **Принято** | Блок диагностики по возможностям + `path` в YAML-подсказке + фокусируемая ссылка (§5.1) |
+| B11 lifecycle warning | **Принято** | Машина состояний с dedupe-ключом; честное «обнаружение при следующем refresh/restart» без registry-подписки в этой итерации (§5.2) |
+| B12 неполный scope | **Принято** | Шапка области исправлена |
+
+
+## 0-bis. Ответ на ревью R2
+
+| # | Вердикт | Решение |
+|---|---|---|
+| R2-B1 plan-якорь | **Принято, факт признан** — plan-сторона использует `poleOfInaccessibility` (houseplan-card.ts:12148), а не area-центроид; моё утверждение р.2 о «согласованности обеих сторон» было неверным. Вариант 1: новый pure helper `areaCentroid(poly)` для ОБЕИХ сторон автокалибровки (plan-полигон и outline), `poleOfInaccessibility` остаётся для подписей комнат. Смена plan-якорей при НОВОЙ автокалибровке признаётся явно; сохранённые матрицы не мигрируют. High-residual: выбрано поведение «подтверждение» — матрица НЕ сохраняется молча; диалог «Совпадение неточное (~N см): Применить / Подогнать вручную» (текущее сохрани-потом-предупреди признано дефектом). |
+| R2-B2 арбитраж пути | **Принято** — нормативный порядок для текущего run: `tele.path` (полная геометрия) → server current → local runtime; сервер остаётся источником истории и fallback после reload. `always` показывает integration path и в покое; `cleaning` — только пока moving. Смок: одновременные srvCur + двухсегментный tele.path → разрывы видимы. |
+| R2-B3 категории кандидатов | **Принято** — compatible / partial / known_xcme_incomplete / sticky-saved (§4.5); XCME-подсказка только для known_xcme_incomplete (platform точно равна xiaomi_cloud_map_extractor) или явно выбранной пользователем камеры. Обычная камера без vacuum-признаков никогда не получает vacuum-подсказку. |
+| R2-B4 unverified | **Принято** — resolver обязан использовать `resolveHaBindingStatus(hass, 'entity:'+source)` / HaRegistrySnapshot; enum + `unverified` (limited registry ≠ доказательство удаления), sticky, то же limited-registry сообщение, что у устройств. |
+| R2-S1 explicit | **Принято** — флаг переименован в `pinned`: любой сохранённый source sticky независимо от происхождения (авто-закрепление _vacSaveMatrix или picker); поле origin НЕ добавляется, модель не меняется. |
+| R2-S2 бюджет сегментов | **Принято** — детерминированный контракт: порядок XCME old→new; при >64 сегментов сохраняются ПОСЛЕДНИЕ 64; каждому drawable-сегменту гарантированы endpoints; бюджет 4000 точек распределяется largest-remainder (tie-break old→new); bounded thinning строго внутри сегмента. |
+| R2-S3 tip | **Принято** — tip крепится к последнему **drawable** (≥2 точек) сегменту; singleton не рисует ни линии, ни tip-связи (смок: [drawable, singleton] не соединяет singleton с puck). |
+| R2-S4 деградация комнаты | **Принято; дополнено owner override F6** — валидный явный якорь сохраняет комнату при битом outline; отдельная полная bbox-четвёрка сохраняет ghost и даёт последний bbox-centre anchor. Комната пропускается лишь без явного, outline- и bbox-якоря. |
+| R2-S5 этап 2 | **Принято** — §6 «Этап 2» помечен как неисполняемое направление: реализация только по отдельному ТЗ после решений владельца; acceptance этапа 1 от него не зависит. |
+| R2-S6 переходы reason | **Принято** — один активный health-state на (marker, source): смена missing↔disabled обновляет reason БЕЗ второго warning; новый warning только после возврата в available. Таблица переходов в §5.2. |
+
+
+## 0-ter. Ответ на ревью R3
+
+| # | Вердикт | Решение |
+|---|---|---|
+| R3-B1 registry-less XCME | **Принято, оба факта верифицированы** (camera.py: generate_entity_id без unique_id; ha-binding-status.ts:442: authoritative без строки → orphaned не глядя на live state) | Нормативный порядок доказательств существования сущности (§4.4) правится в ОБЩЕМ `resolveHaBindingStatus` — кейс системный, не vacuum-специфичный. UX-канал для XCME без атрибутов: расширенная секция picker «Все камеры» с нейтральным предупреждением; XCME-подсказка — после явного выбора камеры без position-атрибутов. `known_xcme_incomplete` по registry-платформе остаётся ДОПОЛНИТЕЛЬНЫМ быстрым путём, когда строка реестра есть |
+| R3-B2 high-residual flow | **Принято** | Полный нормативный flow перенесён в §4.2-bis: физический порог 40 см через cell_cm/grid pitch, отображение через HA unit formatter, конфиг не меняется до подтверждения, четыре ветки протестированы |
+| R3-B3 drawable-арбитраж | **Принято** | Приоритет источников пути считается только по наличию drawable-сегмента (≥2 точек); pure helper `resolveCurrentVacPath` для рендера и тестов; кейсы [[]], [[p]], non-finite-only, [singleton,drawable]+srvCur |
+| R3-S1 tip-противоречие | **Принято** | Старая фраза «непустому сегменту» удалена, осталось единственное правило «последний drawable» |
+| R3-S2 XCME-подсказка | **Принято** | «или неопределима» удалено; подсказка только known_xcme_incomplete ЛИБО явно выбранной камере |
+| R3-S3 sticky-статусы | **Принято** | Любой не-`ok` статус сохранённого источника sticky, включая unverified/unsupported |
+| R3-S4 acceptance Dreame/demo | **Принято** | Переформулировано: сохранённые матрицы/следы байт-неизменны; НОВАЯ автокалибровка может дать иную матрицу по новому алгоритму, но обязана пройти фикстуры в утверждённом резидуале |
+| R3-S5 tie-break бюджета | **Принято** | Largest-remainder, tie-break по порядку old→new |
+| §10.1 picker | **РЕШЕНИЕ ВЛАДЕЛЬЦА ПОЛУЧЕНО 2026-08-09: подтверждён, с формулировкой Codex** — «никаких ручных entity_id/YAML-полей; разрешён объяснимый picker автоматически найденных источников и отдельная расширенная секция camera для registry-less XCME» |
+
+## 0-quater. Ответ на ревью R4
+
+| # | Вердикт | Решение |
+|---|---|---|
+| R4-B1 §4.2-bis отсутствовал | **Принято, признан брак редактирования** — replace-патч ревизии 4 не совпал с якорем и молча не применился (тот же класс дефекта, что я ловлю в чужих тестах: правка без ассерта на успех). §4.2-bis фактически добавлен, §7.4 и acceptance дополнены |
+| R4-B2 drawable-арбитраж отсутствовал | **Принято, та же причина** — §4.1 фактически заменён: helper `resolveCurrentVacPath` с полем `source`, drawable-условие, пять пар одновременных источников в §7.3 |
+| R4-S1 ссылка §4.4-bis | **Принято** — исправлена на §4.4 |
+| R4-S2 таблица статусов | **Принято** — полное отображение семи статусов в §4.4, включая sticky `unsupported` для явно выбранной registry-less камеры |
+| R4-S3 скоринг vs sticky | **Принято** — шаг 1 переформулирован под общее правило |
+
+## 0-quinquies. Ответ на ревью R5
+
+| # | Вердикт | Решение |
+|---|---|---|
+| R5-B1 ключ health-state | **Принято, внутреннее противоречие признано** — dedupe-ключ с reason из р.1 конфликтовал с единой моделью R2-S6. Нормативно: ключ `(marker_id, source_entity_id)`, reason — поле записи; §7.5 расширен полной цепочкой со сменой причины |
+| R5-B2 singleton vs cap | **Принято** — pipeline из пяти шагов, cap 64 применяется к DRAWABLE-сегментам после фильтрации; VacPath не содержит недорисовываемого; три новых теста в §7.3 |
+| R5-S1 markdown таблицы | **Принято** — граница таблицы исправлена |
+| R5-S2 дата ревизий | **Принято** — р.1–р.6 |
+| R5-S3 ссылки B4/B8/B9 | **Принято** — §4.4 и Этап 2 |
+| R5-S4 условие подсказки | **Принято** — записано булевой формулой |
+
+## 0-sexies. Ответ на ревью R6
+
+| # | Вердикт | Решение |
+|---|---|---|
+| R6-B1 recovery не определён | **Принято** — health-машина связана с полным enum §4.4: failure = missing|disabled; доказанное recovery = ok|unavailable|unsupported; unverified нейтрален (держит активную запись, не создаёт новую); четыре новых теста в §7.5 |
+| R6-B2 веса largest-remainder | **Принято** — формула зафиксирована: веса = внутренние точки (n_i − 2), резерв 2k endpoints, floor + дробный остаток, tie old→new; юнит с неодинаковыми n_i на точные target-counts |
+| R6-S1 фраза §5.1 | **Принято** — заменена буквальной формулой §4.5 |
+
+## 1. Продуктовое решение
+
+Довести три Tier-A семейства до честно задокументированного состояния «работает» по их фактическим возможностям (матрица §2), добавить надёжный выбор источника для интеграций вне device registry (XCME), сделать диагностику пообъектной, и отдельным этапом — Roomba с полноценной Tier-B калибровкой. Никаких обещаний возможностей, которых нет у upstream.
+
+## 2. Матрица возможностей (нормативная; заменяет прежние цели §2.1)
+
+| Семейство | Позиция | Комнаты/автокалибровка | Integration path | Стабильный map-id | Обнаружение источника |
+|---|---|---|---|---|---|
+| Dreame/Mova (Tasshack) | да | да | нет (нет в атрибутах) | `map_name`/`map_index` + vacuum `selected_map` | same-device auto (как сейчас) |
+| Xiaomi Cloud Map Extractor | да, при включённом `attributes:` | да | **да** (`path.path`) | `map_name` | **явный выбор** (§4.5) — камера вне device registry |
+| MQTT Vacuum Camera (Valetudo) | да | да (outline) | нет в state-атрибутах | **не подтверждён** — `default`/`selected_map`; nonce не используется | same-device auto |
+| Roomba core (Этап 2) | часть моделей (`cap.pose`) | нет | нет | нет | сама `vacuum.*` (низший приоритет) |
+
+Статусы «verified live» / «implemented from upstream sources» ведутся отдельно от матрицы (issue «Integration coverage matrix», §7.3) и не смешиваются с возможностями.
+
+## 3. Не входит
+
+Как в р.1 (Ecovacs, прямой MQTT, Tuya/Neato/Shark/Eufy, Tier C — отдельный этап по VACUUM.md, Roborock-поллер — решение владельца, команды роботу) + **`vacuum_json_id` и любые нестабильные upstream-поля как ключи хранения**.
+
+## 4. Нормативные контракты
+
+### 4.1. Путь: `VacPath = Pt[][]` (закрывает B6)
+
+```ts
+type VacPath = Pt[][]; // всегда массив subpath'ов
+// legacy плоский путь нормализуется в [points]; серверный/локальный след
+// передаётся рендереру как [trail] БЕЗ изменения storage-схемы.
+```
+
+**Pipeline нормализации (R5-B2), строго в этом порядке:**
+1. нормализовать точки, выбросить non-finite;
+2. удалить из render-path сегменты с `length < 2` (singleton не несёт семантики: не линия, не tip, не позиция — позиция приходит только из `pos`; raw-данные могут оставаться доступными диагностике);
+3. оставить последние **64 drawable-сегмента** (cap применяется ПОСЛЕ фильтрации — singleton'ы не могут вытеснить drawable);
+4. распределить бюджет 4000 точек между оставшимися drawable-сегментами по формуле (R6-B2): `k` сегментов длины `n_i ≥ 2`; резерв endpoints `base = 2k`; если `Σn_i ≤ 4000` — все точки сохраняются; иначе `remaining = 4000 − base`, веса — ВНУТРЕННИЕ точки `d_i = n_i − 2`, идеальная квота `q_i = remaining · d_i / Σd_i`, выдача `floor(q_i)` + остаток по убыванию дробной части (tie-break old→new); итоговый target сегмента `2 + allocated_i`; thinning до target строго внутри сегмента с сохранением первой и последней точки;
+5. по результату выполнить `resolveCurrentVacPath` — возвращаемый `VacPath` не содержит недорисовываемых сегментов.
+Прочие правила: thinning и transform выполняются per-subpath, никогда поперёк разрыва; SVG — один `` с несколькими `M` (без соединительных отрезков — смок-ассерт); в `trail_mode=cleaning` integration path скрывается вместе со следом по завершении уборки, в `always` — остаётся. Формы входа: существующие (`[{x,y}]`, `[[x,y]]`, `path.points`) + XCME `path.path: [[{x,y},…],…]`.
+
+**Арбитраж источников текущего пути (R2-B2 + R3-B3/R4-B2), нормативно.** Единственная реализация выбора — pure helper:
+
+```ts
+resolveCurrentVacPath(tele, srv, rt): { path: VacPath; source: 'integration' | 'server' | 'local' | 'none' }
+```
+
+Порядок (участвуют только источники, имеющие ≥1 drawable-сегмента, т.е. ≥2 конечных точек ПОСЛЕ нормализации): (1) нормализованный `tele.path` с drawable-сегментом → `integration`; (2) drawable server current как `[trail]` → `server`; (3) drawable local runtime как `[trail]` → `local`; (4) иначе `none`. Renderer и диагностика читают `source` из результата и не выводят происхождение собственными условиями. Сервер продолжает записывать всегда (история, previous run, fallback после reload/исчезновения tele.path). Режимы: `always` — integration path виден и в покое; `cleaning` — только пока робот moving; previous run — только из серверного стора (как сейчас).
+
+**Бюджет (R2-S2):** сегменты в порядке поступления old→new; при превышении 64 отбрасываются старейшие; каждому drawable-сегменту гарантированы обе конечные точки; бюджет 4000 точек делится по largest-remainder с tie-break в порядке old→new; thinning строго внутри сегмента. **Tip (R2-S3):** последний drawable (≥2 точек) сегмент; singleton не рисуется и не связывается с puck.
+
+### 4.2. Комнаты: якорь ≠ bbox (закрывает B7)
+
+Два независимых понятия:
+
+- **Якорь автокалибровки** (`cx/cy`): приоритет `cx/cy → center.{x,y} → явные x/y → areaCentroid(outline) → центр полного bbox x0/y0/x1/y1`. Последний bbox-tier добавлен прямым решением владельца после code-review F6 как дешёвая compatibility-страховка для bbox-only диалектов, несмотря на прежний запрет rev.7; он никогда не побеждает более точную геометрию. Согласованность основных tier обеспечивается НОВЫМ pure helper `areaCentroid(poly)` (shoelace), который используется ОБЕИМИ сторонами матча: plan-полигоном в `_vacAutoCalibrate` (вместо `poleOfInaccessibility`, который остаётся для подписей комнат) и outline-фолбэком робота. Изменение plan-якорей затрагивает только НОВЫЕ автокалибровки; сохранённые матрицы не мигрируют и остаются валидными. Для актуального Valetudo-формата явные `x/y` публикуются парсером и побеждают — outline-центроид это фолбэк.
+- **Bbox для fit-ghost** (`x0..y1`): min/max по вершинам валидного outline, вычисляется даже когда якорь взят из явных `x/y`.
+
+Вырожденные входы (детерминированный skip, юниты обязательны): outline < 3 точек; замыкающая дублирующая вершина (допустима, игнорируется); нулевая площадь → фолбэк на среднее вершин как якорь, bbox честный; non-finite вершина outline → outline-bbox/ghost пропадает, но явный якорь либо отдельная полная bbox-четвёрка сохраняют комнату (R2-S4 + F6); комната пропускается лишь без явного/outline/bbox якоря; самопересечение НЕ детектируется (shoelace от него не падает — фиксируем поведением «формула как есть», тест с бабочкой на точные координаты).
+
+### 4.2-bis. High-residual flow автокалибровки (R2-B1 + R3-B2/R4-B1), нормативно
+
+1. `residual` — максимальная евклидова ошибка по matched-якорям в plan-единицах (семантика существующего `affineResidual`), не среднее.
+2. Физическая ошибка: `residual / resolvedGridPitch * resolvedCellCm` (см).
+3. High residual — строго `> 40` см (граница выбрана как ~полкорпуса робота: меньшее расхождение неотличимо от шума телеметрии). Текущий канвасный порог `NORM_W * 0.05` удаляется — процент холста означает разную физическую ошибку при разных cell_cm.
+4. Отображение величины — общий форматтер единиц HA (metric/imperial), никакого хардкода «см» в строках.
+5. До подтверждения предложенная матрица НЕ записывается в config и не меняет сохранённую калибровку (текущее «сохранить → предупредить» — дефект, устраняется).
+6. Low residual: матрица сохраняется сразу, существующий success-тост.
+7. High → «Применить»: сохраняется именно предложенная матрица, success.
+8. High → «Подогнать вручную»: открывается fit-панель, инициализированная предложенной матрицей; сохранение — только по Apply внутри панели.
+9. Cancel/закрытие диалога: прежняя калибровка нетронута, ложный success не показывается.
+
+### 4.3. Map-id: защитный контракт (заменяет прежний §4.3; закрывает B1)
+
+Цепочка `map_name ?? current_map ?? map_index ?? selected_map (source) ?? selected_map (vacuum) ?? 'default'` **не меняется**. `vacuum_json_id` не используется нигде. Новый обязательный guard-тест (Node + Python от одной JSON-фикстуры): два последовательных обновления Valetudo-атрибутов с разными nonce → тот же map-id, тот же продолжающийся run, calibration находится. Существующие кейсы `0`/`"0"`/`""`/`null` сохраняются. В матрице §2 и USER-GUIDE честно: у Valetudo multi-floor стабильного map-id нет — калибровка живёт под `default`/`selected_map`.
+
+### 4.4. Классификация источников (закрывает B4, B8)
+
+```ts
+type VacSourceResolution = {
+ entityId: string | null;
+ status: 'ok' | 'missing' | 'disabled' | 'unavailable' | 'unverified' | 'unsupported' | 'none';
+ pinned: boolean; // сохранён в marker.vacuum.source (любое происхождение)
+ candidates: VacSourceCandidate[]; // для диалога
+};
+```
+
+Единственный классификатор `parseVacSourceCandidate(entityId, state, registryEntry?)`; статусы определяются через общий `resolveHaBindingStatus`/`HaRegistrySnapshot` (R2-B4), в который вносится системная поправка (R3-B1) — **нормативный порядок доказательств для entity-привязки (§4.4)**:
+1. registry-строка с `disabled_by` → `disabled` (приоритет);
+2. живой `hass.states[entityId]` — положительное доказательство существования ДАЖЕ при authoritative snapshot без строки (легальный кейс: YAML-платформы без unique_id — XCME); state `unavailable` → source-статус `unavailable`, не `missing`;
+3. registry-строка без disable ИЛИ живой state → сущность существует;
+4. `missing` — только когда нет НИ строки, НИ живого state при authoritative; при limited — `unverified`.
+Поправка вносится в общий резолвер (кейс системный, не vacuum-локальный) с регресс-прогоном всех его существующих юнитов и смоков disabled-фичи. Тесты: authoritative без строки + живой XCME state → не missing; тот же с unavailable → unavailable; нет ни строки, ни state → missing; limited без обоих → unverified; disabled-строка → disabled. **Sticky (R3-S3): ЛЮБОЙ не-`ok` статус сохранённого источника sticky и не запускает автоподмену** (включая unverified/unsupported).
+
+Полное отображение статусов (R4-S2):
+
+| Условие | `status` |
+|---|---|
+| source не выбран и не найден | `none` |
+| привязка существует, state и позиция валидны | `ok` |
+| сущность существует, но position отсутствует/невалидна | `unsupported` (явно выбранная registry-less камера без атрибутов остаётся sticky `unsupported` и получает XCME-подсказку) |
+| state `unavailable` | `unavailable` |
+| registry `disabled_by` | `disabled` |
+| нет ни строки, ни state при authoritative | `missing` |
+| нет ни строки, ни state при limited | `unverified` |
+
+Скоринг, независимый от порядка массивов:
+
+1. сохранённый `marker.vacuum.source` — **sticky**: любой статус (включая unverified/unsupported) отображается как есть и НИКОГДА не подменяется автоматически;
+2. camera с объектной позицией (`vacuum_position`/`robot_position`);
+3. иная сущность с объектной позицией;
+4. (Этап 2) `vacuum.*` с валидной строкой `position`;
+5. диагностический кандидат: camera без position-атрибутов (для подсказки, не источник).
+
+Один resolver обслуживает рендер, диалог, калибровку и диагностику; бэкенд получает только сохранённое значение. Юниты: перестановка `d.entities` не меняет выбор; camera бьёт vacuum-строку; `position`-строка на `sensor.*`/`device_tracker.*` — не источник.
+
+### 4.5. Выбор источника для XCME (закрывает B2)
+
+Кандидаты диалога (R2-B3), четыре категории:
+- `compatible` — объектная позиция есть (same-device + глобальный скан `camera.*`);
+- `partial` — позиции нет, но есть хотя бы один vacuum-признак (`rooms`/`path`/`map_name`);
+- `known_xcme_incomplete` — платформа по registry ТОЧНО `xiaomi_cloud_map_extractor`, независимо от атрибутов;
+- сохранённый sticky-source — всегда присутствует в результате, каким бы ни был его state.
+Глобальный скан — только для списка кандидатов, НЕ для автопривязки. Поскольку XCME-камера обычно ОТСУТСТВУЕТ в реестре (нет unique_id), канал её выбора (R3-B1, утверждён владельцем): в picker добавляется свёрнутая расширенная секция **«Все камеры»** — все `camera.*` из hass.states с нейтральным предупреждением «камера не отдаёт данных робота; выберите, только если это карта вашего пылесоса». **Условие XCME-YAML-подсказки, буквально: `known_xcme_incomplete` ИЛИ (камера явно выбрана пользователем И position-атрибуты отсутствуют).** Невыбранная камера из секции «Все камеры» подсказку не получает. Обычная камера без vacuum-признаков автоматически vacuum-подсказку не получает никогда; формулировка «или неопределима» исключена (R3-S2). UI: строка «Источник: {entity | не выбран}» + кнопка **«Выбрать источник»** со списком кандидатов (подпись: friendly name + платформа при наличии + какие данные найдены). Семантика: same-device auto-discovery остаётся default'ом для Dreame/Valetudo; выбор из списка записывает `marker.vacuum.source` (модель данных уже это умеет); удаление/переименование выбранного → статус `missing`, баннер с кнопкой «Выбрать другой источник» (не «перепоиск» — автоподмены нет); восстановление сущности → статус `ok` без действий пользователя. Global scan выполняется лениво при открытии секции (не на hass-тике).
+
+## 5. Диагностика
+
+### 5.1. Блок «Живая позиция» — по возможностям (закрывает B10, V3)
+
+Вместо одной итоговой фразы — строки-капабилити: источник (+платформа, если определима); позиция да/нет; комнаты: N найдено, пригодны ли для автокалибровки (≥3 матча имён); путь да/нет; map-id: значение или `default`; действия — «Настроить автоматически» / «Подогнать вручную» / «Выбрать источник» / «Документация» (настоящая кнопка-anchor с keyboard focus, не текст в строке). Спец-подсказка XCME — строго по условию §4.5: `known_xcme_incomplete` ИЛИ (камера явно выбрана пользователем И position-атрибуты отсутствуют); сам факт глобального обнаружения подсказку НЕ включает:
+
+```yaml
+attributes:
+ - vacuum_position
+ - rooms
+ - path
+ - map_name
+```
+
+### 5.2. Health источника в trails.py (закрывает B11, V5)
+
+Машина состояний (R5-B1, единая модель): ключ активного состояния — **только `(marker_id, source_entity_id)`**; `reason ∈ {missing, disabled}` — изменяемое ПОЛЕ записи, не часть ключа:
+
+```
+active_health: Map[(marker_id, source_entity_id)] -> { reason }
+```
+
+**Связь со статусами §4.4 (R6-B1), нормативно:**
+- failure-статусы (создают/держат запись): только `missing | disabled`;
+- доказанное recovery (удаляет запись, `info`-лог): любой статус, доказывающий существование без disable — `ok | unavailable | unsupported`;
+- `unverified` — нейтрален: НЕ новая потеря и НЕ recovery; активная запись сохраняется до следующего доказанного статуса, при отсутствии записи ничего не создаётся;
+- `none` (source не настроен) — запись очищается по lifecycle смены source/удаления маркера.
+
+Warning создаётся только при переходе из отсутствующего/recovered состояния в failure; `missing ↔ disabled` обновляет reason записи БЕЗ нового warning; после доказанного recovery следующая потеря создаёт снова один warning; старт HA с уже отсутствующим источником — один warning; старт с `unavailable`/`unsupported` — БЕЗ warning; повторные refresh — тишина; смена source и удаление маркера удаляют запись. Транзиентный `unavailable` — не warning И доказанное recovery активного failure (сущность снова существует). **Переходы reason (R2-S6):** один активный health-state на (marker, source); `missing → disabled` и обратно обновляют reason активного состояния БЕЗ нового warning; новый warning возможен только после полного recovery (`→ available →` потеря). Тест: `available→missing→disabled→missing→recovery→missing` = ровно 2 warning. Обнаружение — при config refresh/restart; entity-registry подписка не добавляется в этой итерации, что честно записано в VACUUM.md. Диалог показывает статус из §4.4, не из логов.
+
+## 6. Этапность (закрывает B3)
+
+**Этап 1 (эта бета): Tier-A полировка** — §4.1 путь, §4.2 комнаты, §4.3 guard, §4.4–4.5 resolver+выбор источника, §5 диагностика/health, доки/CHANGELOG.
+
+**Этап 2 — НЕИСПОЛНЯЕМОЕ НАПРАВЛЕНИЕ (R2-S5):** реализация Roomba возможна только по отдельному ТЗ, которое будет написано после решений владельца (§10.2); acceptance и тесты этапа 1 от этого раздела не зависят. Зафиксированные требования будущего ТЗ:
+- общий парсер-грамматика строки `position` (не regex): проверка домена `vacuum.*` → снять ровно одну пару внешних скобок → split на ровно 3 части → `Number`/`float` → три конечных числа; `position: null` — не источник. Реализации TS **и** Python (trails.py `_sample`), обе от одной JSON-фикстуры;
+- калибровка, задающая масштаб и ориентацию без комнат: fit-панель получает режим «две отметки» (пользователь отмечает робота в двух разнесённых точках плана в разные моменты — решает translate+scale; rotation/mirror — существующими кнопками) ЛИБО ghost-квадрат с ручками масштаба; выбор конкретного UX — на ТЗ этапа 2;
+- полевой протокол (§7.3): две отдельные уборки + рестарт HA посреди уборки, проверка стабильности origin/масштаба между уборками; 30-секундный тест признан недостаточным;
+- деградация: модель без `cap.pose` → Tier D.
+
+## 7. Тест-план (дополнен обязательными сценариями R1)
+
+**7.1 Source resolution:** перестановка entities; camera > vacuum-строка; sticky missing без подмены; явный выбор переживает reload; XCME-камера вне устройства видна в кандидатах; `position` на не-vacuum — не источник; disabled ≠ missing ≠ unavailable.
+
+**7.2 Map-id:** nonce-guard (два обновления → один run); кейсы `0`/`"0"`/`""`/`null`; Node и Python читают ОДНУ JSON-фикстуру (`test/fixtures/vacuum-attrs/*.json`, подключается и в pytest, и в node:test — расширение существующего cross-language паттерна DISPLAY_MODES).
+
+**7.3 Path:** юнит бюджета с неодинаковыми `n_i`: точные target-counts по формуле §4.1.4, сумма ≤4000, endpoints целы, tie-break по дробной части old→new; XCME два сегмента → `Pt[][]`; пустые/одноточечные/невалидные сегменты без линий и исключений; арбитраж при одновременных источниках: `[[]]`+srvCur → server, `[[p]]`+srvCur → server, non-finite-only+srvCur → server, `[singleton, drawable]`+srvCur → integration, drawable tele+srvCur → integration с сохранёнными разрывами; `[drawable, singleton×64]`+srvCur → integration (cap после фильтрации); `[singleton×65]`+srvCur → server; после cap остаются последние 64 DRAWABLE-сегмента, не последние 64 исходных; transform/thinning/tip per-subpath; лимиты сегментов/точек; `cleaning`/`always` после остановки; смок: нет соединительного отрезка между субпутями.
+
+**7.4 Rooms:** outline+x/y → якорь из x/y, bbox из outline; вогнутый L → якорь = точные координаты area-центроида (без containment-ассерта); bbox-only → нормализованный bbox и его центр как последний якорь; замыкающая вершина/нулевая площадь/non-finite/бабочка → детерминированный результат по §4.2; ≥3 матчей → конечная матрица, резидуал проверяется отдельно; high-residual flow: low (сохранение без диалога), high→Применить (сохранена предложенная), high→вручную→Apply, high→Cancel (конфиг не изменён).
+
+**7.5 Health:** полный сценарий переходов §5.2: `available→missing→disabled→missing→recovery→missing` = ровно 2 warning (смена причины внутри активного состояния — 0 новых); `available→missing→unavailable→missing` = 2 warning (unavailable = доказанное recovery); `available→disabled→unsupported→disabled` = 2 warning; `missing→unverified→missing` = 1 warning (unverified нейтрален); старт с `unavailable`/`unsupported` = 0 warning; `available→missing→refresh×3→recovery` = 1 warning; startup-missing = 1; удаление маркера/смена source чистит запись; disabled ≠ missing различимы в reason.
+
+**7.6 Этап 2 (Roomba):** shared-фикстура строки TS↔Python; рекордер пишет при закрытой карточке; калибровка определяет translate+scale+orientation; две уборки — совместимая СК; рестарт не рвёт run; без pose → Tier D.
+
+## 8. Критерии приёмки (Этап 1)
+
+1. Матрица §2 реализована буквально: каждая ячейка «да» покрыта юнит-фикстурой реального формата, каждая «нет» НЕ обещана в UI/доках.
+2. XCME: путь рисуется с разрывами; камера, не привязанная к устройству, выбирается из кандидатов и переживает reload.
+3. High-residual матрица НИКОГДА не попадает в config до явного «Применить» (тест ветки Cancel).
+3-bis. Valetudo: автокалибровка проходит на outline-комнатах (якоря = area-центроиды, точные координаты в тесте); nonce-guard зелёный — обновления карты не рвут run и не теряют калибровку.
+4. Сохранённый источник sticky: missing показывает баннер и не подменяется; recovery без действий пользователя.
+5. Диагностика показывает пообъектные capability-строки; XCME-подсказка содержит все четыре атрибута; ссылка на доку фокусируема.
+6. Health-warnings по §5.2 (тест 7.5).
+7. Регресс: сохранённые матрицы и следы байт-неизменны (не мигрируют); НОВАЯ/повторная автокалибровка Dreame/демо может дать иную матрицу по areaCentroid-алгоритму, но обязана проходить существующие фикстуры в пределах утверждённого резидуала (R3-S4).
+8. CHANGELOG en/ru + USER-GUIDE таблица §2 + VACUUM.md обновлён (включая честную запись про Valetudo map-id и отсутствие registry-подписки).
+
+## 9. Верификационная кампания
+
+Как в р.1, с поправкой B3/R1-5: для XCME/Valetudo достаточно скриншота диагностики + короткой уборки; для Roomba (этап 2) — протокол §6: две уборки + рестарт, скриншоты fit до/после. Статусы полевой проверки — в issue, отдельно от матрицы возможностей.
+
+## 10. Решения владельца
+
+1. **Выбор источника (B2/R3-B1): РЕШЕНО владельцем 2026-08-09** — picker подтверждён с формулировкой: «никаких ручных entity_id/YAML-полей; разрешён объяснимый picker автоматически найденных источников и отдельная расширенная секция camera для registry-less XCME». Принцип VACUUM.md уточняется этой формулировкой.
+2. **Roomba, этап 2:** подтвердить перенос и выбрать UX калибровки без комнат (две отметки vs ghost-квадрат) — уйдёт в ТЗ этапа 2. **[решение]**
+3. **Roborock-поллер** — без изменений, отложен до полевых отчётов. **[решение из р.1 остаётся]**
+
+## 11. Изменения бэклога
+
+Issue #9 (vacuum_json_id) закрывается как wontfix со ссылкой на B1; #10 (Roomba) переоформляется под этап 2 с полным скоупом; #6 дополняется контрактом `Pt[][]`; #7 — разделением якорь/bbox.
diff --git a/package-lock.json b/package-lock.json
index a9fdb196..c55b769f 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
- "version": "1.60.3",
+ "version": "1.61.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
- "version": "1.60.3",
+ "version": "1.61.0-beta.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
diff --git a/package.json b/package.json
index c0729986..73bec943 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
- "version": "1.60.3",
+ "version": "1.61.0-beta.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
diff --git a/src/color.ts b/src/color.ts
new file mode 100644
index 00000000..d471c9c4
--- /dev/null
+++ b/src/color.ts
@@ -0,0 +1,42 @@
+/**
+ * Persisted Houseplan colours use one intentionally small contract.
+ *
+ * Keeping this stricter than the browser CSS grammar makes old, imported or
+ * manually edited config safe before it reaches an inline style/SVG paint
+ * sink. The backend enforces the same exact form on new writes.
+ */
+export const STORED_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
+
+export function safeStoredColor(value: unknown, fallback: string): string;
+export function safeStoredColor(value: unknown, fallback: null): string | null;
+export function safeStoredColor(value: unknown, fallback: string | null): string | null {
+ return typeof value === 'string' && STORED_COLOR_RE.test(value) ? value : fallback;
+}
+
+const RGB_COMPONENT = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])';
+const GENERATED_RGB_RE = new RegExp(
+ `^rgb\\(${RGB_COMPONENT}, ${RGB_COMPONENT}, ${RGB_COMPONENT}\\)$`,
+);
+
+/**
+ * Render-boundary guard for the two colour forms the application can produce:
+ * strict persisted hex and canonical rgb() generated below. This must not be
+ * used to broaden the persisted colour contract.
+ */
+export function safeRenderColor(value: unknown, fallback: string): string;
+export function safeRenderColor(value: unknown, fallback: null): string | null;
+export function safeRenderColor(value: unknown, fallback: string | null): string | null {
+ return typeof value === 'string'
+ && (STORED_COLOR_RE.test(value) || GENERATED_RGB_RE.test(value))
+ ? value
+ : fallback;
+}
+
+/** Canonical CSS rgb() from untrusted HA attributes; invalid input has no colour. */
+export function generatedRgbColor(values: unknown): string | null {
+ if (!Array.isArray(values) || values.length < 3) return null;
+ const channels = values.slice(0, 3);
+ if (!channels.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
+ const [r, g, b] = channels.map((value) => Math.round(Math.min(255, Math.max(0, value))));
+ return `rgb(${r}, ${g}, ${b})`;
+}
diff --git a/src/device-face.ts b/src/device-face.ts
index c7f592b8..eba0f2d0 100644
--- a/src/device-face.ts
+++ b/src/device-face.ts
@@ -1,6 +1,7 @@
/** Shared icon/value/badge/activity DOM for every device surface. */
import { html, nothing, type TemplateResult } from 'lit';
import type { ResolvedDevicePresentation } from './device-presentation';
+import { safeRenderColor } from './color';
export interface DeviceFaceOptions {
surface: 'interactive-plan' | 'preview' | 'static-card';
@@ -15,7 +16,8 @@ export function deviceFaceStyle(presentation: ResolvedDevicePresentation): strin
if (presentation.scale !== 1) out.push(`--dev-scale:${presentation.scale}`);
if (presentation.display === 'icon_ripple') {
out.push(`--ripple-scale:${presentation.rippleScale}`);
- if (presentation.rippleColor) out.push(`--ripple-color:${presentation.rippleColor}`);
+ const rippleColor = safeRenderColor(presentation.rippleColor, null);
+ if (rippleColor) out.push(`--ripple-color:${rippleColor}`);
}
return out;
}
diff --git a/src/device-presentation.ts b/src/device-presentation.ts
index 639f6a09..0e9e7661 100644
--- a/src/device-presentation.ts
+++ b/src/device-presentation.ts
@@ -19,6 +19,7 @@ import {
type DeviceDisplayMode,
} from './logic';
import type { DevItem } from './types';
+import { safeStoredColor } from './color';
export type PresentationSourceKind =
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
@@ -448,7 +449,8 @@ export function resolveDevicePresentation(
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
const rippleScale = Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3;
- const rippleColor = staticIcon ? null : d.marker?.ripple_color || lightColor || null;
+ const configuredRippleColor = safeStoredColor(d.marker?.ripple_color, null);
+ const rippleColor = staticIcon ? null : configuredRippleColor || lightColor || null;
const valueText = display === 'value' && !effectiveHidden ? value.text : null;
const disabledReason = status?.kind === 'ha_disabled' ? status.reason : null;
const reason = explanationReason(
diff --git a/src/editors/decor/geometry.ts b/src/editors/decor/geometry.ts
index 3333d006..ce4235e8 100644
--- a/src/editors/decor/geometry.ts
+++ b/src/editors/decor/geometry.ts
@@ -1,4 +1,5 @@
import type { DecorShape, DecorStyle } from './types';
+import { safeStoredColor } from '../../color';
export interface DecorBox {
x: number;
@@ -121,15 +122,13 @@ export function decorStyleOf(
): DecorStyle {
const fillable = shape?.kind === 'rect' || shape?.kind === 'ellipse';
const source = shape as any;
- const color = /^#[0-9a-f]{6}$/i.test(String(source?.color || ''))
- ? String(source.color) : fallback.color;
+ const color = safeStoredColor(source?.color, fallback.color);
return {
color,
opacity: clamp01(source?.opacity, fallback.opacity),
widthCm: decorStrokeCm(shape, cellCm, gridPitch, fallback.widthCm),
fill: fillable ? source?.fill === true : false,
- fillColor: /^#[0-9a-f]{6}$/i.test(String(source?.fill_color || ''))
- ? String(source.fill_color) : (source?.fill ? color : fallback.fillColor),
+ fillColor: safeStoredColor(source?.fill_color, source?.fill ? color : fallback.fillColor),
// Legacy fill was hard-coded to 0.25.
fillOpacity: fillable && source?.fill
? clamp01(source?.fill_opacity, 0.25)
diff --git a/src/ha-binding-status.ts b/src/ha-binding-status.ts
index d50fcc2b..b4cdbcdb 100644
--- a/src/ha-binding-status.ts
+++ b/src/ha-binding-status.ts
@@ -440,16 +440,22 @@ export function resolveHaBindingStatus(
return { kind: 'active', enabledEntityIds, allEntityIds };
}
const entity = entities[ref];
- if (!entity) return { kind: 'orphaned', reason: 'entity_missing', enabledEntityIds: [], allEntityIds: [] };
- if (!isRegistryEntryEnabled(entity)) {
+ // YAML platforms without a unique_id (notably Xiaomi Cloud Map
+ // Extractor cameras) legitimately have a live state but no registry row.
+ // A disabled row still wins, otherwise that exact live state is positive
+ // evidence of existence even under an authoritative registry snapshot.
+ if (entity && !isRegistryEntryEnabled(entity)) {
return { kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: [ref] };
}
- if (entity.device_id && !devices[entity.device_id]) {
+ if (entity?.device_id && !devices[entity.device_id]) {
return { kind: 'orphaned', reason: 'device_missing', enabledEntityIds: [], allEntityIds: [ref] };
}
- if (entity.device_id && !isRegistryEntryEnabled(devices[entity.device_id])) {
+ if (entity?.device_id && !isRegistryEntryEnabled(devices[entity.device_id])) {
return { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: [ref] };
}
+ if (!entity && !hass?.states?.[ref]) {
+ return { kind: 'orphaned', reason: 'entity_missing', enabledEntityIds: [], allEntityIds: [] };
+ }
return { kind: 'active', enabledEntityIds: [ref], allEntityIds: [ref] };
}
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 92439e37..12b301a0 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -69,10 +69,14 @@ import {
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
- Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
- autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
+ Affine, applyAffine, readVacTelemetry,
+ autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback,
+ parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid,
+ vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM,
+ VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
- VAC_TRAIL_LINGER_MS, Pt as VacPt,
+ Pt as VacPt, type VacPath, type VacSourceCandidate,
+ type VacSourceResolution, type VacSourceStatus,
} from './vacuum';
import {
buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, humFor, climateTempFor, isHumEntity,
@@ -138,8 +142,9 @@ import {
type DecorBox, type SnapGeometry,
} from './editors/decor/geometry';
import { renderOpeningTunnelFills } from './render/opening-tunnels';
+import { safeStoredColor } from './color';
-const CARD_VERSION = '1.60.3';
+const CARD_VERSION = '1.61.0-beta.1';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -1161,6 +1166,15 @@ class HouseplanCard extends LitElement {
private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams;
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
fx: number; fy: number } } | null = null;
+ /** Marker whose lazy «All cameras» candidate section is expanded. */
+ private _vacAllCamerasFor: string | null = null;
+ /** One snapshot per currently open global-camera section; rebuilt on reopen. */
+ private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null;
+ /** Proposed high-residual auto-calibration. Config remains untouched until Apply. */
+ private _vacCalConfirm: {
+ markerId: string; source: string; mapId: string; matrix: Affine;
+ rooms: number; error: string;
+ } | null = null;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
@@ -1248,6 +1262,8 @@ class HouseplanCard extends LitElement {
_dtDrag: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
+ _vacAllCamerasFor: { state: true },
+ _vacCalConfirm: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
@@ -1421,6 +1437,7 @@ class HouseplanCard extends LitElement {
if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors
if (this._tapConfirm) { this._tapConfirm = null; return; }
+ if (this._vacCalConfirm) { this._vacCalConfirm = null; return; }
if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; }
if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._infoCard = null; return; }
@@ -6217,7 +6234,8 @@ class HouseplanCard extends LitElement {
: `${text}${text ? ' ' : ''}${legacyToken}`;
}
this._decorTextDialog = {
- id: shape.id, x: shape.x, y: shape.y, text, color: shape.color || this._decorStyle.color,
+ id: shape.id, x: shape.x, y: shape.y, text,
+ color: safeStoredColor(shape.color, this._decorStyle.color),
opacity: clamp01(shape.opacity, this._decorStyle.opacity),
angle: this._angleField(shape.angle),
sizeCm: this._decorTextSizeCm(shape),
@@ -7491,7 +7509,7 @@ class HouseplanCard extends LitElement {
}
private get _editorSecondaryDialogBlocked(): boolean {
- return !!(this._tapConfirm || this._roomDialog || this._mergeDialog
+ return !!(this._tapConfirm || this._vacCalConfirm || this._roomDialog || this._mergeDialog
|| this._openingDialog || this._physicalDialog || this._openingInfo
|| this._decorTextDialog || this._decorShapeDialog || this._backdropDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog
@@ -9319,6 +9337,10 @@ class HouseplanCard extends LitElement {
// ================= DEVICE EDITOR (markers) =================
private _openMarkerDialog(d?: DevItem): void {
+ // A global-camera snapshot belongs to one explicit expansion only; never
+ // carry it across device-dialog sessions.
+ this._vacAllCamerasFor = null;
+ this._vacAllCameraCache = null;
if (d) this._ackNewDevice(d.id);
if (!this._norm) {
this._showToast(this._t('toast.marker_needs_server'));
@@ -9337,7 +9359,7 @@ class HouseplanCard extends LitElement {
icon: d.marker?.icon || '',
autoIcon: d.icon || '',
display: normalizeDeviceDisplay(d.marker?.display),
- rippleColor: d.marker?.ripple_color || '',
+ rippleColor: safeStoredColor(d.marker?.ripple_color, ''),
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0,
@@ -11784,6 +11806,21 @@ class HouseplanCard extends LitElement {
${this._decorEraseConfirm ? this._renderDecorEraseConfirm() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
+ ${this._vacCalConfirm ? html` (this._vacCalConfirm = null)}>
+