diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bf8e17a0..f0daf1fe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,2330 +1,653 @@ # House Plan architecture -Updated: 2026-09-07 (#486 sidebar panel). The repository = a HACS integration (category **Integration**) -that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`). +One HACS repository (category **Integration**) ships the backend +(`custom_components/houseplan`), both Lovelace cards and the `/houseplan` panel +(`src/` → `dist/`). This document is the map: each subsystem gets a short +description and a link to its canonical document, which owns the details. ## Styles (#266) -`src/styles.ts` is a 19-line aggregator: `cardStyles = [baseStyles, planStyles, -devicesStyles, chromeStyles, dialogsStyles]` from `src/styles/*.styles.ts`. -The array ORDER is part of the cascade contract — rules of equal specificity -resolve by position, and the golden set is accepted against exactly this -order. Surface ownership: `base` (host, variables, cross-surface groups), -`plan` (stage scene, walls/axes/snap/decor/iso/resize ink), `devices` -(markers, shells, vacuums), `chrome` (toolbars, tabs, menus), `dialogs` -(dialogs, forms, pickers). A rule serving two surfaces lives in `base`; the -invariants (no duplicate selectors across files, aggregator composition, -media-wrapper survival) are pinned by `test/styles-split.test.mjs`, and -`scripts/dev/styles-diff.mjs` proves any restyle-move refactor-only. +`src/styles.ts` composes `cardStyles = [base, plan, devices, chrome, dialogs, +isoTiles]` from `src/styles/*.styles.ts`; the order is a cascade contract (the +golden set is accepted against it; `isoTiles` last so 2.5D tiles beat +equal-weight Flat marker rules, #649). `base` holds host, variables and rules +shared by two surfaces; `plan` stage ink, `devices` markers, `chrome` +toolbars/tabs/menus, `dialogs` dialogs/forms/pickers. `form-kit.styles.ts` +(#594), `editor-secondary.styles.ts` and the summary-panel sheets live outside +that aggregator. `test/styles-split.test.mjs` pins composition, no cross-file +duplicate selectors and `@media` wrappers; `scripts/dev/styles-diff.mjs` proves +a move refactor-only. Public selectors: `STYLING-HOOKS.md`. ## Layout ``` houseplan-card/ -├─ src/ # card sources (TypeScript + Lit 3) -│ ├─ houseplan-card.ts # eager View shell, HA lifecycle and projection -│ ├─ houseplan-panel.ts # HA sidebar/app-bar host for the same full card -│ ├─ editor-runtime-loader.ts # lazy loader: dedupe, retry and build handshake -│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root -│ ├─ stairs-view.ts # eager read-only stair symbols, links and gesture guards -│ ├─ stairs-editor.ts # lazy Plan stair tools, transforms and properties -│ ├─ stairs-editor-model.ts # eager-safe stair helpers: defaults, kind conversion, target state, stair magnet -│ ├─ stairs-box.ts # editor-only box transforms: drag-to-draw, resize, edge magnets, cursors, dialog units -│ ├─ stairs.ts # stair model, render geometry and area math -│ ├─ clean-floor.ts # shared room-floor subtraction including stair footprints -│ ├─ decor-image-editor.ts # lazy Background/Furniture image palette, upload and properties controller -│ ├─ houseplan-onboarding-runtime.ts # first-space/import dialogs, independent of editor -│ ├─ iso-scene-render.ts # lazy 2.5D scene/runtime boundary (settings.volumetric_view, #649) -│ ├─ furniture-art-runtime.ts # page-scoped lazy designer furniture artwork (ready/pending/fallback) -│ ├─ pdf/ # lazy read-only A4 scene, writer, dialog and embedded font -│ ├─ iso-overlays.ts # pure raised-overlay ownership, collision and nudge -│ ├─ hp-dialog.ts # shared HA/native modal shell, focus and transient-overlay lifecycle -│ ├─ hp-confirm.ts # presentation for shared dangerous-action confirmation -│ ├─ danger-confirm.ts # root-owned promise/token confirmation controller -│ ├─ hp-help.ts # presentation-only, localized contextual-help surface -│ ├─ floating-surface.ts # pure visual-viewport flip/shift geometry for dialog surfaces -│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle -│ ├─ editor-secondary.ts # context tray model, groups, focus/dismiss lifecycle and stable template -│ ├─ editor-secondary.styles.ts # styles owned by the context tray/submenu surface -│ ├─ device-inbox.ts # pure exact-binding lifecycle catalog + shared Add eligibility -│ ├─ space-model-selection.ts # active-or-first and exact optional space selectors -│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills -│ ├─ editor.ts # GUI config editor (ha-form + selectors) -│ └─ rules.ts # icon rules (iconFor), filtering, groups, fallback order -├─ dist/ # entry + manifest + content-hashed JS chunks -├─ demo/golden/ # deterministic HP-QA-01 matrix, capture/verify/accept -├─ demo/performance/ # large-house budgets and same-runner comparison -├─ scripts/release-*.mjs # exact-SHA publication contract and local orchestrator -├─ .github/workflows/ -│ └─ publish-prerelease.yml # draft-first one-button prerelease publication -├─ custom_components/houseplan/ # the HA integration -│ ├─ __init__.py # setup: Store, WS commands, JS/card/panel lifecycle -│ ├─ panel_registration.py # fail-soft owned /houseplan custom-panel lifecycle -│ ├─ trails.py # server-side vacuum trail recorder (state-change driven) -│ ├─ websocket_api.py # houseplan/layout/get|set|update -│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins) -│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL -│ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js -├─ hacs.json # HACS manifest -└─ docs/ # this documentation +├─ src/ # card sources (TypeScript + Lit 3) +│ ├─ houseplan-card.ts # eager full card: View shell, HA lifecycle, lazy-runtime hosts +│ ├─ houseplan-panel.ts # /houseplan sidebar host around one full card (#486) +│ ├─ space-card.ts · space-render.ts # read-only houseplan-space-card and its static renderer +│ ├─ houseplan-editor-runtime.ts # lazy Plan/Devices/Background composition root +│ ├─ houseplan-onboarding-runtime.ts # lazy first-space/import dialogs, independent of the editor +│ ├─ iso-*.ts · pdf/ · stairs*.ts · furniture*.ts # 2.5D, PDF export, stairs, furniture (own docs) +│ ├─ devices.ts · rules.ts # device list from registries; icon rules and exclusions +│ ├─ ha-binding-status.ts · device-presentation*.ts # binding authority; marker face decisions +│ ├─ space-geometry.ts · wall-*.ts · physical-geometry.ts · logic.ts · types.ts # pure, no Lit/DOM +│ ├─ config-store.ts · config-adoption.ts · command-stack.ts # config cache, #500 boundary, Undo +│ ├─ sun.ts · day-cycle-render.ts # window rays, four-phase background (SUN.md) +│ ├─ vacuum*.ts · radar-*.ts · zigbee-topology*.ts · summary-panel*.ts # live subsystems +│ ├─ hp-dialog.ts · hp-confirm.ts · danger-confirm.ts · hp-help.ts · floating-surface*.ts +│ ├─ editor-runtime-loader.ts · version-recovery*.ts · editor-secondary.ts # lazy loader; #462; tray +│ ├─ editor.ts · space-editor.ts # Lovelace GUI config editors of both cards +│ └─ editors/ · render/ · styles/ · i18n/ # settings dialogs, SVG projections, sheets, locales +├─ dist/ # two stable entries, houseplan-assets.json, hashed chunks +├─ demo/ # demo rig and smokes; golden/ (HP-QA-01), performance/ +├─ scripts/ · .github/workflows/ # build/bundle gates, release-*.mjs; CI and release +├─ custom_components/houseplan/ # the HA integration +│ ├─ __init__.py · store.py # setup/unload; versioned stores and per-entry runtime data +│ ├─ websocket_api.py · http_api.py · auth.py # WS commands, uploads, the one may_write policy +│ ├─ validation.py · coordinate_canonicalization.py # pure schema validation, write canonicalisation +│ ├─ frontend_registration.py · frontend_assets.py · panel_registration.py # resource, chunks, panel +│ ├─ import_export.py · decor_assets.py · plans.py # backup/import, decor images, plan blobs +│ ├─ trails.py · vacuum_routes.py · radar*.py · virtual_lights.py # live-subsystem backends +│ ├─ config_flow.py · const.py · system_health.py · diagnostics.py · repairs.py · support_*.py +│ └─ frontend/ # release-only snapshot of dist (#657) +└─ docs/ # this documentation ``` -Rollup emits two stable roots: `houseplan-card.js` for optional Lovelace cards -and `houseplan-panel.js` for the primary HA sidebar page. The panel root imports -the card implementation by its content-hashed name rather than duplicating it; -the card initial graph never imports the panel shell. The panel does not go -through the card's stable facade: that facade is the one address with no version -in it — a dashboard reaches the same file through the Lovelace resource's `?v=`, -and a relative specifier cannot inherit that query — while entries are served -without `Cache-Control`, so a browser may keep its copy for hours. Routing the -panel through it let a stale entry pull a stale chunk and run a previous card -against the current backend in silence (#535). Both stable roots keep the -fail-loud stale-load wrapper. Rollup -embeds one source fingerprint in the eager entry and every fingerprint-checked -lazy runtime. -`dist/houseplan-assets.json` records the import graph, sizes and SHA-256 of every -generated asset. View loads only the initial graph; Plan, Devices and Background -share one editor runtime loaded on first intent. An empty installation loads a -separate onboarding dialog chunk, so creating the first space does not require -the editor asset; saving it then continues into Plan as before. The -isometric (2.5D) renderer is a third independent runtime and is not requested -while `settings.volumetric_view` is off (#649). Its first load and cache-busted retry use the same exact-build -fingerprint handshake and atomic install contract. The backend keeps the public -entry URL stable and serves only manifest-listed JS basenames below -`/houseplan_files/houseplan-assets/`. Performance, golden and smoke tooling -verify the manifest and every asset before recording results, so a stale or -partially copied tree cannot produce a false baseline. - -PDF export is a fourth independent, read-only runtime. Ordinary View contains -only the administrator printer trigger and the same exact-build loader -contract; the writer, print-scene geometry, dialog implementation, raster -conversion and embedded Roboto subset stay in `lazyPdfFiles`. The runtime reads -the already normalized current space and the same physical-geometry resolvers -used by View, produces one deterministic A4 document in the browser and never -writes config or layout. Dimension input is normalized before collinear -compaction, accepts only canonical horizontal/vertical edges and deduplicates -opposite pairs only inside one room contour or connected outer ring. Physical -text bounds reuse the writer's actual font metrics and transform; dimension -lanes use exact box/segment intersections against wall rings instead of sampled -points, and parallel facade steps are kept in independent collinear groups. -Exterior extension lines alone use a source-aware collision state machine: one -continuous boundary/solid prefix connected to the measured corner may exit the -wall, but every intersection, tangent contact or overlap after the first free -interval rejects that lane. Dimension lines, shelves, labels and internal -dimensions remain on the strict collision path. -Physical -wall components are emitted as even-odd clipped `#7f7f7f` paths with a -page-anchored hatch, so openings cut both material layers cleanly. Page choice -is made after the complete optional scene exists: actual command bounds select -orientation and standard scale, then centre that whole scene. The footer uses -a filled vector compass and deliberately has no symbol legend. The manifest -and bundle gate require a non-empty PDF graph and reject any overlap with the -initial View graph. - -Prerelease publication has one fail-closed contract shared by the local command -and the manual GitHub workflow. The tag version must match all six shipped -version authorities, both changelogs need a dated section, and the canonical -bilingual `docs/RELEASE-NOTES.md` must link to immutable tagged changelogs. A -public release is assembled as a draft, receives and verifies -`houseplan-card.js` plus `houseplan.zip`, and becomes visible only after the -exact candidate SHA has a green Validate. `RELEASE-MEMBERSHIP.json` binds the -issue batch to that SHA through commit trailers and is itself covered by -`SHA256SUMS`; the post-publication bookkeeping never selects the live S8 queue. -The same manifest consumer makes local and workflow retries idempotent across -comment, label-removal and close failures. Existing release-event workflows are -kept as an independent recovery path; they enforce the same exact-SHA gate. -`Validate` contains only the candidate performance smoke so ordinary betas do -not wait for a full comparison. Every `main` promotion starts the dedicated -`performance.yml` workflow; stable release assets additionally require that -workflow to be green for the exact tagged SHA. Weekly and manual full runs keep -the same profiler available between stable promotions. +Rollup emits two stable roots, `houseplan-card.js` and `houseplan-panel.js`, +plus hashed chunks and `dist/houseplan-assets.json` (graph, sizes, SHA-256). +The panel imports the card chunk by its hashed name, never through the card's +unversioned facade: entries are served without `Cache-Control`, and a stale +facade once ran an old card against the current backend (#535). Both roots keep +the fail-loud stale-load wrapper. View loads only the initial graph; editor, +onboarding, 2.5D (#649), PDF, locale and furniture-art graphs are lazy, each +passing the exact-build fingerprint handshake (one cache-busted retry, atomic +install), and `scripts/bundle-budget.mjs` fails a build whose lazy graph is +missing or leaks into the initial graph. The backend serves only manifest-listed +chunks. Commands and gates: `DEVELOPMENT.md` › Build, › Tests, › Release. ## Key decisions -1. **One repository — integration + panel + cards.** The integration serves the JS through - `async_register_static_paths`; the exact versioned module URL is the shared - resource identity. A writable Lovelace resource registry is authoritative. - YAML resources mode, an unavailable registry and terminal registration errors - use `add_extra_js_url` as a truthful fallback; a pending/transient registry gets - at most one lifecycle-bound retry. The typed registration outcome and final - loader are exposed through System Health, and the first available frontend - registration creates one localized persistent hard-reload notice. The initial - `custom:houseplan-card` graph compares `CARD_VERSION` with the authoritative - `integration_version` from each successful `houseplan/config/get`: ordinary - View offers a manual reload, while kiosk may reload once per backend target and - browser-tab session only in a safe idle state. `custom:houseplan-space-card` - loads the same bundle but has no independent banner or reload controller. - After storage migrations/repairs succeed, setup also registers a public HA - custom panel at `/houseplan` with `require_admin=False`. The panel is a thin - app-bar and container-size host around one ordinary `houseplan-card`; it does - not introduce another configuration model or map HA kiosk state to card - kiosk. Registration is fail-soft: a missing panel asset, foreign path - collision or frontend API failure leaves the backend and Lovelace cards - usable. Ownership is proven by the exact HA panel-registry object plus setup - generation before unload removes `/houseplan`; foreign or newer panels are - never overwritten or removed. System Health exposes bounded status codes and - public URLs without exception text or private filesystem data. - The dashboard card also accepts Home Assistant's `layout="grid"` host signal. - In that Sections-only branch HA owns a fixed vertical slot: `:host` and - `ha-card` fill it, while the header is fixed and `.stage` is the shrinking - flex child. `getGridOptions()` advertises full width, 10 default rows and a - 6-row minimum; explicit dashboard `grid_options` still win in HA's merge. - Mode transitions and soft-boot settling use the card's measured height in - this branch, never `window.innerHeight`. Other dashboard layouts retain the - viewport-height contract, and the compact space card advertises no grid - defaults (#648). -2. **Icon layout lives on the server.** `helpers.storage.Store(1, "houseplan.layout")` → - `.storage/houseplan.layout`. The card reads/writes via `hass.callWS` - (`houseplan/layout/get|set|update`). Fallback — localStorage (when the integration is absent). -3. **No token.** Everything comes from the frontend `hass` object and its - authenticated connection: `hass.states` is reactive, while a module-level - `ha-binding-status` cache obtains the complete device/entity registries via - `hass.callWS`. There is one fetch/in-flight request and one pair of registry - subscriptions per HA connection, shared by every full/static card on the - page; House Plan creates no direct socket or token. Newly observed rows in - the live frontend projection augment an older full snapshot immediately, - and a changed projection schedules a debounced full reconciliation. This - keeps discovery and `disabled_by` changes reactive even when a registry - event subscription is unavailable. -4. **Reactivity.** Every state change in HA leads to set hass → re-render. - Temperatures/LQI/on-off are live by definition (verified by substituting state). - Authoritative device/entity registry rebuilds also run the pure - `device-area-relocation` resolver. Its pending ids override stale layout in - both interactive and hosted-static projections immediately; a writer then - deletes those layout entries before advancing bounded Area provenance in - config. If that config write is rejected, every successfully deleted manual - point is restored through the layout store before a retry; a failed restore - falls back to the existing attention marker. Relocation invalidates only - Undo/Redo commands owned by the moved device. Limited registry snapshots - are read-only and never infer movement. -5. **One modal contract.** Card modals render through `hp-dialog`. An ordinary - dialog uses `ha-dialog` when that component is registered as the instance is - connected; otherwise it keeps the native `` fallback for its whole - lifetime. Alert confirmations deliberately stay native so the browser owns - their real `alertdialog` semantics. The native branch is a first-class modal: - its outer dialog shrink-wraps and centres the bounded surface in the browser - top layer with a backdrop. The wrapper reconciles the actual `:modal` state - after render, update and reconnect; if a retained open flag has outlived the - top-layer entry, it closes and reopens the same native dialog once. A late - `ha-dialog` registration may affect only newly connected instances and never - swaps an already open surface. The wrapper owns the title, - initial focus, Escape close event and restore-focus session. Focus sessions - are scoped to a card shadow root so nested dialogs return to their parent - trigger and dialog replacement still returns to the original outside opener. - `flex-content` forwards ha-dialog's `flexcontent`, making HA's `.body` a - flex column so a consumer that is itself a scroll container (`min-height: 0`, - `overflow: auto`, `overscroll-behavior: contain`) is height-bound and scrolls - by itself; without it Chromium stops wheel and touch scroll chaining at the - never-scrolling child (#508). The summary-panel settings dialog uses it; the - native branch already bounds the surface with its own flex column. - Its footer wrapper is a full-width slot item: HA lays the footer slot out as - flex, so flattening that wrapper would shrink action rows to their content. - The wrapper opts HA's title-height custom property into content sizing so - localized titles may wrap without clipping. Dialogs with destructive and - commit actions use two explicit wrapping groups: destructive actions stay - left, while Cancel/Save move together to a right-aligned second line when - translated labels do not fit. - Dangerous actions additionally use one eager `HpConfirmController` owned by - `HouseplanCard` and the stateless `hp-confirm` presentation. View, onboarding - and lazy editor callers await the same replace-not-queue promise contract; - X, scrim, Escape, route/mode/space change and disconnect all resolve as - cancellation. An internal token rejects stale or duplicate decisions, and - every caller re-resolves its target after `await` before changing config, - layout or HA state. Native browser `confirm()` is not a supported runtime - surface. -6. **Open passages are negative architecture.** `OpeningCfg.type=passage` - shares placement, wall-cut and tunnel geometry with other openings but has - no visible leaf, state binding or isometric panel. Backend semantic - validation is change-aware: existing broken records remain readable, while - new writes/imports are canonical. Static wall fingerprints include passage - cuts only, preserving the historical output of doors/windows/gates. -7. **One transient-surface contract.** `hp-dialog` owns a scoped LIFO registry - for explanatory/help and colour-picker surfaces. Escape and toast close the - upper transient surface before the dialog, and a new transient surface - replaces the previous one only inside the same dialog. `hp-help` and - `hp-color-opacity` share the pure `floating-surface.ts` placement helper - and `floating-surface-controller.ts` fallback/portal lifecycle, - prefer the browser top-layer Popover API and use a real dialog-owned portal - when that API is unavailable. Help text is localized by the owning card so - two cards with different explicit languages remain independent. A help - affordance exists only when both its localized body and complete accessible - label are non-empty; the card factory and `hp-help` enforce this independently, - so incomplete content cannot leave a dead focus target or a layout gap. - English and Russian dictionaries remain in the synchronous graph. German is - a fingerprint-checked lazy locale shared page-wide: every root card/editor - uses the same render gate, keeps a previously committed frame stable during - a language switch, and shows a language-neutral busy frame on a German cold - start. Two failed content-hashed attempts settle on English rather than - leaving an inert surface. The bundle manifest classifies this graph as - `lazyLocaleFiles`, separate from editor and onboarding graphs. - Designer furniture artwork follows the same shape (#474): the catalogue - (ids, groups, default sizes) stays eager, the 60 SVG drawings live in - `lazyFurnitureArtFiles` behind `FURNITURE_ART_RUNTIME`. Since #593 the - library is designer artwork only — the twelve primitive symbols drawn from - code are gone, so every piece waits for the chunk instead of twelve of them - rendering regardless. Plan intake starts - the load only when the plan draws a designer piece, the boot veil holds - until the runtime settles (within the veil's hard cap), the editor imports - the drawings statically and hands them over synchronously (`adopt`), and - two failed content-hashed attempts — or a chunk from another build — settle - into `fallback`: pieces render as unknown symbols and one toast is shown. -8. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts` - atomically chooses a strict real `sun.sun` sample or browser-local clock - fallback and returns only phase/source/light tokens. `src/day-cycle-render.ts` - owns the constant four-layer DOM and exact palette used by full View, kiosk, - and `houseplan-space-card`; no surface copies thresholds or formulas. The - environment is a pointer-inert sibling behind the plan. Before camera input, - the zero-offset outline remains on the grouped paper footprint, preserving - the reviewed single-SVG composition byte-for-byte. The first pan or pinch - switches the card instance to an exact paper copy in a stage-sized sibling - SVG, removes the filter hint from the visible `.hp-paperg`, and explicitly - caches the stage-sized plan. Keeping the filter on the inner coordinate-space - group while moving made 1 cm/point plans exceed WebView texture budgets - (#582). The content tree has no brightness, tint, opacity, or blend changes. - Full and static card lifecycles arm a 30-second - timer only during clock fallback while visible, catch up on visibility return, - and dispose it on disconnect. Window-ray geometry remains a separate - north-gated consumer of `sun.sun`. +1. **One repository — integration + panel + cards.** The integration serves + its own JS; the exact versioned module URL is the resource identity, a + writable Lovelace resource registry is authoritative and `add_extra_js_url` + the truthful fallback. Each `houseplan/config/get` carries the authoritative + `integration_version`: View offers a manual reload, kiosk reloads once per + backend target in a safe idle state, the space card has no version controller + (`DEVELOPMENT.md` › Resource registration and version recovery (#462)). After + migrations succeed, setup registers the public `/houseplan` panel + (`require_admin=False`, #486): a thin app-bar host around one ordinary full + card, fail-soft, removed on unload only by the exact owner and setup + generation (`UX-MODES.md` › Principle; `TESTING.md` › Installation / upgrade / + removal). In a Sections `layout="grid"` slot HA owns the height: `.stage` is + the shrinking flex child and transitions use the measured card height, never + `window.innerHeight`; `getGridOptions()` is full width, 10 rows, min 6 (#648). +2. **Server-side storage, no token.** `store.py` keeps `.storage/houseplan.config`, + `houseplan.layout` (marker positions) and `houseplan.virtual_lights`; + `trails.py` keeps `houseplan.trails`. All traffic uses the frontend `hass` + connection (Integration WS API below); House Plan opens no socket and holds no + token. localStorage keeps a start-up snapshot and, only if no backend ever + answered, a local layout. Registries come from one `ha-binding-status` fetch + and subscription pair per HA connection, shared by every card on the page; + live rows augment an older snapshot at once and a debounced full reload + reconciles `disabled_by` even without registry events (`FILTERING.md` › + Behaviour). +3. **Reactivity.** Every `hass` update re-renders. Registry rebuilds also run the + pure `device-area-relocation` resolver: pending ids override stale layout in + full and static cards at once; the writer deletes those points before + advancing Area provenance and restores them if the config write is rejected + (a failed restore leaves the attention marker); only the moved device's + Undo/Redo is invalidated; limited snapshots never infer movement + (`CONFIG-COMPATIBILITY.md` › Marker Area provenance (#126)). +4. **One modal contract.** Card modals render through `hp-dialog`: `ha-dialog` + if registered when the instance connects, otherwise a first-class native + `` for its lifetime (top layer, backdrop, reopened once if `:modal` + was lost); a late registration never swaps an open surface, and alert + confirmations stay native for real `alertdialog` semantics. The wrapper owns + title, initial focus, Escape and a shadow-root-scoped restore-focus session + (nested dialogs return to their trigger, a replacement to the first opener). + `flex-content` forwards `flexcontent` so an inner scroller is height-bound + (#508); the footer stays a full-width slot item; titles wrap; destructive + actions stay left while Cancel/Save wrap right together. Dangerous actions + use one eager `HpConfirmController` on `HouseplanCard` plus stateless + `hp-confirm` (#32): replace-not-queue, every dismissal cancels, a token + rejects stale decisions, callers re-resolve targets after `await`. Native + `confirm()` is not a supported surface. +5. **Open passages are negative architecture (#157).** `type=passage` shares + placement, wall cut and tunnels with other openings but has no leaf, binding + or 2.5D panel; static wall fingerprints add only passage cuts, so door/window/ + gate output is unchanged (`CONFIG-COMPATIBILITY.md` › Open-passage opening + type). +6. **One transient-surface contract (#68, #57).** `hp-dialog` keeps a scoped + LIFO registry for help and colour-picker surfaces: Escape/toast close the top + one first; a new one replaces the previous only inside the same dialog. + `hp-help` and `hp-color-opacity` share `floating-surface.ts` placement and + `floating-surface-controller.ts` (Popover top layer, dialog-owned portal + fallback). Help is localized by the owning card and exists only when body and + accessible label are both non-empty. Page-wide lazy runtimes — locales + (#62, #400) and furniture artwork (#474, #593) — settle after two failed + attempts into English / no artwork with one toast, never an inert card. +7. **One four-phase environment resolver.** `resolveDayCycle()` (`src/sun.ts`) + and `src/day-cycle-render.ts` serve View, kiosk and the space card; plan + content is never phase-filtered (`SUN.md` › Current four-phase background). + The stage-sized outline root owns the triple drop-shadow and + `will-change: filter`; its child is only the exact paper alpha silhouette, + and the plan root gets an explicit stage-sized transform layer so Chromium + never rediscovers it as an implicit overlap layer (#532, #582). ## Coordinate system -- Base space: **1489×1053** ("pixels" of the old PNG render, 1 unit = 1 px). - All rooms, icon positions and floor viewBoxes live in it. DO NOT change without a layout migration. -- Vector plans are inserted as `` into the `FLOOR_BG_RECT` rectangle: - - f1: scale **0.647**, offset **(490, 27)** → rect [490, 27, 774.2, 949.3] - - f2: scale **0.896**, offset **(351, 21)** → rect [351, 21, 1048.4, 961.4] - - computed via raster correlation (cv2.matchTemplate on binarized darkness maps) of the - SVG render against the reference PNG; accuracy ~1 px. The scripts are reproducible (docs/DEVELOPMENT.md). -- Rooms (`ROOMS`) are snapped to the inner faces of walls (semi-automatic: search for the nearest - "dark line" along the profile + manual fine-tuning against overlay renders). +Plan geometry and marker positions are stored normalised on an unbounded canvas: +`0..1` = `NORM_W` (1000) render units, `±5000` validation, `1/240` lattice +(`src/canvas-constants.ts`). Source of truth: `CANVAS.md` › Principle, › Model, +› Grid precision and visual units, › Persisted coordinate canonicalisation. ## Card data model (runtime) -`SpaceModel` is absent when the authoritative configuration has no spaces. -`_spaceModel()` therefore returns `SpaceModel | undefined`: it preserves the -legacy active-or-first selection for rendering and current navigation, but it -never invents a dummy space. Commands carrying a persisted or otherwise stable -space id use the exact `_spaceModelById()` selector; a stale id aborts before -config, layout, file or service side effects instead of mutating the first -space. +**Optional space model (#113).** Without authoritative spaces there is no +`SpaceModel`: `_spaceModel()` returns `undefined` and never invents one. Its +active-or-first choice serves rendering and navigation only; commands carrying a +stable space id use the exact `_spaceModelById()` and abort on a stale id before +any config, layout, file or service effect (`space-model-selection.ts`). The +first update that sees an authoritative empty `spaces` runs one cleanup (pointer +capture, gestures, draft/history, space dialogs, pending config write, back to +View); recreating a space re-arms it. Pure helpers may return empty results; +mutation entry points guard explicitly. -The first update that observes an authoritative empty `spaces` array runs one -space-bound lifecycle cleanup. It releases tracked pointer capture, cancels -pan/pinch/drag/resize/vacuum and geometry gestures, clears draft/history and -space dialogs, cancels the debounced config write and returns the card to View. -The global empty-state Create/import flows remain available. Recreating a space -re-arms cleanup so a later WS transition back to empty is handled identically. -Pure render/geometry helpers may return an empty result while the model is -absent; mutation entry points must guard explicitly. - -`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary -(the first resolved state entity for actions requiring one target), temp, -members[] (light group), link/linkPrimary (Z2M group). Marker state consumes -the complete resolved role, not this single compatibility field. `entities[]` -contains active runtime entities only; `allEntities[]` is metadata-only, and -`bindingStatus` distinguishes `active`, `ha_disabled`, `orphaned` and -`unverified` without mutating persisted markers. - -`resolveHaBindingStatus()` is the only authority for saved HA bindings. Full -registry data wins; a limited-permission client accepts positive live evidence -but never guesses that a missing shortened row means disabled/deleted. Every -plan-level consumer uses the active registry/state projection, so disabled -bindings cannot leak through Glow, climate, LQI, live text, openings, controls -or vacuum rendering. A bounded local runtime cache stores only the last -authoritative active/disabled decision to avoid a stale warm-remount flash; it -is not part of the server config or layout. - -Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype: -- only devices with an area from the room list are shown; -- hidden: entry_type=service, integrations from EXCLUDED_DOMAINS, model=Group, scenes, bridges, - myheat sub-devices, duplicates by "name|area"; -- **a device with a `lock.*` entity always gets `mdi:lock`** (TTLock locks in the registry - are named "Dom"/"Terrasa"/"Kladovka" [House/Terrace/Storeroom] — unrecognizable by name); -- lamps (mdi:lightbulb) with ≥2 in a room collapse into a group `mdi:lightbulb-group` - (click → menu: the whole group + individual lamps). +**`DevItem`** (`src/types.ts`) is rebuilt by `buildDevices()` (`src/devices.ts`) +from the active registry projection plus config markers: id, name, model, area, +space, icon, `entities[]` (active runtime entities only), `allEntities[]` +(metadata only), `primary` (first resolved state entity for single-target +actions; marker faces use the resolved role, `DEVICE-PRESENTATION.md`), +temp/hum, marker metadata and effective `controls`. `bindingStatus` (`active`, +`ha_disabled`, `orphaned`, `unverified`) comes only from +`resolveHaBindingStatus()`, never mutates markers, and gates every plan-level +consumer (Glow, climate, LQI, live text, openings, controls, vacuum). +Auto-discovery takes non-service devices whose HA Area is bound to a space. The +base icon is the first matching icon rule (`" "`) → entity +`device_class` → `mdi:chip`; a device with a `lock.*` entity is always +`mdi:lock`. Duplicate `name|area` pairs are numbered; HA light groups and Z2M +group devices become `lg_` items that fold their lamps. Hiding is +`FILTERING.md`. ## Live data -- Value sources: `src/device-value-badge.ts` owns candidate discovery, source - keys, HA formatting, units and unavailable handling for both an explicit - `marker.value_source` inside the **Value + state** face and an explicit - `marker.value_badge` satellite. Absence of `value_source` keeps the legacy - automatic face resolver; absence of `value_badge` projects the legacy - automatic temperature/humidity satellite. Renderers consume only the - corresponding fields of `ResolvedDevicePresentation`. -- LQI (zigbee): the average over `*_linkquality` entities → label under the icon; color via - `lqiColor()`: ≤40 red → ≥180 green (hsl gradient). The room average is shown in the room tooltip. - The same tooltip includes the formatted clean-floor area (inner contour for - thick walls). View hover is a late plain-SVG wash plus wide/narrow accent - strokes over that clean-floor geometry. It deliberately uses no CSS/SVG - filters: promoting a filtered sibling makes Chromium briefly recompose and - brighten the isolated screen-blended Glow layer. -- Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor - of problem classes), unavail (transparency, also used by a powered-down - media endpoint). Yellow remains on the marker in - source-glow fill mode: a light pool is spatial information, not a replacement - for the universal working-state plate. +State, values, LQI and activity are resolved once per frame into one +`ResolvedDevicePresentation` (see Device markers); explicit `marker.value_source` +and `marker.value_badge` go through `src/device-value-badge.ts`. Decision table, +plate colours and LQI scale: [`DEVICE-PRESENTATION.md`](DEVICE-PRESENTATION.md). -### Presence-radar runtime (#485 Stage 1) +### Presence radars (#485 Stage 1) -`marker.radar` is an optional, versioned source/installation namespace. The -ordinary config transaction remains its only persistence boundary and -`radar_validation.py` validates only a changed known version: untouched future -versions are preserved inertly. Import virtualization removes hardware entity -bindings. `settings.radar.show_live` is only a display preference and never -authorizes source discovery, recording or hardware writes. +`marker.radar` is an optional versioned namespace saved only by the ordinary +config transaction. One backend `RadarCoordinator` per entry alone reads raw HA +states and streams clipped, ACL-filtered frames over `houseplan/radar/subscribe`; +observations, calibration samples and trails never reach config, uploads, +support reports or browser storage. Contract and code map: [`RADAR.md`](RADAR.md); +normative limits: [`specs/485-radar-presence-stage1.md`](specs/485-radar-presence-stage1.md). -`RadarCoordinator` owns exact HA source listeners, report-time freshness, -independent-pair skew, source/calibration epochs, projection, real-room clipping -and bounded public frames. It reconciles on config revision, has one runtime -instance per integration entry and closes all listeners/timers on unload. -The explicit profile/source-role inventory is the shared authority for those -listeners, setup listeners and per-entity read ACLs. Common roles are -`occupancy_entity`, `count_entity` and `availability_entity`; Cartesian slots -add `x_entity`/`y_entity`, polar slots add -`distance_entity`/`angle_entity`, range rows add `entity_id`, zone rows add -`entity_id`, and slot/range presence gates add `presence_entity`. Unknown -future fields remain inert instead of becoming sources by naming convention. -Only the backend interprets raw HA states; the eager View graph receives -normalized `targets/ranges/zones/health` snapshots from -`houseplan/radar/subscribe`. Per-user entity-read ACLs are checked for initial -and subsequent delivery. The two setup commands additionally require the -existing `may_write` policy, validate bounded drafts and enforce subscription, -payload and inspect-rate limits. +### Robot vacuums -The eager client split is `radar-model.ts` (frame validation/ordering/leasing), -`radar-live.ts` (one active-space subscription and lifecycle) and -`radar-render.ts` (pointer-transparent SVG only). Dots live below ordinary -device markers. The server supplies already-clipped range segments; an empty -segment list is authoritative and must not fall back to an unclipped arc. -Transition eligibility is also a server fact: only a current same-slot step of -at most 100 cm in a convex room may receive the CSS movement transition. -Reduced motion disables it. The client diagnostic trail is per slot, limited -to 8 seconds/32 points, resets on gaps or large jumps and never enters storage. - -The lazy editor split is `radar-editor.ts` (recognition/draft round-trip), -`editors/radar-section.ts` (marker-dialog composition) and `radar-setup.ts` -(session-only on-plan wizard). The physical mount/calibration is independent -of marker layout. A two-reference result changes only the open editor draft; -the third reference is a check and the ordinary revisioned marker Save is the -only write. Source/profile/geometry changes invalidate calibration, while -display-only switches do not. Page hide, Cancel, Escape, binding change and -disposal release draft subscriptions and samples. - -Raw observations, calibration captures, live frames and trails are never put -in config/layout, uploads, support reports or browser storage. See -[`RADAR.md`](RADAR.md) for the user contract and -[`specs/485-radar-presence-stage1.md`](specs/485-radar-presence-stage1.md) for -the normative limits and acceptance matrix. - -### Vacuum map-to-space routing authority - -`src/vacuum-routes.ts` owns the answer to "which map is on which floor" and is -the only place that answers it. `effectiveRoutes()` reads explicit -`marker.vacuum.map_routes` or, for a plan that predates #162, the legacy -`calibration` dictionary as routes into the dock's space. `resolveRoute()` -turns the observed map id per exact source into one of six results, never into -a guess: two candidates are `ambiguous`, not "the first one". The result is -computed once per frame into `render-device-snapshot.ts` -(`facts.get('vacuum:')`), so `render()` cannot derive a second answer, and -`planVacuumOverlay()` decides what the space currently on screen draws — the -dock stays in `marker.space` while the live overlay follows the active route. - -The editing half lives apart, in the lazy editor graph: `vacuum-route-edit.ts` -(add, re-target, delete, legacy conversion, matrix write, fit target) and -`editors/vacuum-maps-section.ts` (the "Maps and floors" block). The View card -must not pay for code it can never run. `custom_components/houseplan/ -vacuum_routes.py` is a byte-for-byte mirror of the resolver and the legacy-run -adoption rule, driven by the shared fixtures in -`test/fixtures/vacuum-routes/`: the recorder files each point under the route -that produced it, and a divergence between the two sides shows up as a robot on -the wrong floor. - -### 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. - -`smoothVacPath()` is the shared pure presentation step for current and previous -runs. It consumes calibrated flat plan coordinates and returns typed -`move|line|quadratic` commands with a caller-supplied physical radius; the card -then applies flat/isometric scene projection and SVG serialization. Each corner -uses a quadratic inside the adjacent-segment convex hull, bounded by half of -both segment lengths, so the 17.5 cm product limit, exact endpoints and literal -subpath gaps are structural rather than renderer-specific accidents. - -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. +`src/vacuum-routes.ts` is the only map-to-space authority (six fail-closed +results, #162; mirrored byte-for-byte by `vacuum_routes.py`), `src/vacuum.ts` +owns telemetry normalization, path arbitration and smoothing, and `trails.py` +records runs server-side. Contract and code map: [`VACUUM.md`](VACUUM.md). ## Sizes -`icon_size` in the config = **% of the visible plan area width** (default 2.5). -The surface boundary resolves this legacy public unit to the current effective -device base (`2.25` for the default) before the shared face sees it; the face -does not apply a late visual factor. Implementation: `.stage { -container-type: inline-size }` + sizes in `cqw`. Legacy px values (>8) are ignored. +`icon_size` is a percentage of the plan (default 2.5; legacy px values > 8 fall +back to 2.5), converted once at the surface boundary and rendered in `cqw` inside +`.stage { container-type: inline-size }` — [`CANVAS.md`](CANVAS.md) §6. ## Sticky header -`.head { position: sticky; top: var(--header-height, 56px) }`; ordinary dashboard -cards keep `ha-card { overflow: visible }` because hidden overflow breaks sticky. -The bounded `panel-host` and Sections `layout="grid"` branches deliberately own -their complete height chain and clip at the external slot instead. +`.head` is `position: sticky; top: var(--header-height, 56px)`, so ordinary +dashboard cards keep `ha-card { overflow: visible }` (hidden overflow breaks +sticky). The bounded `panel-host` and Sections `layout="grid"` branches own their +complete height chain and clip at the external slot instead (Key decision 1). -## Device markers (v1.6.0+) +## Device markers -Per-marker appearance: `display: badge|icon_ripple|value|static_icon`. Entity semantics originate in -`src/device-visual.ts`; `src/device-presentation.ts` resolves HA/registry/light sources and the -complete renderer-ready projection, while the pure `src/device-presentation-policy.ts` -is the single owner of lifecycle, availability, static/live/value and diagnostics -priority. Its stable internal decision trace is specified by -[`DEVICE-PRESENTATION.md`](DEVICE-PRESENTATION.md) and never enters stored config or UI. -`src/device-pulse.ts` is the single pure projection from semantic activity to -`none|alarm|short|continuous`, and -`src/device-face.ts` renders one package-derived shell/core DOM on the full plan, -device preview and static space card. The saved coordinate remains the icon-core -centre; Text is shell-centred, while a Double shell extends around the anchored -core. The 101.5/80 shell/core ratio, shared shell/core centre, Light/Dark -context, full-text fitting and 44×44 core-centred interaction floor are -renderer facts rather than surface-specific DOM. A positioned shell frame owns -the complete visible capsule hit area; its event bubbles to the marker's one -action path. Overlapping marker targets do not inherit DOM order. All painted -shells share a layer above all invisible 44 px floors; `device-hit-owner.ts` -then resolves the semantic owner in screen coordinates, preferring a painted -capsule and otherwise the nearest core with a stable id tie-break. The card -measures the current faces into a small spatial index only after render/resize -invalidation, never by scanning layout on every pointer move. One owner is -latched from pointerdown through hover/action/long-press/context-menu and a -Devices-editor drag, so a terminal event cannot jump to a neighbouring marker. -`badge` shows the icon/morph and semantic core; `icon_ripple` additionally shows three finite -event waves or one continuous wave for presence, mechanical transition and actual work; -`value` replaces the icon -with the HA-formatted numeric or text value. Ambiguous/missing/unavailable sources fall -back to the icon instead of selecting an arbitrary registry row. A critical alarm is red -in every dynamic presentation. `static_icon` deliberately keeps the configured/automatic -base icon on one neutral theme-aware core: state morphing, work/open/alarm/unavailable paint, -activity, RGB, value, temperature/humidity/LQI badges and live vacuum overlays are all -suppressed. Hover/focus, taps, controls and light aggregation keep their normal behaviour. -An optional `marker.value_badge` adds a state/attribute, derived LQI or canonical -`marker:` light-state section at right/bottom/left/top inside that shell. -Explicit settings override the global legacy temperature gate; explicit off -suppresses legacy output. Bottom badges stack above system LQI, and a derived -LQI badge de-duplicates that system row. `hp-device-preview` fits and centres -the complete face bounding box rather than allowing satellites to clip. -An optional `marker.value_source` selects the same source kinds for the inner -face when `display: value`. Missing explicit data renders a dash without -falling back to another source or the icon; absence/`null` preserves the old -automatic face selection. Derived marker references share the same rewrite and -space-transfer seam as controls and value badges. -`normalizeDeviceDisplay()` is the mandatory compatibility gate for every consumer and maps -legacy `ripple` to `icon_ripple`. `markerLqiBand()` remains marker-only semantic -metadata for accessibility, while `markerLqiColor()` delegates to the shared -continuous `logic.ts::lqiColor()` red-to-green scale. The marker dialog builds -its unsaved draft through `buildDevices`, -then `hp-device-preview` shows the actual projection, integration provenance from -registry/config-entry metadata and isolated short/continuous activity demonstrations. -Runtime baselines are seeded as soon as a rebuilt registry becomes authoritative, before -the next HA snapshot is classified; source-key changes reset any finite effect immediately. -The backend accepts legacy `display: ripple` only for compatibility. `ripple_color` and `ripple_size` remain the -stored names used by every unified pulse kind (alarm keeps its safety-red colour). -Absent pulse size resolves to 1.5; explicit persisted color/size wins, followed -by live RGB and the presence-green/work-amber/transition-blue fallback. -Continuous/short/alarm durations are 3.6/3.3/2.4 seconds. Enter/Space on an -interactive marker calls the same `_clickDevice()` path as pointer activation, -so secure confirmation and Device-editor routing cannot drift. -`size` (icon multiplier via the -`--dev-size` CSS var — value badges scale along) and `angle` rotate/scale a single icon. -Room drawing shows a live **ruler** (`segmentCm` + -`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is -per-space canonical `cell_cm`. New spaces default to 1 cm in metric HA or -2.54 cm (shown as 1 inch) in imperial HA. Missing legacy data still reads as -5 cm and is not migrated. +`config.markers[]` records are +`{id, binding: 'device:'|'entity:'|'virtual', space?, area?, hidden?, removed?, name?, icon?, …}` +plus presentation, light, control, vacuum and radar fields (`validation.py` is +authoritative). Registry devices appear on their own; a `device:` marker +overrides one, `entity:` covers groups/helpers, `virtual` is a manual icon. The +id (device id, `lg_` or `v_`) keys the layout. `hidden` is the +reversible hide flag, `removed` a never-rendered binding tombstone ([`FILTERING.md`](FILTERING.md)). -Legacy raw SVG constants are classified as visual units relative to the old -5 cm renderer and pass through `gridVisualScale()` / `gridVisualUnits()`. -Physical cm paths, screen-fixed chrome, plan-relative marker/label sizes and -grid geometry are deliberately excluded from that factor. Full/static roots -expose the same `--hp-cell-visual-scale`; hidden isometric heights and -user-space shadows include the factor in their structural cache inputs. +View, kiosk, the static card and the dialog preview share one pipeline: +`device-visual.ts` classifies, `device-presentation.ts` resolves sources, the pure +`device-presentation-policy.ts` owns priority, `device-pulse.ts` owns activity and +`device-face.ts` only paints. `normalizeDeviceDisplay()` is the mandatory read gate +for `display` (`badge|icon_ripple|value|static_icon|value_static_icon`; legacy +`ripple` → `icon_ripple`). The saved coordinate is the icon-core centre; +overlapping 44 px targets get one screen-space owner (`device-hit-owner.ts`), +latched for the whole pointer sequence. Rules: [`DEVICE-PRESENTATION.md`](DEVICE-PRESENTATION.md); +stored fields: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md). +Attachments are staged in `up_*`, promoted into `/houseplan/files//` +on Save and served by signed `/api/houseplan/content/files/…` URLs (Integration +WS API). Custom Background images use the content-addressed +`/houseplan/assets/` store (`asset_id` = SHA-256 of canonical bytes; +config never carries bytes or URLs) — [`DECOR-EDITOR.md`](DECOR-EDITOR.md) §7. -`config.markers[]`: `{id, binding:'device:'|'entity:'|'virtual', space?, area?, hidden?, removed?, -name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices -appear on their own; a marker with `binding=device:` overrides them (metadata/rebinding/hiding), -`entity:` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id / -`lg_` / `v_` (preserves the position in the layout). The binding picker and -the Device editor lifecycle catalog use the same pure `bindingCandidates()` -eligibility helper; filtering/paging happens only after the full candidate -snapshot, so large registries cannot hide later exact entities. The catalog's -`buildDeviceInbox()` projection combines runtime devices, markers, tombstones, -HA binding statuses and `new_device_ids` without owning persistence or Lit -state. Manual files: transactional HTTP upload into `/houseplan/files//` -(staging `up_*` folders promoted on save), served via signed -`/api/houseplan/content/files/…` urls. +## Server-side configuration -Custom Background images use a separate content-addressed store at -`/houseplan/assets/`. Raster input is fully decoded and SVG is parsed -through a strict allowlist before promotion; the SHA-256 of canonical bytes is -the persisted `asset_id`. Config never carries file bytes or a signed URL. -`houseplan/assets/resolve` maps unique ids to authenticated content paths. -Writers may resolve any catalog id; a read-only household member may resolve -only ids referenced by the current saved config, with forbidden ids reported as -ordinary `missing` entries. The reference snapshot is taken under the config -write lock, but file I/O happens after releasing it. The resolve path reads only -the requested sidecars rather than scanning the catalog. The HTTP content view -keeps its authenticated/signed exact-URL contract. - -Resolve and HTTP GET share one HA-instance memory-only integrity verifier. It -streams SHA-256 in bounded chunks and caches at most 256 actual digests by -canonical path plus size/mtime/ctime signature. Per-file-version single-flight -deduplicates concurrent reads without serialising different files; followers -have a bounded wait. Both signatures require a regular file, and the second -`stat` prevents a digest for bytes changed or replaced mid-read from entering -the cache. Missing, changed, non-regular and corrupt files fail dark. The shared -`ContentSigner` batches signatures for `` elements. Catalog deletion -rechecks references across every space under the config write lock. Missing or -corrupt assets are never painted in View. - -Physical inventory is separate from the strict catalog projection. Quota -counts every regular `` blob by actual file size, -including blobs with absent or malformed sidecars; a sidecar without a blob -does not count. Entries which disappear or change type during the scan are -skipped without aborting the remaining inventory. Re-uploading exact bytes repairs a digest-proven orphan before -new-file quota checks and reports `reused:false`, while an already valid row -reports `reused:true`. Explicit delete removes only the exact hash sidecar and -exact allow-listed blob names under the reference/upload locks—never a prefix, -temporary file, directory or unknown extension. There is no automatic orphan -collector. - -Both cards treat `decor_assets_api` as fresh runtime authority: localStorage -cannot grant it, and each successful `config/get` can revoke it. Static cards -do not call resolve without exact v1 and clear their projection on downgrade. -Resolve caching is scoped by connection, config revision and sorted unique id -set (including missing results); failed transport calls are not cached. - -`removed:true` is a binding tombstone, not a renderable marker. It claims an -HA binding against automatic discovery while intentionally exposing that same -binding to the catalog's re-add flow. A device tombstone excludes all data of that device; -an entity tombstone excludes the standalone entity binding but does not mutate -the same entity out of a still-live parent device. A live exact `entity:X` -marker is the one narrow override: it may coexist with a `device:D` tombstone, -restoring X while the parent claim continues to suppress D and every sibling -without its own live exact marker. The catalog exposes active children of a -device tombstone only behind **Show entities**, so that combination is reachable -without weakening ordinary runtime deletion. Runtime-filtered references such -as `controls` and live text remain persisted and become active again after -exact re-add. Exact `opening.contact` / `opening.lock` fields are a separate -architectural-object role: their HA availability ignores marker tombstones but -still uses `resolveHaBindingStatus()` to reject disabled, orphaned or unverified -entities. Their painted state comes from the immutable active-registry frame, -not directly from live `hass`. Re-adding a marker therefore cannot duplicate or -rewrite an opening reference. -Re-adding the same binding replaces its tombstone. Re-adding a child entity of -a tombstoned device preserves the parent tombstone instead; virtual markers -need no tombstone because they have no discovery source. - -## Server-side configuration (current shape, v1.51+) +`.storage/houseplan.config` holds `{model_version, spaces[], markers[], settings}`. +A space carries plan-image fields, `rooms[]` (with ordered `wall_ids`), +`wall_segments[]`, `partitions[]`, `wall_columns[]`, `openings[]`, `decor[]`, +`stairs[]` and `settings`. Coordinates keep the historical normalization +(`1.0` = `NORM_W` = 1000 render units) on an unbounded plane with a ±5000 guard +([`CANVAS.md`](CANVAS.md)); `plan_aspect` letterboxes the image and `plan_x/y`, +`plan_scale_x/y`, `plan_angle` transform it ([`DECOR-EDITOR.md`](DECOR-EDITOR.md) §3). +Layout v2 maps `device_id | rl_` to `{s, x, y}`. Plan files are +copy-on-write `/houseplan/plans/..`, never deleted for +age ([`SCOPE.md`](SCOPE.md)). Migrations: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md). ### 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", - "plan_x","plan_y","plan_scale_x","plan_scale_y","plan_angle", - "plan_scale", // legacy optional fallback, docs/DECOR-EDITOR.md §3 - "view_box":[4], - "rooms":[{"id","name","area","poly|x/y/w/h","wall_ids":[…],"settings"}], - "wall_segments":[{"id","a","b","cm","owners":[…]}], - "partitions":[…], "wall_columns":[…], - "openings":[…], "decor":[…], "stairs":[…], "settings":{…} }], - "markers": [{ "id","binding":"device:|entity:|virtual","hidden","removed", - "name","icon","display","controls","is_light","glow_color","tap_action", - "room_id","pdfs",… }], - "settings": { "exclude_integrations":[], "group_lights":true, - "filter_seeded":true, "fill_colors":{…}, "icon_rules":[…], - "known_devices":[…], "new_device_ids":[…] } } -``` -All coordinates are **normalized (0..1 of the canvas)**; the canvas is always -**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no -proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox -it centred on the square; optional `plan_x/y`, independent `plan_scale_x/y` -and `plan_angle` then transform that rectangle (`planRect`, docs/DECOR-EDITOR.md §3). -Legacy `plan_scale` feeds both axes, and the absence of every transform field -is the centred default exactly. The schema bounds geometry to ±5000 with strictly -positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long -gone — markers carry everything. `marker.hidden` is the explicit reversible -"hide from plan" flag seeded once by the old filter; `marker.removed` is the -minimal delete tombstone (docs/FILTERING.md). -Layout v2: `{device_id | rl_: {"s": space, "x", "y"}}` (normalized, -bounded ±5000). Plan files: `/houseplan/plans/..` -(copy-on-write, never overwritten), served via signed -`/api/houseplan/content/plans/_/` urls; growth is bounded by store -quotas, nothing is ever deleted for being old (docs/SCOPE.md). +Every stored colour is exactly `#RRGGBB`: `src/color.ts` resolves it and +`validation.py::_COLOR` guards writes. Render-time resolvers re-apply a safe +default because old, imported or hand-edited stores are not migrated on read. +HA `rgb_color` is live state: three finite channels, clamped/rounded to 0–255 and +emitted only as generated `rgb(R, G, B)`. The final inline-style sink accepts +only those two forms; arbitrary CSS colour syntax would need a separate +product/security decision and must not be added to an individual sink. ## Room and independent wall geometry -Model v10 separates a wall's durable identity from its current geometric lookup, -gives zero thickness one canonical meaning and stores every accepted open-chain -edge as an ordinary partition (#282, #306, #478). -`wall_segments[]` is the authoritative catalog of atomic room-wall -intervals; `rooms[].wall_ids[]` owns their ordered contour references. The -historical polygon and positive-only `walls[]` list remain render/read compatibility -projections. Room-wall openings reference `{kind:'wall', id, t}`; partition -openings continue to reference `{kind:'partition', id, t}`. The shared -frontend/backend materialiser lives in `src/wall-segment-model.ts` and -`custom_components/houseplan/wall_segment_model.py`, with a common parity -fixture. +Model v10 (#282, #306, #478): `wall_segments[]` is the authoritative catalog of +atomic room-wall intervals with stable ids, `rooms[].wall_ids[]` orders each +contour, and room polygons plus `walls[]` are read-compatibility projections. +Every accepted Walls-chain edge is an ordinary `partition`; `wall_columns` are +square/circular columns; neither creates or implicitly splits a room or HA area. +`cm:0` keeps a structural axis and identity without masonry; `space.zero_wall_style` +makes it dashed (transmits light) or solid (a zero-area barrier). Reads are +projection-only: a physical-geometry mutation builds a local candidate, +canonicalizes it, materialises the catalog (`src/wall-segment-model.ts` ↔ +`wall_segment_model.py`, shared parity fixture), validates references and commits +one config transaction; ambiguity fails closed with no partial config, history or +revision. Model and lineage: [`WALL-THICKNESS.md`](WALL-THICKNESS.md) §1; +migrations and stale-client guard: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md) +(model v8–v10); rationale: [`adr/282-wall-geometry-representation.md`](adr/282-wall-geometry-representation.md). -Read is projection-only. Before any physical-geometry mutation the card builds -a local candidate, canonicalizes coordinates, materialises/updates the wall -catalog, validates references and only then commits one config transaction. -Initial legacy IDs are deterministic so frontend/backend and repeated migrations -converge; genuinely new segments use UUIDs. Split lineage assigns the old ID -to one deterministic child. Ambiguity fails closed with no partial config, -history or revision update. `scripts/mutation-gate.mjs` guards every structural -writer entrance. +Rooms may not partially overlap (lying on a shared wall is legal, a fully nested +island is supported). Merge/Split use **polyclip-ts** (not `polygon-clipping`, +see [`DEVELOPMENT.md`](DEVELOPMENT.md)): Merge accepts a pair only when the union +is one hole-free outline; Split cuts wall-to-wall and the larger part keeps the +room identity (name, area, devices). -Active-chain Undo preserves the complete record of every surviving partition, -including its stable ID; only a genuinely new edge receives a new identity. -Each segment history snapshot carries session-only chain seed IDs. While the -chain is active the snapshot is literal and Undo removes one point; after -finish, Undo/Redo passes that seed scope through the same lossless finalizer so -hidden collinear seams cannot return as durable Optimize debt (#477). -The backend stale-client guard compares only room/compatibility contour geometry -with `wall_segments[]`. Partitions, columns and -explicitly hosted openings own their identity and may be written without a -contour-catalog change, subject to the full schema (#314). +`wallBodiesGeometry()` is the single physical masonry for flat full/static +rendering, 2.5D, paper, clean floor and Glow/sun occlusion. Its exterior shell +comes from the union of room centrelines plus surviving `outer` atoms; junctions +follow the bounded mitre/bevel rules (#249, #271, #272, #275, #288, #302, #309, +#310); independent bodies join through `physicalBodySet()` while raw quads keep +editor identity. The result is a typed component set (`ok`, `degraded-extra`, +`failed-core`; #197, #278): rendering keeps every valid component, mutation +preflight rejects `degraded-extra`, `failed-core` fails dark. It is computed state +only — cached per structural geometry, never written back, never rebuilt by HA +state ticks. Contract: [`WALL-THICKNESS.md`](WALL-THICKNESS.md) §2–§4, §9–§11. -Room-boundary walls remain *derived* from room outlines (`roomEdges`, deduped by -`segKey`), so deleting a room keeps the boundaries its neighbours still -contribute. Two explicitly typed exceptions are stored per space: `partitions` -for independent wall segments (including accepted edges of the active Walls -chain) and `wall_columns` for square/circular columns. -They do not create a room or HA area and never split a room implicitly. Their -physical bodies are unioned with room walls for rendering and light occlusion, -and subtracted from clean room floor area. A finished partition may explicitly -host a door, window, gate or passage; columns may not. +## Markup editor -`cm:0` is valid for contour atoms and partitions. It preserves the -structural axis and stable identity but contributes no masonry body, floor -subtraction, paper, opening tunnel or opening host. `space.zero_wall_style` -selects one policy for all of them: missing/unknown and `dashed` paint a dash -and omit the segment from Glow/sun barriers; `solid` paints one line and adds -the exact axis as a zero-area visibility barrier. The resolver in -`src/zero-walls.ts` is shared by flat/static/isometric presentation and light. -Legacy `open_spans` (or `rooms[].open_to` only when spans are absent) are -read-projected and atomized into `wall_segments[].cm=0` by the structural -migration introduced in v9. Canonical v10 writes remove both deprecated fields -and `room_drafts`; existing `cm:0` receives the same policy regardless of its -provenance. - -Independent linear objects have two deliberate projections. Raw flat-capped -quads preserve source identity for hit/selection/drag/properties/delete/history -and furniture magnet behaviour. `physicalBodySet()` also derives exact -endpoint↔endpoint and endpoint↔line topology, adds bounded mitre/bevel patches -without persisted nodes or segment splits, and exposes the joined geometry to -presentation and physics. Degree-one caps remain flat; an interior X crossing -is only a boolean overlap. The full card caches this structural frame by -space/config geometry, while static cards use a weak server-snapshot cache; -cursor and HA state updates do not repeat the saved O(N²) node search. - -Rooms may not overlap -(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring -walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from -**polyclip-ts** (chosen over `polygon-clipping`, whose ESM build exports only a default while -its types declare named exports — breaking either tsc or the runtime): merge accepts a pair only -when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the -bigger part keeps the room identity (name/area/devices). - -`wallBodiesGeometry()` is the canonical physical masonry for flat full/static -rendering, hidden isometric projection and Glow/sun occlusion. Its exterior -shell is derived from the union of room centrelines plus the surviving `outer` -atomic intervals; internal/shared interval bodies are clipped to that union -before the shell is restored. Consequently a Split edge ending at an exterior -vertex cannot contribute a child-room mitre to the facade. Per-room rings remain -an interior join/nested-room representation, and atomic quads provide a safe -physical interval when an acute child ring cannot be subtracted. Paper and -masonry paths are emitted by that same geometry pass. Computed independent -junction patches enter through the same physical union. A partition-hosted -opening is subtracted from its explicit raw body before that union. It also -cuts a derived room wall only when the wall is exactly collinear and covers -the complete hosted interval; crossing or nearby bodies remain opaque and room -exterior authority remains intact. -Virtual-wall junction patches are computed, scale-relatively normalised below -the geometry epsilon, and unioned one at a time. Each such union is an optional -transaction: a malformed/degenerate patch retains the previous canonical body -and later patches still run. The surrounding structural pass is deliberately -outside that fallback boundary: a core room-body failure remains `failed-core` -and activates fail-dark behaviour. Successful geometry is a typed component -set (`ok` or `degraded-extra`), not one all-or-nothing polygon. Every optional -independent body and the final room-body/exterior-shell merge is transactional; -if both operands are structurally valid but their union fails, the operand is -retained as a separate non-cancelling component. Plan, View, Static, hidden Iso, -paper and light consumers project the same component set. The strict mutation -preflight rejects `degraded-extra`, while read-only rendering preserves all -known-valid masonry without rewriting the saved plan (#197, #278). -The same structural pass builds one scale-relative physical endpoint map for -room profiles, exterior intervals and junction patches (#249). Co-directional -duplicates collapse while opposite rays remain distinct. Each canonical -direction retains the non-dominated finite `(half-depth, length)` supports of -its source intervals, so local reconstruction cannot invent masonry, paper or -an occluder after a real endpoint (#271). At degree 3+ nodes it uses -`H = max(incident half-depth)` and clips excessive overlap to a straight bevel -bounded by `1.25 × H`; degree-2 joins keep the legacy `MITRE_LIMIT = 4`. -The final bevel is applied to canonical masonry after its room/atomic/exterior -union, preventing later boolean inputs from recreating the discarded spike. -Canonical masonry replaces each affected local mask with complete physical ray -strips clipped to the bounded physical paper envelope, not just the room union, -and retains overlap through the approved radius on both sides of the facade. -Only the excessive portion beyond `1.25 × H` is removed. This prevents the -repair from deleting an exterior half-strip into a white T-junction wedge while -still rejecting the old unbounded spike. Paper applies that same bounded cut -before re-unioning the room centre footprint (#261). -The cut's offset faces meet at one point, which is not topological connectivity -for polygon holes. A scale-relative local corridor overlaps both sides of that -tip and the exterior angular sector; it keeps the approved `1.25 × H` endpoints -and acute wall centrelines intact while preventing an enclosed white component. -Room masonry, final masonry and paper use the same connector (#272). -For #275, a pair-level perpendicular classifier marks only rays that have an -orthogonal partner. Their finite physical strips are subtracted from every -effective bevel cut and restored after local boolean work. The protected union -is built once for the structural node map, rather than once per node, because -adjacent repair masks may overlap and a later node pass must retain an earlier -node's material. Non-orthogonal rays keep the bounded #249 cut. The shared -result remains pre-opening geometry: explicit opening slots are subtracted -afterward, and all SVG, paper, clean-floor, Iso and light consumers receive the -same canonical topology. -When a short ray ends inside another replacement window, the endpoint map also -records any finite shared strip attached at that far endpoint (#288). The mask -restores that attached strip in its own direction and depth, clipped to the -local window; it does not turn the strip into another incident ray or scan -unrelated walls. Canonical room masonry remains continuous without undoing the -finite-ray phantom removal from #271. -`wallBodiesGeometry.roomGeom` caches this repaired room masonry before openings -and independent bodies; clean-floor consumers subtract it from each source room -and clip their fallback, so fill cannot escape the building or silently drop a -floor pocket. Full, Static, hidden Iso, room fills/hover and light barriers -therefore observe the same topology, and cached HA/theme ticks do not rebuild -the map. -Before the exterior offset is built, each saved atomic endpoint splits its -containing collinear union edge. Offset changes are explicit butt steps at that -endpoint, including nonzero-to-zero transitions. The topology tolerance starts -in render units and is divided by the current edge length before it is compared -with or used to de-duplicate normalized `t` fractions; this keeps the result -scale-independent and prevents one interval's depth from leaking into its -neighbour. -The full card retains the -pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot -cache guarded by a structural geometry fingerprint. This is computed render -state only: it never rewrites rooms or wall entries, and an HA state tick does -not rebuild topology. - -### Hidden Isometric Stage 2 composition (#122) - -The hidden `iso` View reuses that masonry but has one bounded structural scene, -not a second house model. `_isoGeometryCache` remains an eight-entry LRU keyed -by room/wall/opening geometry (including opening flips), scale/camera, fixed -wall/floor-edge heights and an algorithm revision. Each value holds wall faces, -the room/exterior slab edge, immutable opening jamb bases and the projected -frame. HA state, theme, hover and filter support are presentation inputs and -never enter this key. - -`floorFootprintGeometry()` derives only the union of room floors and exterior -masonry; unlike wall volume, it has no independent partition/column input. -`buildIsoFloorGeometry()` emits visible low faces for outer component rings, -not internal edges or holes. `src/iso-openings.ts` stores jamb/axis topology and -applies `openingAmount()` only during live projection, keeping contact updates -out of the boolean geometry path. - -Composition is shared-viewBox SVG: ambient shadow/floor edge → the existing -affine-projected floor/live scene → wall material and vertical panels → -existing screen-facing HTML overlays. Internal wall-contact and opening-leaf -shadows are deliberately absent. A constant set of gradients and one ambient -filter serves every face. Unsupported decoration or forced colours remove -nuance/ambient shadow without changing projection; only structural failure -uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in -`docs/adr/122-isometric-stage2-composition.md`. - -### Hidden Isometric Stage 4 visual handoff (#570) - -Stage 4 keeps the Stage 2/3 structural scene and fixes the camera authority at -`rotDeg=0`, `tiltDeg=20`, with scale-aware wall height 84. `isoPlaneMatrix()` is -shared by floor content, invisible footprint corners, point projection and -inverse floor hit mapping. The projected frame includes the floor edge, -wall/opening tops and low overlay plane; blur/shadow extents never enter fit. - -`src/iso-overlays.ts` is the pure boundary between floor-bound and low-plane -presentation. Device roots, room-label/card roots and opening-lock roots keep -an immutable floor anchor and receive one computed visual anchor four -scale-aware units above the floor. Their conservative floor-parallel footprint -stays calculation-only for collision and fit; Stage 4 paints no ground dot, -tether, plate or per-marker shadow. The existing screen-facing HTML root remains -the sole hit, focus, tooltip and action target. -Vacuum, lighting, fills, backdrop and decor continue to consume `z=0`. - -Wall collision consumes projected top and visible-side silhouettes produced -once from canonical physical masonry and stored in the eight-entry structural -LRU. The pure resolver uses a four CSS-pixel safety gap and a bounded 48 -CSS-pixel inward search toward a proven owning-room point. Every candidate path -must remain strictly inside that room and outside its island holes. It changes -only the visual anchor. Ownership failure, invalid wall geometry, an owner -boundary or an exhausted cap yields a deterministic unchanged placement and no -write. Room labels use their room, device markers prefer a valid explicit room -and otherwise the smallest strictly containing room, and lock badges inherit -the physical room side selected by opening-host geometry rather than -re-inferring ownership from their offset point. - -After individual wall correction, one deterministic group pass separates the -full screen-space roots of devices and lock badges within the same absolute 48 -CSS-pixel cap. A bounded spatial grid avoids all-pairs scans; stable required -displacement plus kind/id controls priority independently of HA registry order. -Candidate generation is boundary-driven (#585): it starts with only the roots -that actually overlap, adds newly encountered roots iteratively, and evaluates -the origin projections and intersections of their expanded one-dimensional -boundaries on the integer CSS-pixel lattice. Structural bounds and the 48 px rim -are bounded fallback events, while the existing exact room-path, wall-silhouette -and footprint predicates remain authoritative. This finds sub-4 px legal slits -without either the lossy 4 px lattice or the former 7238-point disk scan. -Room labels are excluded and remain below interactive roots. An impossible -layout keeps every root and reports stable residual pairs rather than hiding, -shrinking or moving an item across its owning-room boundary. Live placement is -memoized by immutable geometry/footprint signatures; fit probes reserve the -maximum envelope and skip this pass. - -Stage 4 opening bases retain state-independent full-depth reveals and matte -door/gate leaf thickness. Windows use a 0.38H..1.00H fixed frame, a -0.40H..0.98H sash and 0.45H..0.93H glass with neutral rails and separate blue -side/top glass materials. Live -`openingAmount()` still projects leaves after the LRU hit. Passage has no -decorative volume. Derived door/gate leaves pivot on the selected physical host -face while the saved/Flat axis remains unchanged. The shared wall/opening -painter queue retains its global screen-depth slots and reorders only one -opening's existing slots by physical camera depth, preventing a rear sill from -covering elevated glass or a rotating prism from inverting its faces. Door and -gate faces have no stroke; window frame/glass borders remain. A bounded set of -shared material definitions textures only generated 2.5D surfaces and uses one -fixed visual-light vector for the single building ambient shadow; theme, HA -Sun, hover/focus and filter capability remain presentation-only inputs. - -The DOM exposes fail-closed evidence without becoming public API: -`.stage[data-hp-iso-stage="4"]` carries the structural build counter, low-plane -interactive roots identify their overlay kind/raised/nudged state, and shared -material definitions carry `data-hp-iso-material-def`. With borders disabled, -raised roots and Stage 2/3/4 volume are absent while the true affine floor -matrix remains active. Forced colours or missing filters strip texture and -soft shadows only; topology/projection exceptions alone enter the established -Flat fallback latch. Historical Stage 3 decisions remain in -`docs/adr/160-isometric-stage3-overlays.md`; the current reviewed contract and -designer handoff are attached to issue #570. - -## Markup editor (v1.4.0+) - -State inside the card: `_markup` (mode), `_tool` (draw/column/merge/split/resize/opening/ -stairs/wallthick/delroom), `_path` (the current outline, -vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed -= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and -rectangles are rendered uniformly (hit-test: point-in-polygon / rect). - -All committed plan-geometry mutations enter one named 50-command Undo/Redo stack. Ctrl+Z, -Ctrl+Shift+Z/Ctrl+Y and the toolbar buttons use the same stack; a new mutation after Undo drops -the redo branch. The local stack survives the server echo of its own writes, but is cleared when -a newer external config revision is adopted. Positional placement is always quantized to the plan -grid. Shift may alter a gesture's geometry (square/circle creation, independent -resize axes or free rotation), but it cannot create off-grid coordinates. - -Room Resize (#277) is a fixed-topology wall move, not a general polygon -transform. `resolveSafeResize` admits one axis-aligned edge of one room or one -exact endpoint-to-endpoint pair of two rooms. `applySafeResize` moves only the -two existing endpoint vertices in those rooms; partial shared boundaries, -diagonals, physical duplicates and third-room cascades remain visible disabled -handles. `clampSafeResize` explores grid deltas contiguously from zero and -memoizes exact checks in a weak, per-plan, 4096-entry cache, so an irregular -pair stops at its first corner and cannot jump through it. - -`src/resize-controller.ts` is the sole owner of Resize selection, gesture, -accepted preview, live labels and eligibility-cache state. The card remains a -DOM/render/persistence adapter: it supplies immutable snapshots and pure -callbacks, then applies only the controller's accepted commit result. The -controller rebuilds every live candidate from one immutable -`SpaceGeometryState`. `rekeyWallsAfterMove()` maps exact wall-owned records -into that overlay; -partitions, drafts, columns, decor and plan transform stay byte-equivalent. -Wall rekey has a production-only fixed-topology mode: rigid moving edges -translate all breakpoints, while length-changing side edges move only proven -old-vertex → new-vertex endpoints. Before the overlay is accepted, the union of -collinear room/partition carriers must cover every new exact wall record and no -new lattice/carrier violation may appear. Historical invalid records are -compared through the shared production helper in -`src/wall-record-preservation.ts`, rather than repaired during an unrelated -Resize. The controller uses exact multiplicity for every finite centimetre -value, including `cm: 0`; the CLI migration/invariant adapter keeps its -historical positive-value presence check. -The renderer's canonical wall/floor result for the final preview cfg epoch is -the pointerup preflight result. Success copies that exact overlay once and -records one Undo/save; there is no commit-time simplify/degrade/reconstruction. -Failure or cancellation writes nothing. Historical partial-shared and corner -scale helpers remain pure-test history only and are tree-shaken from the -production interaction path. Exact `a/b` wall endpoints remain identity and -the quantised midpoint/direction `key` remains only a compatibility index. - -Live measurement layout is isolated in pure `src/resize-labels.ts` (#300). -The controller supplies the accepted candidate, current view, cached stage -size and the room gear's `iconCqw()`-derived footprint. It produces exactly two -side-wall highlights/lengths plus one area/leader per affected room. The SVG -ink sits above wall bodies and below openings/handles; HTML labels are -pointer-inert. No `getBoundingClientRect()` enters the pointer path. - -Near-axis geometry has one shared classifier in `src/near-axis.ts` (#290). -Walls applies it after architectural/grid resolution and before hover/commit, -moving only the free endpoint. Resize validates that its fixed-topology output -contains no near-axis edge. Explicit Optimize runs the lossy legacy repair only -after grid alignment, moves coincident room endpoint owners atomically, then -reuses ordinary opening projection and wall rekeying. Unique physical -count, maximum centimetres and skipped candidates stay separate from ordinary -grid movement; no load/save migration invokes this repair. - -`normalizeWallIntervals()` compacts atomic real-wall intervals only when both -their centimetre thickness and ownership signature match (#299). The signature -is `outer(A)` or the stable sorted pair `shared(A,B)`; an outer/shared transition, -a change of shared pair, or ambiguous multi-owner geometry is a hard breakpoint. -Explicit Optimize and the room-deletion transaction call this same normalizer, -so neither path can create one saved record whose physical role changes halfway -through its exact span. Ambiguous ownership fails closed per atom. - -All physical-geometry writers share the same transaction boundary (#278). -`checkSpacePhysicalGeometry()` validates the exact candidate through canonical -wall and floor builders before history or save. A failed or degraded candidate -restores the immutable pre-edit state, creates no Undo entry and sends no -WebSocket write. A physical fingerprint is checked again at the deferred write -boundary so a stale success cannot approve a newer candidate. Marker, title, -colour and other presentation edits bypass this structural check, allowing an -old degraded plan to be exported or corrected without a background migration. - -`reconcileCoincidentPartitions()` is the shared structural canonicalizer -(#276/#296/#477). Full-space use remains an explicit Optimize operation; the -current wall-chain writer invokes it only at finish and only for the surviving -seed component it just authored. It consumes canonical room-wall intervals and the -partition-opening compatibility resolver; it does not implement a second -nearest-wall model. A source axis is atomized at solid interval and opening -boundaries. Exact one-owner outer or two-owner shared spans may be absorbed; -ambiguous spans are recombined into deterministic residual partitions and keep -their hosted openings. Converted openings are materialised onto ordinary room -walls, and `max(roomCm, partitionCm)` keeps the original centred physical union -envelope. Unknown partition semantics, gaps, overlapping openings and adjacent -independent bodies fail closed. The candidate then crosses the existing whole-plan -geometry preflight and one atomic Optimize write/Undo boundary. Render and -unrelated ordinary save paths never invoke this pass, so `PLAN_MODEL_VERSION` -remains unchanged. -`OptimizeDependencies` is a narrow test/benchmark seam: production uses the -real helper, while the committed large-house benchmark substitutes a no-op to -measure only this pass and the unit contract instruments its exact per-space -call count. A source-ownership assertion fails if a render/pointer module ever -imports the helper. - -There is no separate Boundary tool or virtual-wall session. A wall chain and -the Thickness editor both accept `0..100 cm`; an exact zero remains a normal -stable wall carrier. Transitioning a positive hosted segment to zero is -rejected atomically while any opening uses that target. Hit widths and junction -ambiguity are still measured in CSS pixels and converted through the live -viewBox, so the editable target does not collapse to the visual one-pixel line. - -Every completed Walls segment is persisted immediately as an ordinary -`partition`, including the thickness selected when that segment was placed. -The ordered path, chain id and participating partition ids are session-only. -Changing Plan tool, editor or floor finishes an open chain through one bounded -lossless finalizer before clearing that session state. The finalizer repeatedly -merges only the seed-connected compatible collinear run, rehosts its openings, -and reconciles only surviving positive seed partitions proven coincident with -room masonry. Its cloned candidate crosses the current-model identity barrier, -one local physical/junction proof and storage canonicalization before atomic -adoption; rejection keeps the visible chain and original config. It adds no -history command and never sweeps unrelated legacy debt. Esc, Reset, route/hash -departure and a rejected-all room-face batch share this owner (#477). -Pan, pinch, pointer cancellation and suppressed clicks never finish a chain or -append a segment. A finished open chain is ordinary masonry and is not resumed -after reload or remount. - -The intermediate click write has a deliberately narrower proof boundary -(#461). On an already materialised model-v10 document, -`commitWallChainSegmentGeometry()` first proves an exact one-partition append, -then builds matching previous/candidate projections containing the new -segment, incident junction rays, its collinear run and only physical envelopes -that can interact with that component. It runs the normal production physical -check once on that local candidate and passes the resulting wall geometry into -the junction check instead of rebuilding the union. Unknown or mixed writes and -pre-v10 documents fall back to `_commitPhysicalGeometry()`. Room creation from -a closed chain always uses an independent full-space barrier; the local -verdict is never a terminal approval. Both routes share the existing history, -pending-write and backend-rejection rollback contract and still send the full -configuration. - -`src/wall-face-graph.ts` derives an immutable planar graph from solid room edges -and partitions, including the active chain. A sweep -broadphase atomizes endpoint, T, X and collinear intersections; deterministic -half-edge traversal extracts bounded canonical faces. The click handler diffs -the graph before/after the latest segment and offers only newly created faces -that contain an atom of that segment, ordered by area and canonical key. Exact -or partial overlap with a room is rejected while legal nesting is preserved. -A clean single-room divider reuses `splitRoomPath`, offering only the smaller -child while the larger child retains the original room identity and metadata. - -An idle Walls click also queries the smallest exact unoccupied bounded face at -the raw point; boundary/snap hits and desktop `Shift+click` remain drawing -gestures. If no exact face exists, `src/wall-face-repair.ts` may plan one -endpoint→endpoint or endpoint→solid-line move no longer than 2 physical cm. -Room vertices are never movers, multiple valid repairs fail closed, and the -immutable proposal is revalidated against current source/target geometry before -it is applied. The move and room are one history/config transaction; rejecting -or cancelling the room never applies the proposal. - -Room answers are buffered in `_wallFaceBatch`. Create/Keep-as-walls advance the -queue without mutating geometry; Cancel/Esc restores the terminal draft. The -last answer revalidates every face and capacity limit, then commits all accepted -rooms, the split result and every unconsumed active atom in one Undo/Redo and -config transaction. Existing saved source geometry is never atomized or -rewritten merely because it participated in a face. `column` still creates a -physical object whose size comes from the current Thickness field. The legacy -root `space.segments` array is stripped on every save. - -Room deletion is likewise planned before mutation. `src/room-deletion.ts` -classifies the selected room's atomic outer/shared/open intervals and its -unhosted openings. Keep-walls materializes only exclusive positive solid -intervals as partitions (reusing exact compatible masonry) and rehosts their -openings. Delete-walls cascades only those exclusive openings. Shared walls, -explicit partitions and partition-hosted openings survive. The selected room, -wall profile/open spans, partitions and openings commit in one named geometry -transaction through an accessible `hp-dialog`, never native `confirm()`. -The same command rewrites room references in every marker: direct `room_id` is -deleted (or remapped to the survivor for Merge), and exact values in cross-space -`vacuum.segment_map` are deleted/remapped. History snapshots only these room -fields, so Undo/Redo and write rollback are atomic with geometry while unrelated -marker and vacuum fields remain live (#477). - -While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/ -`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is -per-space `cell_cm` — canonical centimetres represented by one grid cell; new -spaces use 1 cm or 2.54 cm/1 inch, while missing legacy values fall back to 5 cm. +Card state: `_mode`, `_tool` (`select|draw|column|merge|split|resize|opening| +stairs|wallthick|delroom`), session `_path` on the `GRID_N = 240` lattice +(`_snap`, `CANVAS.md` §9). Committed geometry enters the named 50-command +Undo/Redo stack shared with Background (`DECOR-EDITOR.md` §6, §9); it survives +the echo of its own writes and clears on a newer external revision. Every +physical-geometry writer crosses one fail-closed boundary (#278, +`checkSpacePhysicalGeometry()`) before history/save, rechecked at the deferred +write; presentation edits bypass it. Contracts: Walls chain, faces, room +deletion, partitions — `WALL-THICKNESS.md` §6, §9–11, `CONFIG-COMPATIBILITY.md` +(#478, #461), `TOUCH-SUPPORT.md`; Resize (#277, #300) — `RESIZE.md`; near-axis +(#290, `src/near-axis.ts`) — `CANVAS.md` §9.3. `reconcileCoincidentPartitions` +(#276/#296/#477) and `normalizeWallIntervals` (#299) run plan-wide only in +explicit Optimize, locally in chain finish/room deletion, never in render or +ordinary saves. Saves strip the legacy root `space.segments`. ### Stairs (#663) -Stairs are a separate Plan entity, not decor. The lazy `StairEditorRuntime` -owns the straight/spiral tool group, drag-to-draw placement, selection, -continuous move/resize/rotation, edge magnets and properties, and renders the -selection frame that the root card places in its top overlay above wall bodies -(#676). The eager `StairViewRuntime` owns only read-only symbols, the hover -tooltip, guarded navigation and gesture suppression, so opening a plan does not -load the editor graph. The root card owns lifecycle composition, shared Plan -history/persistence and stage pointer terminals. `stairs.ts` is the pure -boundary for the discriminated model, exact 30 cm tread geometry, cached render -projection and footprint containment; `stairs-editor-model.ts` keeps the small -eager-safe helpers (defaults, kind conversion, target state, stair-to-stair -snapping) the View runtime shares, while `stairs-box.ts` holds the editor-only -oriented-box transforms — drawing, resizing about the anchor, wall-face edge -magnets with the outward faces of physical bodies, handle bearings and cursors, -dialog unit conversion — imported only by the lazy editor chunk. - -`spaces[].stairs[]` is optional and capped at 250 records. A record exists on -one space only; `target_space_id` is a one-way navigation reference and never -creates target geometry. Straight footprints are rotated rectangles; spiral -footprints are circles. `clean-floor.ts` subtracts their geometric overlap -from the same clean-area result used by room cards and summary metrics, without -cutting the painted floor or changing walls/light/vacuum. PDF and full/static -renderers consume the same stair geometry. The 2.5D scene keeps the SVG object -on its floor plane with no height or shadow. See `docs/STAIRS.md`. +Separate Plan entity, not decor (`STAIRS.md`). Eager `StairViewRuntime`: +symbols, tooltip, guarded navigation; lazy `StairEditorRuntime`: drawing, +transforms, magnets, properties (a plan never loads the editor graph). The root +card keeps lifecycle, shared history/persistence and stage pointer terminals. ## Editor chrome and contextual controls -Every editor uses one stable primary `.editbar`. Its `.editbar-tools` contains -only persistent tools and Undo/Redo; `.editbar-end` is a separate pinned end -cap for Close. Selection, operation and tool-state changes must not insert -controls into this measured row. +One stable `.editbar` per editor: `.editbar-tools` (persistent tools, +Undo/Redo) and pinned `.editbar-end` (Close); transient controls never enter +this measured row. Selection actions, tool parameters, hints, palettes and the +approved groups (Opening, Stairs) come from one `EditorSecondaryModel` in the +single light-DOM `.editor-secondary-host` inside `.stage` +(`editor-secondary.ts`): outside the `_hdrH` measurement, pointer events only +on its visible surface, empty in the Device editor (future marker quick +actions go there). Mutating actions revalidate a deterministic `contextId`; +`Delete`/`Backspace` never fall through from it. Product rule: `UX-MODES.md`. -Transient UI is resolved into one `EditorSecondaryModel` and rendered by the -single `.editor-secondary-host` inside `.stage`. Generic state, group -navigation, focus/animation lifecycle, outside-dismiss handling and the stable -light-DOM template live in `editor-secondary.ts`; its CSS is isolated in -`editor-secondary.styles.ts`. The root card only builds Plan/Decor models and -supplies typed product callbacks. Keeping the existing light DOM is deliberate: -layout selectors, focus queries and browser-smoke hooks remain unchanged. +## Doors, windows, gates & passages -The host is absolutely positioned, has pointer events only on its visible -surface and is outside the header/`_hdrH` measurement boundary. Plan selection -actions, drawing thickness and operation hints, Background selection/style -actions and the furniture palette all use this surface. The Device editor uses -the same empty host and must route future marker quick actions through it. - -Every mutating secondary action captures a deterministic `contextId` and -revalidates it before invocation, so a callback from an old selection cannot -modify a newer target. `Delete`/`Backspace` do not fall through while focus is -inside any secondary surface. The same host also implements an explicit -second-level `EditorToolbarGroup` contract (launcher, one open group, keyboard -navigation, focus restoration and outside-dismiss consumption), but no current -tools are grouped without a separate product decision. The change is UI-only: -plan/config models and geometry commands are unchanged. - -## Doors, windows, gates & passages (v1.23.0+) - -`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, -a length and one wall, while markers are free points whose positions live in -the layout store. Model: -`{id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}`. -Room-wall openings omit `host` and retain the absolute-coordinate association. -An independent-wall opening stores -`host:{kind:'partition',id,t}`; the stable id and normalized position `t` are -authoritative, while `x/y/angle` are an atomically refreshed compatibility -projection. No explicit host ever falls back to a nearest wall. - -Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a -quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall. Flat, -preview and Static keep the visible group centred across wall depth; their shared pure placement -helper returns an exact zero translation for every type and `flip_v` value. The derived 2.5D -door/gate volume instead pivots on the selected physical host face so its prism does not begin -inside masonry; this never changes saved coordinates or the Flat symbol. Windows remain centred -through the reveal. `flip_v` changes door/window direction or the gate turn and selects the -corresponding 2.5D host face. Windows are two casement leaves. -A gate has the same data/light/contact/lock semantics as a door, but -uses two centred half-width leaves opening only 10° toward the selected face and no large swing arc. -Its default width in the editor is 300 cm. `openingAmount` (pure) maps the contact state to -0..1: no sensor → door/gate drawn open / window closed (static-plan convention); -`unavailable`/`unknown` freeze that default. The lock renders as a compact -package-derived shell/core HTML padlock badge (`.oplock`) in the device layer, -with theme-aware locked/unlocked/unknown states; a lock is -**never** toggled from the plan (`resolveToggleIntent` returns a secure no-op). View-mode UX: hover outline, -drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer), -double click → properties dialog. In markup mode the "Opening" tool handles clicks instead. - -Contact and lock are exact HA references owned by the opening, not aliases of -standalone markers. Their candidate/action path follows HA binding status while -their render path follows the frozen active-registry projection; neither path -consults marker tombstones. For that projection, the presence of an exact state -is sufficient: registry-less YAML entities have no row, while explicit -disabled/orphan rows have already been stripped together with their states. -The render helper must never receive raw live hass. Plan-level consumers keep -the tombstone policy described above. - -For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall -interval and adjacent room on each side of the centreline. Opening symbols, -wall cuts and room-coloured tunnel patches all consume this association; none -has a separate nearest-wall fallback. A candidate must be genuinely adjacent -to the opening axis, so a detached parallel room inside one grid cell cannot -own the far half. Full-width coverage, signed inner-face distance, room area -and stable room id form the deterministic tie order. - -The full card caches that index and the batch tunnel geometry by space, -`_cfgEpoch` and complete room/wall/opening geometry. A normal HA state tick -therefore resolves only live room fill values, not `roomWallProfile` again. -The batch helper removes already-painted intervals from later overlapping -openings, preventing double alpha. A base patch beneath Glow/sun repeats the -same frame-local effective fill as the room shape. Outer openings give the one -room both halves; shared openings use a local-coordinate hard stop at `y=0`. -Virtual spans and zero-thickness walls are ignored; legacy -spans are clipped per atomic body. - -The same resolved host drives placement, symbol face, full-depth partition cut, -static/hidden-isometric rendering, Glow and edit operations. Rigid host drag -keeps `t` and updates every materialized projection in one history command. -Hosted openings have two deliberate validation policies: render/read consumers -use the historical zero-margin resolver, while creation and direct geometry -edits use a strict resolver that reserves `wallCmToUnits(partition.cm) / 2` at -each endpoint. The backend repeats that physical boundary as semantic delta -validation; rigid partition translation and unrelated writes therefore keep a -legacy near-end opening losslessly, while host/position/length/span/thickness -changes opt it into the strict rule. -Deleting a host with openings requires an explicit cascade dialog; an invalid -host fails dark and is visible only as a rebind diagnostic in Plan. Structural -room-face topology deliberately keeps every valid wall axis continuous through -all opening types (#185). Zero-thickness axes remain graph edges; whether they -transmit light is the separate `zero_wall_style` policy. +`space.openings[]` is plan geometry, not markers: `{id, type: +door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, +flip_h?, flip_v?}`. `host` `{kind: wall|partition, id, t}` is authoritative and +`x/y/angle` its atomically refreshed projection; an absent host is the legacy +room-wall association, and no host ever falls back to a nearest wall +(`CONFIG-COMPATIBILITY.md` › #132/#157). One `OpeningWallIndex` feeds symbol, +cut, tunnel fill, Glow and the 2.5D face (`WALL-THICKNESS.md` §3–4, +`ISOMETRIC.md`, `LIGHT.md`). The Flat symbol follows easy-floorplan (MIT). +`openingAmount()` maps contact state to 0..1 — no sensor: door/gate open, +window closed; `unknown`/`unavailable` keep that default. Contact and lock are +opening-owned exact references: candidates follow HA binding status, render +reads the frozen active-registry projection (never raw `hass`), neither +consults marker tombstones. The `.oplock` badge never toggles a lock +(`resolveToggleIntent` → no-op, `SCOPE.md`); openings are edited only in Plan. ## Integration WS API -`may_write` is the single writer policy for WebSocket and HTTP: administrators -always write; with `admin_only=true` nobody else writes; with -`admin_only=false` ordinary non-admin groups write while -`system-read-only` remains denied. Missing or incomplete user/group data fails -closed. Read ACL is intentionally different: authenticated View receives the -complete config and layout without per-entity projection. The maintenance -catalogs `plans/list` and `assets/list` are writer-only; `trail/get` keeps the -coordinates needed by View but projects every `source` entity ID out of the -response without mutating the recorder store. `virtual_light/toggle` remains an -authenticated View action, not a writer operation. +`auth.may_write()` is the single writer policy for WS and HTTP: administrators +always write; `admin_only` (default `true` when unset) restricts writing to +them; with `admin_only: false` other users write unless they belong to +`system-read-only`. A missing entry or incomplete group data fails closed. +Reads are deliberately broader: every authenticated user receives the complete +config/layout (no per-entity projection), trails without `source` entity ids +(#626) and may toggle virtual lights. **W** = writer-only (else +`unauthorized`); runtime-backed commands answer `not_ready` before setup. -| Command | Parameters | Response | +| `houseplan/…` | Parameters | Result · domain errors | |---|---|---| -| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` | -| `houseplan/layout/set` | `layout`, `expected_rev?` (omission only at `rev=0` bootstrap) | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` | -| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` | -| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}, decor_assets_api?}` (runtime capabilities are optional for rolling compatibility) | -| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` | -| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs and raw robot coords, with internal `source` entity IDs removed | -| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion | -| `houseplan/config/set` | `config`, `expected_rev` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` | -| `houseplan/plan/optimize` | `config`, `layout`, both expected revisions | crash-resumable two-store commit + one-deep backup | -| `houseplan/plan/optimize_undo` | both expected revisions | restores backup only before any later edit | -| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `..`, deletes nothing | -| `houseplan/plans/list` | — | writer-only `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) | -| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` | -| `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` | -| `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` | -| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move | -| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection | -| `houseplan/assets/list` | — | reusable image metadata plus authoritative `used_by` references | -| `houseplan/assets/resolve` | `asset_ids[]` (max 200) | verified metadata/content paths plus missing ids; writer: catalog, read-only: saved references only | -| `houseplan/assets/delete` | `asset_id` | explicit exact-id deletion of sidecar and allowed-extension blobs, only when no decor record refers to it | -| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for ``/`` fetches | -| `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space | -| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions | -| `houseplan/import/apply` | token, both expected revisions, content confirmation | crash-resumable paired config/layout commit; full import gets one-deep undo | +| `config/get` | `space_id?`, `fields?`, `marker_fields?` (#256; project only the document) | `{config, rev, virtual_lights:{rev,config_rev,off[]}, can_write, can_optimize_undo, undo_kind, integration_version, support_api, decor_assets_api, summary_panel_api, radar_stage1_api?}` | +| `config/set` **W** | `config`, `expected_rev` | `{ok, rev}` · `conflict`, `too_large` (2 MiB), `invalid_format`, `missing_plan`, semantic codes below | +| `layout/get` | `space_id?` | `{layout:{id:{x,y,s?}}, rev, can_optimize_undo, undo_kind}` | +| `layout/set` **W** | `layout`, `expected_rev` | `{ok, rev}` · `conflict` (external clients; the card writes points) | +| `layout/update` **W** | `device_id`, `pos` | `{ok, rev, ignored?: removed\|missing_virtual}` — a tombstoned owner's late drag is acknowledged, not stored | +| `layout/delete` **W** | `device_id` | `{ok, rev}` (`rev: null` when nothing was stored) | +| `space/delete` **W** | `space_id`, `expected_config_rev`, `expected_layout_rev` | `{ok, config_rev, layout_rev, removed_layout}` · `space_in_use`, `space_not_found`, `invalid_space_id` | +| `plan/optimize` **W** | `config`, `layout`, both expected revs | `{ok, config_rev, layout_rev, can_undo}` — also the server-side wall-model migration barrier | +| `plan/optimize_undo` **W** | both expected revs | restores the one-deep Optimize/full-import backup · `no_backup` after any later edit | +| `geometry/repair` **W** | `space_id`, `aspect`, `dry_run?`, `undo?`, `expected_rev?` | manual re-transform of one space's positions (HP-1500-01) with one-deep `repair_backup` · `nothing_to_repair`, `no_backup` | +| `export/create` **W** | `kind: full\|space`, `space_id?`, `plan_only?`, `card_version` | `{document, filename}` | +| `import/revalidate` **W** | `token`, `duplicate_policy?` | refreshed preview and current revisions | +| `import/apply` **W** | `token`, both expected revs, `duplicate_policy?`, `confirm_missing_content?` | paired commit; full import gets one-deep undo · `conflict`, `preview_expired`, `content_confirmation_required`, `missing_plan`, `missing_content` | +| `virtual_light/toggle` | `marker_id` | `{marker_id, on, rev}` · `not_toggleable` | +| `trail/get` / `trail/delete` **W** | — / `marker_id` | `{trails:{marker:{current,previous}}}` / `{ok, removed}` | +| `plans/list` **W** / `plans/delete` **W** | — / `name` | newest 60 `{name,url,size,modified,used_by}` + `total` / `{ok, removed}` · `in_use`, `invalid_name` | +| `plan/set` **W** | `space_id`, `ext`, `data` (base64) | `{ok, url}` — kept only for pre-#617 cards; current cards use HTTP | +| `files/migrate` **W** | `from_id`, `to_id` | `{ok, mapping, copied}` — copies, never moves or overwrites | +| `files/cleanup` **W** | `marker_id` | `{ok, removed, kept}` — removes only files the stored config does not reference | +| `assets/list` **W** / `assets/delete` **W** | — / `asset_id` | catalog with authoritative `used_by` / `{ok, removed}` · `in_use` | +| `assets/resolve` | `asset_ids[]` (≤200) | `{assets, missing}`; non-writers resolve only ids the saved config uses | +| `content/sign` | `paths[]` | `{urls}` — 24 h `authSig`, only `/api/houseplan/content/…`, first 200 paths | +| `support/*` **W**, `radar/*` | — | `SUPPORT-PRIVACY.md`, `RADAR.md` | -`config/set.expected_rev` is semantically mandatory once a document exists. -The wire schema permits omission only for the first empty-store bootstrap at -revision zero, so the endpoint can return the stable `conflict` domain error -instead of a generic format error. A revision-less write over `rev > 0` is -rejected under the same `write_lock` before validation, no-op detection, -backup cleanup, file collection or update events (#340). The same rule holds -for `layout/set` (#356). External writers (scripts, automations, custom -integrations) must therefore follow the read-then-write cycle the card uses: -call `houseplan/config/get` (or `layout/get`), keep the returned `rev`, and -send it back as `expected_rev`; a `conflict` answer means the document moved — -re-read and retry with the fresh revision (#368). +Semantic write codes: `invalid_config`, `invalid_passage_fields`, +`invalid_partition_opening_host`, `invalid_partition_opening_jamb_margin`, +`wall_model_client_outdated`, `wall_model_migration_blocked` (Optimize), +`junction_limit_`, `invalid_radar`, `invalid_light_entity`, +`invalid_vacuum_map_route`, `*marker_control*`, `invalid_value_badge*`; paired +writers add `commit_failed`. Events: `houseplan_config_updated`, +`houseplan_layout_updated` (`{rev}`), `houseplan_virtual_light_updated`, +`houseplan_trail_updated`. -The normal frontend reaches `houseplan/plan/optimize` only after the exact -preview candidate passes `src/plan-geometry-preflight.ts`. That pure barrier -uses the same room/open-span/ordinary+hosted-opening projection, -`physicalBodyParts`, `wallBodiesGeometry` and `floorFootprintGeometry` as the -renderer for every space. The dialog retains statuses and a config fingerprint, -not polygon output or exception text; a mismatch before Apply triggers a fresh -check. A red result means zero WS calls. Python deliberately does not duplicate -`polyclip-ts`: the endpoint remains the independent permission/schema/revision -and crash-resumable atomicity boundary, not a consumer-supplied preflight -attestation. +HTTP views (`requires_auth`, same policy): `POST /api/houseplan/upload` +(marker attachment), `/plans/upload` (#617), `/assets/upload` +(`DECOR-EDITOR.md` §7), `/import/preview` and `GET +/api/houseplan/content/{kind}/{sub}/{name}` (`nosniff`; SVG only gets a +`sandbox` CSP, HP-1454-01). Only the manifest-gated bundle under +`/houseplan_files/` is public; legacy `/houseplan_files/plans|files` URLs are +still recognised in stored config but no longer served +(`CONFIG-COMPATIBILITY.md` › Legacy content URLs). -`src/space-reference-repair.ts` keeps orphan-layout classification pure. The -card builds a runtime-only owner roster from the complete HA device/entity -registries, current states and config names, and marks absence authoritative -only after the registry load succeeds. The repair pass may then distinguish a -proven-absent room label/device/group position from a live owner in a deleted -space and from an unverified future or registry-limited owner. The first enters -the default candidate, the second only an explicit secondary opt-in, and the -third never a destructive candidate. No registry data or classification status -is persisted; Apply still sends only the exact ordinary config/layout pair that -was previewed. +**Invariants** -Manual attachments upload over HTTP (streaming, transactional staging), not WS — -the old `houseplan/file/set` was removed in v1.10.0. A usable -`Content-Length` is checked conservatively against the hard limit, aggregate -quota and free-space floor before multipart streaming begins; chunked uploads -retain the streaming cap. The exact staged size is checked again under the -runtime `upload_lock` immediately before promotion. Decor-image decoding is -also serialized by that lock so compressed images cannot multiply peak Pillow -memory across concurrent requests. +- *Optimistic locking.* Each config/layout writer takes `write_lock`, resolves + a pending pair, then checks revisions before validation, no-op detection or + file collection. `expected_rev` may be omitted only at `rev = 0` (#340, + #356). External writers read `rev` via `config/get`/`layout/get`, send it as + `expected_rev`, and on `conflict` re-read and retry (#368). A canonical no-op + keeps revision, events and the maintenance backup. +- *Paired writes* (Optimize, Optimize Undo, full import, space delete) use the + durable `optimize_pending` intent and one-deep `optimize_backup` + (`kind: optimize|import`) — `CONFIG-COMPATIBILITY.md` › #491. Events fire only + after both halves are durable; Store exceptions are resolved by reloading + and comparing exact payloads; layout-store writes go through + `async_save_layout_state` so unknown metadata survives. +- *Validation is the server's.* Schema/semantic checks run in the executor + under the lock; the browser never parses an import. Frontend preflight + (`src/plan-geometry-preflight.ts`) only avoids doomed calls. +- *Files* (`SCOPE.md` › Standing rule): uploads claim a fresh name + (`reserve_filename`, `O_EXCL`), plans are copy-on-write + `..` (a space id cannot contain `.`), and `check_quota` + bounds bytes/files/free disk at upload. `config/set` collects, under its + lock, only what its own commit replaced; a daily pass runs the collectors + with the stored config on both sides and sweeps `.upload-` temporaries. A + newly referenced internal plan must exist (`missing_plan`; import also checks + attachments). A usable `Content-Length` is checked before streaming, the + staged size again under `upload_lock`, which also serialises image decoding. +- *Import preview* streams ≤8 MiB, rejects duplicate/prototype keys, + non-finite numbers and future model versions, and keeps the candidate in + memory for 10 min behind a token bound to the user, candidate digest and + both revisions (global and per-user caps). *Export* deep-copies one coherent + pair under the lock and builds outside it. +- *Virtual-light state* lives in its own Store (`CONFIG-COMPATIBILITY.md`); + toggles reply immediately and coalesce into one delayed durable write, + flushed before config transitions and unload. A cached snapshot never + authorizes an optimistic toggle. -Manual virtual-light state is operational data, not plan configuration. The -integration owns a separate versioned `houseplan.virtual_lights` Store whose -bounded payload contains only `{rev, config_rev, off[]}`. The existing shared -write lock serializes config reconciliation and atomic toggles. Eligibility is -always recalculated from server config; the toggle command accepts no desired -state, entity id or service. It is intentionally available to every -authenticated View user, while config writers remain governed by `may_write`. -The runtime revision, reply and event are immediate; rapid toggles are -coalesced into one delayed durable write of the latest state. Config -transitions and integration unload flush pending state before continuing. A -config-revision gap from an older writer clears manual off bits to the -compatibility default `on`. +**Client side** -The first `config/get` frame carries the coherent operational snapshot. Full -cards subscribe directly to the update event; all `houseplan-space-card` -instances share the module-level config cache and one subscription. Local -storage may retain the last snapshot for continuity, but never authorizes an -optimistic toggle. The data is excluded from marker/layout schemas, portable -export/import and the HA entity registry. +- `_writeConfig()` keeps one `config/set` in flight, each carrying the previous + reply's revision (HP-1454-03). A rejected physical transaction restores the + earliest server-backed snapshot of every affected space and reloads (#314). +- `src/config-adoption.ts` (#500) owns config/layout body + revision + + fingerprint. It changes only by authoritative adoption + (`adoptAuthoritativeGated`, profiles `reload`/`post-write`), own-write + acceptance (`acceptConfigWrite`, `acceptPairWrite`) or warm-cache restore; + paired writers take revisions from the re-read. Local staging is limited to + files pinned by `test/config-adoption-ownership.test.mjs`. Ordinary debounced + editor saves remain optimistic: a rejection that is neither a revision + conflict (which reloads) nor a physical-geometry rollback keeps the local edit + with a failure toast until the next authoritative reload. Writers that must + undo on rejection capture an `OptimisticAttempt` + (`beginOptimistic`/`rollbackOptimistic`), which restores the server-backed + body only while that failed candidate is still current (#442, #500). +- `src/config-reload-authority.ts` (#543): each reload holds a generation-scoped + claim; a superseded one ends with no side effect. +- `ContentSigner` (`src/signing.ts`) is the only signer for both cards: + `MAX_SIGN_PATHS` (200) is shared with `const.py`; the cache is age-aware and + pruned to live URLs; queued/in-flight are distinct, failures back off and + in-flight entries expire after `SIGN_INFLIGHT_MS`. +- Room climate is one `roomClimateMap()` pass per hass snapshot (#317) shared + by full and static cards; exact `entity:` placement beats its parent + `device:` (no double vote); never call the `areaClimate()` wrapper in render. +- Load, continuity and fixed-floor rules: `WARM-REMOUNT.md` § 5. -Portable import preview uses authenticated -`POST /api/houseplan/import/preview`. The endpoint streams at most 8 MiB, -strictly rejects duplicate/prototype keys, non-finite numbers and future model -versions, and retains the parsed candidate only in memory for ten minutes. Its -opaque token is bound to the HA user, normalized-candidate digest and the exact -config/layout revisions. Parsed candidates are capped globally as well as per -user. +## Second card: houseplan-space-card (read-only) -Plan-only export is a server-owned, fail-closed projection rather than a -client-side scrub. It removes every marker and all device layout, preserves -only canonical room-label placements, and copies one space through explicit -geometry/presentation allowlists. The parser recomputes that projection and -its placement manifest before showing a plan-only preview, so manually adding -a private field while keeping `transfer.plan_only: true` is rejected. +One bundle registers `houseplan-card` (interactive) and the read-only +`houseplan-space-card` (one space; `src/houseplan-card.ts` imports +`./space-card`). User options, `fit` and the deep link: `USER-GUIDE.md` §18, +`CANVAS.md` §4.4, `LIGHT.md` › Which surfaces render pools. Shared modules keep +the two views from diverging: `space-geometry.ts` (pure model/position math), +`space-render.ts` (`renderSpaceStatic()`: plan, rooms and markers through +`buildDevices`, `ResolvedDevicePresentation` and `renderDeviceFace`, no marker +handlers), `glow-scene.ts` (opt-in `light_pools`, per-card bounded caches) and +`config-store.ts` — one module-level `{config, rev, configFingerprint, layout, +layoutRev, layoutFingerprint}` cache and one subscription for all embedded +cards, seeded from `houseplan_card_cfg_v1` and refreshed on +`houseplan_config_updated`/`houseplan_layout_updated` without first clearing +the visible snapshot. `.hp-static-stage` and every descendant +(`*, *::before, *::after`) are `pointer-events:none`, overriding the markers' +44 px opt-in (#564, #664); only the footer button is interactive, asserted by +`demo/smoke_space_card.mjs` with `elementFromPoint`. A card with `floor` +ignores `#space=`. -Export snapshots config and layout as one coherent deep copy while holding the -shared write lock, then releases the lock before schema projection and content -hashing in the executor. Thus an export reflects exactly one stored pair while -ordinary reads and later writes do not wait for archive materialization. -Import attachment/asset scans likewise run in the executor; only the paired -revision check and commit remain serialized. +## Subsystems with their own canonical documents -The browser never parses imported configuration. Optimize, Optimize Undo, full -import, space deletion and maintenance share the `optimize_pending` -crash-recovery intent and the one-deep backup slot; the backup carries -`kind: optimize|import`, while every layout-store writer goes through -`async_save_layout_state` so unrelated store metadata survives. Each paired -writer persists an exact target intent before either half, retries convergence -once, then durably replaces it with an exact before-pair rollback intent before -reporting failure. HA Store exceptions are resolved by reloading and comparing -the exact payload because an exception may follow a durable atomic replace. +- **Decor, plan image, furniture, custom images** — `space.decor[]` is a purely + visual layer with one selection/transform/history pipeline; image bytes live + only in the asset store: [DECOR-EDITOR](DECOR-EDITOR.md), [FURNITURE](FURNITURE.md). +- **Light and Glow** — one visibility region per source (#71); Glow is an overlay + independent of the data fill (#55): [LIGHT](LIGHT.md). **Room fill** — + `resolveEffectiveRoomFill()` is the single projection for room floors, + clean-floor holes and opening tunnels; `room_color` styles only borders and + names; custom colour and legacy tokens (#56, #581): + [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md). +- **Device state, light membership, action** — `resolvedDeviceStateEntities`, + `resolvedLightSources` and `src/device-toggle.ts` are the only resolvers + (#94, #251, #318, #381): [DEVICE-PRESENTATION](DEVICE-PRESENTATION.md). +- **Zero-thickness walls, nested rooms** — `resolveZeroWalls()` feeds every + renderer, Glow and sun (#306): [WALL-THICKNESS](WALL-THICKNESS.md). **Kiosk, + navigation** — kiosk is a card flag, not a mode; `LS_NAV` stores only the space + (#93, #210): [UX-MODES](UX-MODES.md). -Every runtime config/layout writer holds the common `write_lock` and calls the -same pending-pair resolver before reading revisions, validating or checking for -a no-op. A stale CAS writer therefore sees the recovered revisions and gets a -normal conflict; point layout writers apply only their delta to the recovered -layout. If convergence still fails, the new writer performs no own write and -leaves the intent available for retry or restart. Setup runs this resolver -before any setup-time storage migration. Config/layout update events are fired -only after both halves and final metadata are durable. Apply still rechecks -local plan files under the write lock. - -**If the v1.48 migration crashed halfway** (HP-1500-01): the config write -landed, the layout write did not, and both triggers are gone — markers of that -space sit in the old coordinates and nothing in the data can prove it. The -`geom_pending` intent (v1.50.0) prevents this for any future migration, but -cannot help an install that was already stranded. There is no safe automatic -answer — re-transforming a layout that is actually correct would corrupt it — -so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}` -re-applies the transform to that one space's positions. `dry_run: true` -previews, the previous positions ride the same store write as a one-deep -backup, and `undo: true` restores them. Admin-gated like every other write. - -**The canvas is square, the image is not** (v1.48.0). A space used to carry an -`aspect`, and coordinates were normalised against it — x by the width, y by the -height. That made every geometric question depend on a per-space number for no -benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted -inside it by its own ratio (`fitInSquare`, shared by both renderers), which is -stored as `plan_aspect` so the layout does not jump before the file loads. -Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out -to a square and re-expresses every coordinate against it — a uniform scale plus -an offset in render units, so angles and proportions are exact — and scales -`cell_cm` for tall plans, since the grid pitch is a fraction of the width. - -**User content is served inert** (HP-1454-01). An uploaded SVG is the only -thing here that a browser will happily treat as a *document* rather than an -image, and it would be a document of Home Assistant's own origin. Inside the -card that never matters — `` does not run scripts — but the url is -reachable directly, and uploading needs only write access, which by default -every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG -and only with SVG: a CSP on a PDF response can break the browser's built-in -viewer, and a raster image has no execution model to disable. - -**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02). -An upload takes a free name and never overwrites, because the bytes under an -existing name may be referenced by the stored configuration and an upload is -not part of that transaction. `reserve_filename` *claims* the name as it picks -it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two -uploads agree on one name and quietly overwrite each other. It also budgets the -length so the result survives the sanitiser the content view applies to the -request, since a name the view rewrites is a file written and never served. -Streaming temporaries live in the files root under `.upload-`, are removed on -every exit path of the request (including cancellation, which is a -BaseException and slips past `except Exception`), and are swept at startup and -daily. That scheduled pass also runs the two collectors with the stored -configuration as *both* sides — nothing superseded, so every referenced file is -kept and only aged unreferenced ones go. Without it, collection would only ever -happen when somebody saves, and a file uploaded into a dialog that was then -cancelled would wait for a write that may never come. A new icon has no id yet, so its -files go to a per-dialog staging folder and move to the real id once the config -write is accepted — the same copy → save → cleanup order as a rebind. -`config/set` collects what its commit superseded — that much a commit knows for -certain. *Unreferenced* is a far weaker signal, and the policy follows from one -asymmetry: **a few unnecessary megabytes can always be removed by hand; a file -we should not have removed cannot be brought back.** When the evidence is weak, -keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to -every unreferenced file destroyed two detached plans. - -The classification is by **owner**, not by "is it referenced". A file leaving -the configuration looks identical whether the plan was replaced, detached, or -its space deleted — and only the first is a deletion the user asked for. Reading -`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it -was detached, under documentation promising the opposite (HP-1465-01). - -| Case | What it means | Rule | -|---|---|---| -| Space in both, plan A → plan B | the user picked another image | removed immediately | -| Space in both, plan → none | detached; one click undoes it | **kept** | -| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** | -| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them | -| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately | -| Marker gone | same call as a deleted space's plan | **kept** | -| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) | -| Marker there, file it never listed | a rejected upload | **kept**, same reason | - -Nothing is deleted for being old, with one exception: a per-dialog staging -folder (`up_*`), which by construction can only hold an upload from a dialog -that was never saved. The disk therefore stays bounded by the user, not by a -timer — `houseplan/plans/list` shows every stored plan with its size and which -space uses it, and `houseplan/plans/delete` removes one on request, refusing -while a space still references it. That listing is what makes "we never delete" -livable: a detached plan is not lost, it is one click away in the space dialog. - -**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a -single promise: one `config/set` in flight, each carrying the revision the -previous one returned. The debounce still spaces out *when* a write starts; -what it cannot do — and used to be relied on for — is keep two writes from -overlapping, which produced a self-inflicted conflict and lost the newer edit. -Physical edits additionally form a pending transaction per space. A successful -write clears only the exact accepted fingerprint, so a newer queued edit stays -pending. A rejected write synchronously restores the earliest server-backed -snapshot for every affected space, clears its gestures and geometry history, -then best-effort reloads authoritative config. Thus a newer edit made while the -rejected request was in flight cannot survive on an unaccepted base (#314). - -**Config/layout identity has one owner** (#500). `src/config-adoption.ts` -holds the server config and the device layout together with their revision -and content fingerprint; the card exposes `_serverCfg`, `_cfgRev`, `_layout` -and `_layoutRev` only as read delegates. The identity changes in exactly -three ways — adopting an authoritative response, accepting the reply to our -own write (`acceptConfigWrite`, `acceptPairWrite`), restoring the warm cache — -and a revision is never taken apart from the body it describes: after -`space/delete`, Optimize Undo or Import the revisions come from the re-read -`config/get`/`layout/get`, not from the write reply. Every authoritative -adoption goes through `adoptAuthoritativeGated`: compare by fingerprint → -backdrop readiness (`ContentSigner.prepareImage`) → continuity candidate → -adopt → tail. The `reload` profile (initial load, `config_updated`, summary -lost-ACK recovery) runs the shared tail (decor assets, initial space, pending -nav mode, cache snapshot); the `post-write` profile (the four re-reads after a -paired write) ends at adoption and leaves each caller its own tail. Bodies may -still be staged locally before a write — that is how the editors work — but -only in the files pinned by `test/config-adoption-ownership.test.mjs`, whose -counts ratchet down. Feature runtimes see one host method, -`_adoptAuthoritative`, instead of the eight steps it replaces. - -**Overlapping config reads have one live owner** (#543). -`src/config-reload-authority.ts` gives every `_reloadConfigOnly` request a -claim before its first network await. The claim is valid only while it is the -latest request in the same connection/user/route/lifecycle generation and the -accepted config revision/fingerprint has not changed under it. Ordinary config -events additionally reserve a generation-scoped revision high-water, so a -late lower event cannot cancel an already-running higher one; an explicit -force/reset starts a fresh generation and may legitimately accept a lower -revision. Route departure, disconnect/reconnect, connection replacement and -user replacement invalidate claims monotonically, so returning to the same -visible identity cannot revive an old promise. The claim is checked after the -transport await and by `adoptAuthoritativeGated` on both sides of -`ContentSigner.prepareImage`; a loser returns `superseded` without continuity, -adoption, cache, retry, toast or render side effects. The winning synchronous -adoption tail remains one JavaScript task and does not make a newer reload wait -for an older image. - -**Persisted coordinates have one lattice-aware write boundary** (#291). -`canonicalizeConfigGeometry()` / `canonicalizeLayoutGeometry()` / -`canonicalizePosition()` own the frontend candidate; mirrored Python functions -run in validation and again in `async_save_config_state()` / -`async_save_layout_state()`. Only allow-listed coordinate/size components less -than `1e-4` grid steps from a `1/240` node become the exact node double. -Authored off-grid values and unknown numbers are not recursively snapped. The -executable `coordinate-write-barrier-guard.mjs` inventories every outbound -config/layout writer and permits direct plan Store writes only inside the two -central helpers; trails remain an explicit operational-Store exception. - -**Plan uploads are copy-on-write, and collection belongs to the commit** -(reviews R2-1, R3-1). The file system is not part of the config's -optimistic-locking transaction, so nothing referenced may be overwritten or -deleted before the CAS succeeds: the upload writes a new versioned name and -removes nothing. Deciding what may then go is *not* a client's call — a cleanup -request cannot be ordered against another client's commit, and a delayed one -deletes a plan that was just saved. So `config/set` collects itself, inside its -write lock, from the pair of configurations that bracket the commit -(`plans.collect_plans`): a file the commit REPLACED goes immediately, and -nothing else goes at all — see the table above; only a per-dialog staging folder -ages out. Growth is bounded at the door instead, by `plans.check_quota` on every -upload (store size, file count, free disk), because a limit that deletes is how -plans were lost twice. The `.` between id and token is load-bearing — -a space id cannot contain one, so `..` can never be confused -with the files of a space whose name merely starts the same way. - -**An internal plan url must exist when it is stored** (HP-1470-02). The picker -can attach a plan and then delete it, and two clients can do the same in either -order — the write lock orders the requests but says nothing about whether the -file survived. `config/set` therefore checks every `/api/houseplan/content/plans/` -url against the disk before saving, and refuses with `missing_plan`. External and -legacy urls are the user's own and are never second-guessed. -Portable import repeats the same check under its paired-write lock for both -plans and local marker PDF attachments, so content that disappears after the -preview cannot leave a newly broken reference in the restored config. - -**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner` -in `src/signing.ts` is the single implementation, used by both cards; the -duplicate inside houseplan-space-card signed correctly and never handed the -result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS` -(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a -request there and says nothing about the rest, so the card must chunk. Cached -signatures carry the time they were issued — an aging one keeps rendering while -its replacement is fetched, an expired one is dropped rather than served (it -would 401 and raise a failed-login warning). The cache is pruned to the urls the -live config references, so it cannot grow past the cap through history alone. -Queued and in-flight are distinct states: a render happening while a request is -out must not queue the same url again, a failure backs off rather than retrying -on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a -promise that never settles cannot block retries forever. - -**Visual continuity is a frame contract, not a loading screen** (#73). -`src/visual-continuity.ts` owns one tokenised state machine shared by the full -and static cards. A complete frame remains mounted during resume, reconnect, -structural revalidation and positive-size changes; `0×0` observations never -change the viewport. Config and layout carry independent revision plus -content-fingerprint identities, so revision-only echoes preserve authoritative -objects and geometry caches while changed content cannot hide behind an equal -revision. A candidate becomes complete only after Lit settles, required signed -assets are loaded, and two animation-frame opportunities pass for the current -token. A bounded trace and the production `data-continuity-state`, -`data-continuity-token`, `data-frame-fingerprint` and conditional -`data-recovery-reason` attributes expose this contract without entity ids or -URLs. - -The signed-asset runtime is authority-scoped (`hass.connection`), bounded and -shared across placements. A warm remount can therefore use an already loaded -backdrop synchronously. Refresh is stale-while-decode: the painted signed URL -stays authoritative until its replacement has loaded and decoded off-DOM. -Only when no complete/stale frame can be retained may the controller show the -localized opaque recovery overlay, after a 150 ms delay. The overlay never -steals initial focus; while visible it alone is interactive and the scene is -`inert`. - -**The initial snapshot does not depend on live-sync subscriptions** (#131). -`houseplan-card` first accepts config and layout, builds the model, chooses one -exact space, caches the accepted snapshot and restores its viewport. Only then -is the mandatory load complete. Config, trail and layout event subscriptions -start together as independent best-effort enrichments: one rejected channel -does not prevent the others from subscribing, does not erase the usable -snapshot and does not schedule a full-load retry solely for that rejection. -Missing channels get another attempt on the next normal load or reconnect. - -`src/initial-load.ts` is the shared authority for the exact space used by a -cached snapshot, a live snapshot and its protected-backdrop candidate. When the -card config owns a `floor` property, `resolveFixedFloor()` has absolute -authority: a string is an exact stable id and a finite non-negative integer is -a zero-based server-model index. A valid fixed value beats URL hash, warm/current -state, saved navigation, `default_floor` and first space. Invalid explicit -values fail closed instead of falling back; numeric indexes wait for the fresh -server model before the first spatial frame. A fixed instance never reads or -writes `houseplan_card_nav_v1`, and every accepted `_space` transition passes -through the same fixed-authority guard. - -With no own `floor` property, the legacy cold load considers only valid ids in -this order: URL hash, saved navigation, `default_floor`, first live space. Once -an initial URL hash has been consumed, a valid same-route current selection is -preserved instead of repeatedly snapping back to that hash. The legacy field -initializer is never a cold-start choice by itself. A plan with no spaces keeps -`null` authority and does not invent an id. - -**Room climate is one pass per hass snapshot** (review R2-3, issue #317). -`roomClimateMap()` classifies the whole active registry once and returns one -`{temp, hum}` aggregate per effective room target. HA-area rooms keep their -area key. An explicitly placed marker overrides registry placement; an -area-less room uses a collision-safe `space + room_id` key. Exact `entity:` -placement wins over its parent `device:` placement for that entity, so a -reading cannot remain in the old Area and vote twice. The card memoizes the map -on `hass`, rules and markers; per-room lookups are O(1). `areaClimateMap()` and -`areaClimate()` survive as compatibility wrappers — using the single-area -wrapper in a render reintroduces the O(rooms × entities) cost the map removed. - -Explicit room `temp_source`/`hum_source` remains above the automatic aggregate. -Hidden live markers still contribute; removed and HA-disabled bindings do not. -Full View and hosted Static use the same resolver and bounded active render -snapshot, so a state tick cannot update a temperature fill through a different -membership rule. - -Per-room comfort bounds are resolved by `roomTempRangeOf()`: each absent or -invalid room side independently inherits the space side, then the effective -pair is normalised. Both full and static renderers pass that one result into -`resolveEffectiveRoomFill()`, keeping room polygons and thick-opening tunnels -in the same temperature band without another climate aggregation pass. - -**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload` -(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`. - - -## Second card: houseplan-space-card (read-only, v1.16.0) - -The bundle registers **two** custom elements from one entry (`src/houseplan-card.ts` -imports `./space-card`): - -- `houseplan-card` — the full interactive card. -- `houseplan-space-card` — a static, read-only schematic of ONE space for embedding. - -Shared, framework-light modules keep the two views from diverging: - -- `src/space-geometry.ts` — pure model/position math (`spaceModels`, `roomBounds`, - `roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested, - mirrors the full card's private geometry. -- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room - borders/names + device markers (via `buildDevices`, same filtering) with NO marker - handlers. Current states, values, alarms, temperature/LQI badges and witnessed activity - use the shared `ResolvedDevicePresentation` and `renderDeviceFace`; optional card settings - can disable ordinary live dressing, temperature or signal without creating another - semantic implementation. -- `src/glow-scene.ts` — one canonical Glow transport/runtime/SVG implementation - shared by the full renderer and the opt-in static adapter. The static card's - public `light_pools` flag defaults to false and gates barrier/visibility work - before it starts; each mounted card owns and disposes its bounded clip cache, - source transitions and timers. -- `src/config-store.ts` — module-level `{config, rev, configFingerprint, layout, - layoutRev, layoutFingerprint}` cache shared by all embedded - cards (dedupes `houseplan/config/get`), seeded synchronously from the full card's - localStorage snapshot (`houseplan_card_cfg_v1`) and invalidated on - `houseplan_config_updated` or `houseplan_layout_updated` without first - clearing the visible static snapshot. - -**Static contract:** the schematic layer (`.hp-static-stage`) is `pointer-events:none`; the -footer button lives outside it and stays clickable. `pointer-events` is not an -inherited ban, so the card also closes every descendant opt-in -(`.hp-static-stage *, *::before, *::after`): the shared marker styles re-enable -the 44 px floor and painted capsule for the interactive plan (#564), and -without that rule the schematic showed a pointer cursor over markers and -swallowed clicks (#664). `demo/smoke_space_card.mjs` asserts it with the -browser's own hit test (`elementFromPoint` over the marker and a grid across -the stage), not with the stage's computed style. - -**Static frame contract:** `fit` is normalised to `content | house`, with every -missing, empty or unknown value resolving to `content`. The default calls the -unchanged content/outlier frame. Opt-in `house` derives one zero-intentional- -padding `viewBox` from all sane architectural geometry and its painted stroke/ -opening envelope; backdrop, decor, labels, devices and environmental effects -do not vote. It uses `contentFrame(...).all` semantics so a detached structural -wing cannot be rejected as an outlier, and falls back to the default frame when -there is no structure. The resulting single `viewBox` still drives the SVG, -HTML marker/label layers and continuity overlay together. - -**Deep-link contract:** the footer button calls `navigate(button_target + "#space=")` -(default target `/plan-doma`). An unpinned full card reads `#space=` on load (a valid id wins -over `default_floor`) and on `hashchange`, without blocking manual space switching; an -invalid/absent hash falls back to the default. A card with `floor` ignores the hash and remains on -its configured space. - - -## Additions v1.28–v1.41 (2026-07-24) - -- **Decor layer** (`space.decor[]`, v1.33; unified editor 2026-08-07): purely - visual line/rect/ellipse/text/furniture/image shapes, normalised geometry and - physical per-shape style. `src/editors/decor/types.ts` is the typed persisted - union; `geometry.ts` owns cm↔render conversion, oriented boxes and the - decor+room magnet; `hp-color-opacity` is the shared colour/alpha control. - `houseplan-card.ts` still owns orchestration, but every kind uses one - selection/transform/history pipeline. `DECOR_SCHEMA` accepts canonical - `width_cm`, text `size_cm`, opacity/fill fields, optional per-line - `line_style` (`solid` / `dashed`; the frontend omits the legacy solid - default), and legacy width/text-size representations for read compatibility. - Furniture keeps positive `w/h`; optional `flip_h/flip_v` mirror only its SVG - art. Its continuous local-axis resize/45° rotation path is deliberately - separate from the shared grid-snapped box controller, while Undo/Redo, - canonicalization and persistence remain common. - Custom images reuse that continuous box controller without the furniture - wall magnet. Their records contain `asset_id`, positive `w/h`, optional - `flip_h/flip_v`, `angle` and opacity; bytes live only in the dedicated store. -- **Plan image transform**: `planRect()` resolves the fitted image plus - `plan_x/y`, independent `plan_scale_x/y` and `plan_angle`; legacy - `plan_scale` feeds both axes. The image is interactive only in its own - Background tool, rotated corners contribute to content bounds, and the - static card uses the same model. See `DECOR-EDITOR.md` §3. -- **Independent Glow overlay** (#55): `settings.glow_enabled` is orthogonal to - the data `fill_mode`; room `settings.glow` is the tri-state-compatible model - foundation for #36. Legacy `fill_mode: 'glow'` remains a permanent read - token and projects to data fill `none` plus Glow `true` unless an explicit - boolean wins. Normal settings/room saves materialise that projection in the - same write; Optimize Plans performs the equivalent idempotent model-v7 - migration. Render order is paper → resolved data room/tunnel fill → conditional - pointer-free Glow base for rooms whose resolver result is absent or fully - transparent → radial - pools → sun/interactive layers. A resolved data/static fill (`lqi`, `light`, - `temp`, `custom`) never receives the dark base, so its exact color and alpha - remain visible; a dynamic mode without usable data or a custom fill with - zero opacity receives the base instead of exposing bright paper. Radial pools - stay independent and continue to render. The static room card uses the same - data/base projection, omits empty base groups, and renders the same live pools - only when its default-off `light_pools` option is enabled. -- **Custom room fill** (#56, #581): `space.settings.custom_fill` is the space - color and `room.settings.custom_fill` is an optional explicit override that - counts **only together with the room's own `fill_mode: 'custom'`**. The pure - projection is room (own mode) → space → `{c:'#607d8b',a:.18}` and every read - crosses `safeStoredColor` plus finite alpha clamping. A room colour stored - without the own mode — the state "as the space" used to leave behind — is an - orphan: `roomCustomFillOf` paints the space colour, never rewrites the config, - and the next save of that room drops the field. The room dialog keeps the - colour draft only under its own "custom" radio and clears it when the radio - leaves that mode, so the dialog can no longer create the orphan. `resolveEffectiveRoomFill` - remains the single source for room floor, clean-floor holes and thick-wall - tunnel colors; stored `room_color` continues to control borders/names only. -- **Glow pools and additive composition** (#19, #71): every source retains its - own radial gradient and one `clipPath` — the floor that source can see. A spot - is a single circle, screen-blended by `mix-blend-mode: screen`; all spots - share one isolated parent and no outer opacity. `resolveGlowAppearance` resolves the marker-owned - live/manual colour and brightness, while `glowAlpha` is the only intensity - formula: `paletteAlpha * .7 * (.4 + .6 * bri^(1/2.2))`. That alpha is the - gradient's centre; `GLOW_FALLOFF` then spends it over the whole radius - (100/88/62/32/0 %) instead of holding a plateau to 70 % — a clipped shape - used to become a slab of solid colour with a rim. The gradient is - `userSpaceOnUse` and centred on the lamp, so attenuation is a property of - distance from the lamp and of nothing else. - - **Transport is one question, asked once per source: what can this lamp see?** - The full model, its exceptions and the reasoning behind them live in - `docs/LIGHT.md`; the summary here is the map, not the territory. - `_lightBarriers` collects everything opaque: the wall bodies exactly as the - plan draws them (`wallBodiesGeometry`, real thickness, mitred junctions), - every independent body (partition, column, draft), and the bare outline of - any edge that carries no thickness. It cuts out the exceptions: interior - doorways and gates according to their resolved live opening amount — a - closed bound opening keeps the masonry, while a positional cover cuts a - centre-aligned fraction between the jambs — and saved passages, which remain - fully open, plus dashed zero-thickness walls. Solid zero-thickness walls instead add - their exact axes as zero-area barriers. Windows stay - solid, so an indoor lamp never washes the street; the light's masonry is cut - by passages only and therefore differs on purpose from the drawn one. So does - a door with no floor behind it: an opening is transparent only where BOTH - sides are floor, otherwise a front door glows halfway — up to the centreline - where the room polygon ends — and the plan shows a lit doorway to nowhere. A wall - treated as its centreline instead (the first cut of this model) let light - bleed half a wall deep, which showed up as a bright bar at every opening, and - started each shadow half a wall away from the corner casting it. - Barriers are then split at every point where they CROSS each other - (`splitAtIntersections`): the sweep casts a ray at each endpoint, so a corner - formed by two faces crossing in their middles — normal where wall bodies meet - at a junction — would otherwise never be sampled, and the fan would close it - with a chord, leaving a sliver of floor dark next to a corner the lamp plainly - sees. `visibilityPolygon` (`src/light-visibility.ts`) then sweeps the corners - of those segments and returns the region the lamp reaches; intersecting it - with the room floors gives ONE clip for ONE circle. A beam through a doorway, the - room it lands in, the shadow of a column, a wall corner cutting that beam - two rooms away and light crossing a dashed zero wall are all the same - computation, so they cannot disagree with each other — which is what every - earlier bug here was made of (a doorway painted as an unlit bar, a beam - detached from its aperture, a shadow blurred into a smear, walls in a farther - room ignored). There is no spill layer, no sector, no tunnel rectangle, no - open-zone graph and no shadow mask left in the light path. - - Barriers are cached per space by a fingerprint of their complete geometry - (every body point, both wall endpoints and scale inputs) plus a sorted - signature of bound interior door/gate opening amounts, never by `_cfgEpoch`: - the epoch lags behind geometry edited in place, and a stale barrier set is - invisible — the plan simply keeps lighting through a wall or closed door that - now exists. Unrelated HA updates retain the same signature and reuse the - barrier set. The combined fingerprint keys the per-source region cache. A cached per- - `Document` raster probe verifies actual SVG screen pixels rather than trusting - CSS syntax support; pending/unsupported/error/timeout states render with - deterministic normal blending and a successful probe requests one update. - Radius remains global `settings.glow_radius_cm` with optional per-marker - `glow_radius_cm`. The shared - light resolver marks external `controls` as non-spatial: they vote in room - state/statistics and drive group actions but never place a pool at the - controller. `wall-thickness.ts` and the card orchestrator continue to own all - geometry/caches; `render/opening-tunnels.ts` only projects immutable inputs. - Opening-tunnel faces are emitted as one simple union contour per connected - physical span: thickness steps are vertices on the outer envelope, never - touching translucent rectangles. The negative and positive halves use the - same nonzero winding across their tiny centre overlap, so fractional SVG - rasterisation cannot cancel the fill into a seam or stack its opacity. -- **Zero-thickness walls** (#306): canonical v10 stores them only as - `wall_segments[]` or `partitions[]` with `cm:0`. - `resolveZeroWalls()` supplies their exact line geometry and the space-level - solid/dashed light policy to every renderer, Glow and sun. In View a line is - painted before thick bodies so adjoining masonry masks its centreline ends; - editors paint it after the bodies. `open_spans` and `room.open_to` are - compatibility reads only and disappear together after a successful current - structural migration. -- **Marker controls** (v1.36): persisted `marker.controls[]` is a lossless, - ordered external-target list. Opening and saving the dialog preserves - duplicates and temporarily unknown/vendor targets, removing only the - marker's own bound/device entities. The runtime projection separately - de-duplicates and filters to currently controllable lights/switches. For an - explicit `tap_action=toggle`, `resolveToggleIntent` executes the available - subset with HA-group semantics and reports missing/disabled/unsupported refs; - icon working state mirrors the effective light graph. Controller availability - is deliberately separate (#251): at least one live own active entity - (including battery/LQI/update diagnostics) keeps a physical controller - available, while an all-unavailable target graph is neutral. An active - physical `device:` binding with an empty own entity roster is also available: - absence of telemetry is not proof that the device is offline, and its target - graph still decides working versus neutral (#318). Once that own roster is - non-empty, all-missing/`unknown`/`unavailable` states remain positive offline - evidence and fade the controller. A virtual controller is available by - definition. An explicit Toggle whose configured - group has no executable unavailable/missing/HA-disabled target produces the - card's standard local explanatory toast and no service/press feedback; - partial groups keep executing their available subset. - The persisted external-target list also keeps the physical-controller role - when every runtime target was filtered by another marker's deletion - tombstone: own live diagnostics still decide availability instead of an - event-primary fallback. Marker-dialog drafts are projected through - `buildDevices` with the complete persisted marker roster, replacing only the - edited marker. Consequently target ownership/tombstones and controller - presentation are identical on the committed plan and in preview. -- **Universal device action** (#94): `src/device-toggle.ts` is the only authority - for toggle origin, exact target, capability/security filtering, next effect - and service command. The dialog hint, click path, confirmation re-resolution - and cover presentation consume the same immutable result. Exact `entity:` - bindings never retarget to siblings; persisted controls never fall back to a - controller's own entity; secure targets are explicit no-ops. The removed UI - action `cover` remains accepted and losslessly round-tripped as a legacy - origin until the user deliberately changes the selector. An absent action on - a primary `light.*` likewise stays absent on an untouched Open → Save. - The canonical explicit `tap_action=none` (#381) is resolved separately from - that absent default. `_clickDevice()` first consumes propagation and - re-resolves the current marker by stable id, then returns on `none` before - capability lookup, confirmation, cards/toasts, press feedback or HA - dispatch. Keyboard Enter/Space shares the same path; hold and context-menu - handlers remain independent. - `POWER_ADAPTERS` is the explicit domain allow-list and carries per-entity HA - feature masks where a domain-wide service is not capability proof. The - service catalog is a second fail-closed guard. A click resolves the current - marker by id rather than using a retained #73 visual snapshot; the snapshot - remains valid only for read-only presentation. - Optional `marker.toggle_entity` is an exact own `light.*`/`switch.*` override - layered before the legacy own-role resolver. Its absence leaves legacy - single/group membership unchanged; an active explicit choice also joins an - explicit controls group, while stale values fall back without being erased. - It is deliberately independent from visual `marker.light_entity`. -- **Resolved device state** (2026-08-06): HA provides states per entity, not - one state per device. `resolvedDeviceStateEntities` therefore starts from - uncategorised registry entities, resolves one functional role (whole-device - domains, then semantic binary signals, then one representative switch), and aggregates - passive readings as the final fallback. `_devicePresentation` consumes the full - result; `primaryEntity` only selects its first member where a single action - target is required. Integration option switches can no longer make an - otherwise healthy device working or unavailable merely by list order. For - A switch-only device never aggregates sibling option switches: integrations - which fail to categorise night mode, voice enhancement or child lock cannot - paint the whole marker as working. When generic HA metadata identifies a - dedicated Power switch in such a composite controller, `on` is a neutral - powered lifecycle and `off` reuses the faded unavailable presentation; a - lone relay keeps normal working-state yellow. For `climate`, a recognized - explicit `hvac_action` is authoritative: idle remains - neutral and heating/cooling/preheating/defrosting are working. Unknown vendor - pseudo-actions are ignored; when no recognized action exists, a current - non-off state advertised by `hvac_modes` (or a standard HA HVAC mode) is the - best available enabled-mode fallback. -- **Resolved light sources** (UX-12): `resolvedLightSources(hass, devices, - room?)` is the only light-membership resolver. `marker.is_light` is a real - tri-state: absent/null keeps automatic role discovery, `true` adds the - marker's own source (including a passive source without HA entities), and - `false` suppresses only that own candidate. A resolved source separates its - `key` from `stateEids` and `serviceEids`: `marker:*` links are graph identity, - never fake HA entities or service targets. Optional `marker.light_entity` - selects the leading entity of a multi-channel forced source. External entity - and marker controls remain independent room-state votes; a target marker owns - position/statistics while the controller presentation still mirrors its - aggregate working state. The graph uses a content fingerprint, excludes - hidden/disabled targets, de-duplicates aliases, and honours `marker.room_id` - before an HA area. Glow, Light fill, room light stats, marker indication/card - ordering and group toggle all consume this result instead of maintaining - separate domain tests. Per-marker - `glow_color:{c,bri?}` can override colour alone or colour plus brightness; - strict invalid overrides fall back atomically to live source values. -- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`); - parents render as evenodd paths with holes (`islandsOf`). -- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is - hard-blocked, header hidden, swipe/carousel handled in the stage pointer - pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`. -- **Nav persistence** (#93, #210): `LS_NAV` stores `{space}` only for unpinned - cards; hash deep-link > saved > `default_floor`, with stale-cache retry after - the live load. A card with `floor` neither reads nor writes `LS_NAV`, and its - resolved stable id or server-order index remains authoritative across hash, - warm remount and kiosk inputs. Editor mode is transient: cold load, reload - and return from another HA route start in View. The warm memo may carry an - editor only across a technical remount on the same route. - -## View/editor transition ownership (#101) - -`src/mode-transition.ts` owns the only RAF/token timeline for entering, leaving -and switching editors. `ModeTransitionController` interpolates measured editor -chrome height, stage geometry, world-space camera centre, logarithmic screen -pixels-per-unit, stage/paper colours, day/night brightness and presentation -weights together. Every intermediate SVG viewBox is derived from the current -stage aspect, so no default-fit or letterbox frame can appear. The stage is -inert while geometry is moving; header mode tabs remain available for a rapid -retarget. Reduced motion commits the exact target atomically. Visibility loss, -space change, recovery and disconnect cancel or settle this same owner rather -than leaving CSS timers or WAAPI animations behind. - -## Camera-only transition ownership (#82) - -`src/viewport-transition.ts` owns a separate one-token/one-RAF controller for -discrete zoom commands inside an already settled mode. It shares #101's easing -primitive but only interpolates reactive `{zoom, viewBox}`: no chrome, -background, layer opacity or CSS transform. Wheel retargets from the camera -actually presented while accumulating from the pending target; pointer/pinch, -mode, space, projection, resize, structural adoption, visibility and teardown -are explicit ownership boundaries. The component remains the sole writer of -camera state and persists one exact target only after settle. The controller -lives in the core View bundle and does not import the lazy editor runtime. +## Camera and mode transitions (#101, #82) +Two one-token/one-RAF controllers own every animated camera change and leave +no CSS timers or WAAPI animations behind. `ModeTransitionController` +(`src/mode-transition.ts`) is the only timeline for entering, leaving and +switching editors: it interpolates measured chrome height, stage geometry, +world-space camera centre, logarithmic pixels-per-unit, stage/paper colours, +day/night brightness and presentation weights together, deriving each `viewBox` +from the current stage aspect; the stage is inert meanwhile, header tabs stay +live for a retarget. `src/viewport-transition.ts` animates discrete zoom in a +settled mode with the same easing but only `{zoom, viewBox}` (no chrome, +background, layer opacity or CSS transform) and lives in the core View bundle. +The component stays the sole camera writer; ownership boundaries and timings: +[CANVAS](CANVAS.md) › View/editor camera handoff and §5. ## Settings tiers (owner's principle, 2026-07-26) -Four levels: **global (config.settings) → space (space.settings) → room -(room.settings) → device (marker.*)**. Duplicated options are deliberate: the -more specific tier overrides the more general one; "unset" always means -"inherit". Resolution lives in pure helpers (`spaceDisplayOf`, -`roomFillModeOf`, `sourceValue`, `resolveToggleIntent`) — never inline in render. -The UI will later be unified around this model; until then each tier keeps its -own dialog (general settings gear / space gear / room-card gear / marker -dialog). +Four levels: **global (`config.settings`) → space (`space.settings`) → room +(`room.settings`) → device (`marker.*`)**. Duplicated options are deliberate: +the more specific tier wins and "unset" always means "inherit". Resolution +lives in pure helpers (`spaceDisplayOf`, `roomFillModeOf`, `roomGlowOf`, +`roomTempRangeOf`, `sourceValue`, `resolveToggleIntent`), never inline in render; +each tier keeps its own dialog (General settings, space, room, marker). - -## Audit follow-ups (2026-07-27) - -- **Content is authenticated.** `/houseplan_files/…` now serves ONLY the card - bundle (a Lovelace resource must be public). Plans and marker files go - through `HouseplanContentView` (`/api/houseplan/content//…`, - `requires_auth`). `contentUrl()` rewrites legacy stored URLs on read, so no - storage migration is needed. Static paths cannot be unregistered — the old - routes survive until the next HA restart. -- **Optimistic UI, stated explicitly (audit L7).** `_serverCfg` is mutated in - place before a fallible save in ~22 places and there is no rollback: after a - rejected save the UI shows the edit until the next reload. This is a - deliberate optimistic-UI choice, not drift. Paths where it is unacceptable - need their own rollback. -- **Split invariant.** `splitRoomPath` guarantees a partition: the two parts' - areas sum to the original (within epsilon) or the cut is rejected. - -## Schema as the source of truth (#33, 2026-08-30) +## Schema as the source of truth (#33) The Voluptuous schema in `custom_components/houseplan/validation.py` is the -single owner of the persisted config/layout shape. Three artefacts keep every -other world honest against it: +single owner of the persisted config/layout shape. The generated manifest +`scripts/config-schema.json`, the enum parity test with its self-checking +allow-list, the decision registry `scripts/config-field-registry.mjs` and the +lifecycle fixtures keep every other world honest against it: +[CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md) › Schema manifest and parity. -- `scripts/dump-config-schema.py` walks the schema into the deterministic - `scripts/config-schema.json` (265 leaf paths at introduction); - a pytest regenerates it and fails on any uncommitted drift. -- `test/config-schema-parity.test.mjs` compares manifest enums with the - exported frontend const lists (`DISPLAY_MODES`, `TAP_ACTIONS`, - `SPACE_FILL_MODES`/`ROOM_FILL_MODES`, `OPENING_TYPES`, - `VACUUM_TRAIL_MODES`, `ZERO_WALL_STYLES`, `BG_MODES`). Every divergence - must be blessed in `scripts/schema-compat-allowlist.mjs` with a reason and - an owning issue — and an allow-list entry that stops matching a real - divergence fails the test too, so the list cannot rot. -- `scripts/config-field-registry.mjs` stays the DECISION layer on top of the - manifest: only fields with a non-trivial fate live there, each resolving to - a manifest path or carrying an explicit `schema: 'allow-extra'` / - `'lovelace-card'` passport; implemented mechanisms cite their code point in - `enforcedBy`. The lifecycle fixtures in `test/fixtures/config-lifecycle/` - pin the load contract: oldest-supported and future-field configs pass the - schema losslessly. - -## No hidden discovery knobs (#44, 2026-08-30) +## No hidden discovery knobs (#44) Every stored key that shapes device discovery is a visible, supported setting or does not exist. `settings.group_lights` and `settings.exclude_integrations` -are edited in the device catalog's Discovery-filters section; the ONE resolver -`effectiveExcludedIntegrations()` (devices.ts) feeds discovery, the -materialisation seed and room climate alike, and the preview in the dialog -diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no -second copy of the filter logic to drift. The field registry (#33) carries -their passports; `scripts/config-audit.mjs` treats both as `current`. +live in the device catalog's Discovery-filters section and are resolved only by +`effectiveExcludedIntegrations()`: [FILTERING](FILTERING.md) › Seeding. -## Contextual Zigbee topology (#54, #457, #464; 2026-09-06) +## Contextual Zigbee topology (#54, #457, #464) -The initial View graph owns only the fail-closed settings reader and a dynamic -overlay bridge. A saved `settings.zigbee_topology.enabled === true`, an actual -HA admin, full-card View and non-kiosk surface are all required before the -topology overlay chunk is requested. Opening the lazy General Settings runtime -does not load provider transport until an enabled saved setting needs status or -the admin presses a provider action. +The initial View graph holds only the fail-closed settings reader and a dynamic +overlay bridge; the overlay chunk loads only for a saved +`settings.zigbee_topology.enabled === true`, a real HA admin, full-card View and +a non-kiosk surface. General Settings loads provider transport only when an +enabled setting needs status or the admin presses a provider action. +`zigbee-topology.ts` normalises ZHA and Zigbee2MQTT into unordered edge pairs +with directional observations, maps IEEE nodes through exact registry ownership +and resolves only edges incident to the hovered marker, never inventing +neighbours. `zigbee-topology-runtime.ts` keeps a per-connection memory cache with +in-flight dedupe: ZHA reads `zha/devices` without a scan; Z2M checks the retained +bridge-info topic, sends one correlated raw `routes:false` request through +`mqtt.publish`, rejects retained/foreign/late replies and always unsubscribes. +The pointer-transparent overlay is a child of the `.devlayer` camera, projected +once with the markers by `live-viewport.ts`; only the source and drawable +neighbour markers are promoted above it, through transient attributes the +overlay owns and clears. Unknown-LQI links are a 4 px `#2e2e2e` casing under the +2 px grey core. Persistence, privacy: [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md). -`zigbee-topology.ts` normalizes ZHA and Zigbee2MQTT into unordered edge pairs -with separate directional observations, maps IEEE nodes through exact HA -registry ownership and resolves only edges incident to the hovered marker. -Routes never invent neighbor edges. `zigbee-topology-runtime.ts` owns a -per-connection memory cache and in-flight dedupe: ZHA reads `zha/devices` -without requesting a scan; Z2M verifies the retained bridge-info topic, sends -one correlated raw `routes:false` request through `mqtt.publish`, rejects -retained/foreign/late replies and always releases subscriptions. +## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579) -The pointer-transparent overlay is a child of the same `.devlayer` camera and -stacking context as device markers and room labels. Ordinary plan HTML is -below it; only the exact source and drawable local-neighbour marker roots are -temporarily promoted above it, so complete endpoint markers remain readable -while unrelated markers and room labels cannot cover the diagnostic lines or -bubbles. The overlay owns and clears those namespaced transient attributes, -including after marker DOM replacement; no endpoint state enters config or a -full-card reactive render. +Rewriting the SVG `viewBox` re-rasterises the whole scene, so per-frame writes +made panning crawl. `paintLiveViewport` keeps an anchor (the `viewBox` in the DOM +and when it was written) and moves scene nodes each frame with the HTML layers' +projective transform (`liveLayerProjection`, `transform-origin: 0 0`); +`needsViewBoxRefresh` rewrites the anchor only after `LIVE_VIEWBOX_REFRESH_MS` +(100 ms) or a `LIVE_VIEWBOX_REFRESH_SHIFT` (15 %) shift/scale change — module +constants, not settings. Scene nodes project from the anchor, HTML layers from +the last settled Lit frame; both land on the current view, keeping #451's +one-CSS-pixel marker contract on every frame. `.stage` stays the outer clip, but +a transformed scene SVG gets inline `overflow: visible` so rasterised content +covers the incoming edge, even with the pointer held still (#544); HTML layers +never do. From the first live paint to the terminal commit a scene SVG stays in +one compositor lifecycle: a refresh or Lit frame may replace its anchor but never +demote/re-promote it — HA Companion WebView shows that as a blank frame (#579). +The day-cycle paper outline joins these roots before the first camera move; the +static card uses its stage-sized form from the first frame (#582, Key decision 7). +Unchanged values are never rewritten, so idle frames stay byte-identical; +`commitHouseplanViewport` removes the transforms and forces the final `viewBox`. -`live-viewport.ts` projects the `.devlayer` parent exactly once. The nested -overlay has no independent `data-hp-live-layer="camera"`, then recomputes its -screen-space geometry from the already projected marker centres on the -terminal `viewKey` frame. Unknown-LQI local links are two coincident dashed -non-scaling strokes: a 4 px `#2e2e2e` casing followed by the existing 2 px gray -core; known-LQI and solid parent routes remain single strokes. Cache data, IEEE -addresses and raw payloads are never persisted, logged, exported or admitted -to support diagnostics. - -## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579, 2026-09-14) - -Rewriting the `viewBox` attribute is not a move, it is a repaint: the whole SVG -scene is re-rasterized. Doing it once per gesture frame is what made panning -crawl on the owner's machine — the frame reached the screen in 200 ms and the -refresh driver skipped 124–144 ticks per second marked "waiting for paint". - -So `paintLiveViewport` keeps an anchor: the frame whose `viewBox` is currently -written into the DOM, and when it was written. Every gesture frame moves the -scene nodes by the same projective transform (`liveLayerProjection`, -`transform-origin: 0 0`) that already moved the HTML layers — a composited move, -no repaint. The `viewBox` is rewritten only when `needsViewBoxRefresh` says so: -`LIVE_VIEWBOX_REFRESH_MS` (100 ms) has passed, or the view shifted by -`LIVE_VIEWBOX_REFRESH_SHIFT` (15 %) of its own size on either axis, or the scale -changed by as much. The time budget covers ordinary dragging; the shift budget -covers a flick, where the plan can travel half a screen before 100 ms is up and -an empty band on the leading edge would become visible. Both are module -constants, not settings. - -The two projections have different bases and must stay that way: scene nodes are -projected from the anchor (what is drawn now), HTML layers from the last settled -Lit frame (their content is positioned in percentages of that view). They land on -the same current view, which is what keeps the #451 contract — a marker within -one CSS pixel of its place in the scene — true on every frame of the gesture. - -There are deliberately two clipping levels (#544). `.stage` remains the outer -clip for the card, but a camera or floor scene whose anchor is being transformed -temporarily gets inline `overflow: visible`. That lets the already rasterized SVG -cover the incoming edge until the budgeted `viewBox` catches up; otherwise the -root SVG clips at its stale viewport and exposes a band of stage background. -HTML layers never receive this exception. From the first live paint through the -terminal commit, every scene SVG stays in one compositor lifecycle: even an -identity projection remains explicit and retains `transform-origin`, -`will-change: transform` and `overflow: visible`. A budget refresh or a full Lit -frame during an active gesture may replace the anchor `viewBox`, but may not -demote and re-promote the scene; HA Companion WebView can present a white or -transparent frame at that ownership boundary (#579). Only the terminal commit -removes the inline styles, so a settled scene returns to its ordinary authored -state. The coverage contract also holds when a pointer is held still between -frames: the fast frame must contain every scene pixel that a forced target -`viewBox` would contain inside `.stage`. - -Before the first direct or animated camera movement, the full card activates -the safe day-cycle paper outline as one of those scene SVGs (#582). Its root -CSS box is stage-sized and owns the triple -drop-shadow and `will-change: filter`; its child is only an exact paper alpha -silhouette. The visible paper loses its filter and the plan root receives an -explicit stage-sized transform layer, so Chromium never rediscovers it as an -implicit overlap layer. During a gesture the same floor/camera projection and -budgeted `viewBox` updates are applied to both roots. A fresh idle full card -keeps the historical inner outline for exact reviewed pixels. The static space -card has no camera lifecycle, so it uses the stage-sized sibling from its first -frame and never creates a coordinate-sized filtered paper group. This keeps -#532's isolated raster path without moving a 4700×4200-style footprint. - -Neither the attribute nor an equal style property is written when its value is -unchanged: an idle frame must leave the DOM byte-identical, or the settled raster -shifts by a few colour levels and golden frames flap. `commitHouseplanViewport` -still ends the gesture the same way — transforms removed, final `viewBox` -forced in. - -## The initial bundle carries English and Russian whole (#400, 2026-08-31) +## English and Russian ship whole (#400) `en` and `ru` are synchronous dictionaries in the initial chunk; `de` and `fr` -load lazily (`src/i18n/registry.ts`). That includes strings only ever shown in -the editor — the 38 settings-help entries of #86 among them — and the question -of splitting them out was raised by the v1.70.0-beta.1 audit. +load lazily (`src/i18n/registry.ts`), editor-only strings included. At the +decision the 38 settings-help entries of #86 cost 2 654 B gzip (0.9 % of the +initial-View budget); splitting would need a second dictionary half, a runtime +merge, an extra request and a second source for the `en.json` key type (#391). +Budget planning assumes whole dictionaries; revisit only if editor text grows by +tens of kilobytes. -Measured before deciding: those 38 entries are 11 818 B of raw text but -**2 654 B gzip** inside a bundle of 47 754 B of dictionaries — 0.9 % of the -300 000 B ceiling. Splitting them would mean cutting a synchronous dictionary -in two, merging the halves at runtime, a second network request the first time -a hint is opened, and a second source of truth for the key type derived from -`en.json` (#391). That is a lot of new machinery, in the area that #352–#355 -had to stabilise, for 2.6 KB. +## Backend quality gates (#42) -So the decision is deliberate, not an oversight: **English and Russian ship -whole**. Budget planning assumes it. If the editor's text ever grows by tens of -kilobytes, revisit this — the number above is what makes it worth revisiting, -not the feeling that "editor text should be lazy". - -## Backend quality gates (#42, 2026-08-30) - -- `tests_backend/requirements.txt` is the single source of backend CI - dependencies (validate.yml and mutation-gate.yml install from it; the file - itself was introduced by #392, which also moved the harness to python 3.14 - and the current Home Assistant — #42 adds ruff and mypy to it for the lint - and typing steps). -- `pyproject.toml` configures ruff (E/F/B/I, E501 excluded by decision) and - mypy strict for a grow-only allowlist of pure modules; the completeness - guard lives in `tests_backend/test_backend_quality.py`. -- Writing into `sys.modules` from a backend test is refused by - `test/backend-test-hygiene.test.mjs` — by the fact of the write, not by its - spelling (#398). Two files are named exemptions: `conftest.py`, whose stub is - conditional on Home Assistant being absent, and `pure_imports.py`, which - registers a module only for the duration of `exec_module` and removes the - whole `custom_components` difference afterwards — the removal itself is - proven by an executable test, because the static guard cannot see it. -- Both linters RUN in the backend CI job: the typing step derives its module - list from the `pyproject.toml` allowlist rather than repeating it, refuses an - empty list, and is itself guarded by a test plus the `typing-gate-stops- - running` mutant — a configured-but-unexecuted gate measures nothing. -- The backend CI job measures branch coverage (pure + HA harness combined), - fails below `scripts/backend-coverage-baseline.txt` and refuses to run when - the HA harness would silently skip. -- The junction-limit mirror has a separate clean-runner `geometry_parity` job - (#548). It compiles only the transitive TypeScript graph rooted at - `src/junction-limits.ts`, loads the production Python module without Home - Assistant and compares both over `test/fixtures/junction-limits-parity.json`. - The job fails closed on missing build/runtime prerequisites, announces the - number of executed scenarios, and reuses a result only when both mirrors, - fixture, toolchain pins and harness inputs are byte-identical. -- `const.ERROR_CODES` / `ERROR_CODE_FAMILIES` are THE stable error contract: - the scanner test proves every emitted code (send_error literals, exception - class attrs, literal and variable-passed MarkerControlError codes, f-string - families) is registered and localized; `invalid_passage_fields` and - `invalid_partition_opening_jamb_margin` ship structured JSON details, and - the frontend renders unknown codes localized (code-first, raw messages go - to the console). +`tests_backend/requirements.txt` is the single source of backend CI dependencies; +ruff, strict mypy, the `sys.modules` guard, the coverage baseline and the +`geometry_parity` job: [TESTING](TESTING.md) › Backend quality gates. +`const.ERROR_CODES` / `ERROR_CODE_FAMILIES` are THE stable error contract: every +emitted code is registered and localized (scanner test); `invalid_passage_fields` +and `invalid_partition_opening_jamb_margin` carry structured JSON details; the +frontend renders unknown codes localized, code first, raw messages to the console. ## Summary panel boundary (#437) -`settings.summary_panel` is the only shared persistence for the read-only -summary overlay. The backend validates its bounded version-1 shape, preserves -omission from older ordinary writers, and performs change-aware checks for new -space/entity references under the calling user's read permissions. The -`summary_panel_api` value returned by `config/get` gates shared editing; cached -configuration cannot manufacture that capability. Unknown future versions are -stored but never executed or rendered by an older frontend. - -`summary-panel.ts` owns deterministic defaults, stable-id validation, -native-narrow/fit predicates and local-key encoding. `summary-panel-picker.ts` -owns a non-DOM index of the current `entity_id + friendly_name` composition. -It reuses that index for state-value changes and exposes only 100 search -results to the one active picker. `summary-panel-runtime-loaded.ts` is the lazy -View controller: it measures the actual `.stage`, renders a screen-space -sibling of the camera layer, owns at most one boundary-aligned minute timer, -and keeps browser-local show/icon/text preferences separate by HA user, route, -host and logical card slot. The resolved summary-local key is the scale -authority; the legacy kiosk key is only a first-load seed. A lifecycle -generation binds dialog, draft, picker, index and async continuations to one -route/user/permission/kiosk identity. `summary-panel-editor.ts` is dynamically -imported only after the settings button is pressed. It edits one draft; a -successful revision-checked shared write precedes application of the draft -local show choice. - -`summary-panel-identity.ts` resolves a native Masonry slot from the top-level -card element's index in `hui-masonry-view.cards`, which remains in dashboard -config order while HA moves those elements between responsive visual columns. -A nested stack/conditional appends only its composed descendant path below -that canonical top-level card. A new `masonry-v2` marker separates these keys -from the old ambiguous DOM paths. If the native view exists but its canonical -array or matching ancestor is not available yet, the runtime keeps preferences -session-only and does not read or write a guessed persistent key. - -`summary-runtime-loader.ts` (#506) separates the summary *code* from its -*state*. The loaded factory is remembered per page; every host builds its own -runtime from it — preferences, drafts, subscriptions, timers and DOM are never -shared between card instances. Once the factory is warm, a new instance -(a warm remount or a cold card on a warm page) receives its runtime -synchronously in `connectedCallback`, before the first Lit render, so the -first header measurement already includes the summary controls and no late -summary-driven refit or `stage-resize` continuity candidate follows. The chunk -itself stays lazy: the first cold mount pays one dynamic import, concurrent -cold mounts share that pending import, a failed import is forgotten so the next -connection may retry, and a disconnect cancels the pending attachment of that -connection so a late resolution cannot connect a runtime to a host that has -left the tree. A same-node reconnect keeps its instance. - -The #505 designer-aligned surface stays in the lazy summary graph. Its sheet -includes the settings-only composition from `summary-panel-editor-style.ts`. -The runtime installs `summary-panel-dialog-style.ts` in each summary dialog's -shadow root, scoped to `data-kind="summary"`, without growing the eager shared -`hp-dialog` graph or styling other dialogs. -`summary-panel-presentation.ts` owns transient hidden/entering/visible/exiting -phases, never persistence. One 190 ms opacity/translate animation and bounded -cleanup timer retain an inert outgoing node, snapshot interrupted motion, and -discard stale completions. Reduced motion, lifecycle invalidation, backgrounding -and an ineligible layout settle immediately. The stage camera is not animated -or remeasured as a consequence of toggling panel visibility. Size preferences -remain owned by the existing local key; their controls are no longer in the -summary form. Its mobile checkbox is disabled under draft local-off without -clearing the stored shared value. - -The overlay is not SVG, does not enter camera/content bounds, and performs no -HA actions. Device totals require an authoritative per-connection registry -snapshot and deduplicate real parent device ids before visual filters. Clean -area unions canonical room floors (including holes) per space and converts -each space with its own `cell_cm`; its cache is independent from the registry -cache. Entity values remain in the current user's `hass.states` and use the -existing HA formatting boundary. Editors and `houseplan-space-card` never load -or render the panel. - -`prepare_ordinary_summary_candidate()` is the common backend boundary for -`config/set` and `plan/optimize`: it preserves an omitted stored namespace -before the endpoint-specific schema/migration sequence, then applies the same -change-aware reference validation against the caller's readable-entity -snapshot. Full import/restore deliberately bypasses this ordinary-writer -helper because the archive is authoritative. +`settings.summary_panel` is the only shared persistence of this read-only overlay; +`prepare_ordinary_summary_candidate()` is the common backend boundary of +`config/set` and `plan/optimize` (full import bypasses it), and +`config/get.summary_panel_api`, never cached config, grants editing. Persistence +and local keys: [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md) › Summary panel namespace. +`summary-panel.ts` owns defaults, stable ids, fit predicates and local-key +encoding; `summary-panel-picker.ts` a non-DOM `entity_id + friendly_name` index +reused across state changes; `summary-panel-runtime-loaded.ts` the lazy View +controller — a screen-space, non-SVG sibling of the camera layer outside content +bounds, with one minute-aligned timer and a lifecycle generation binding dialog, +draft, picker and async work to one route/user/permission/kiosk identity. +`summary-runtime-loader.ts` shares code, never state, and attaches a warm runtime +before the first render ([#506](specs/506-startup-performance.md)). +`summary-panel-editor.ts` loads on the settings button; its revision-checked +shared write precedes the local show choice. #505 styles and the 190 ms phases +stay lazy and never move the camera; editors and the space card never load the +panel. Device totals need an authoritative registry snapshot and dedupe parent +device ids before visual filters; clean area unions canonical room floors per +space with that space's `cell_cm`. ## Private support boundary (#43) -The Help & feedback surface is rendered by the lazy editor runtime even when -the card remains in View. Its form state is component memory only. About and -language-routed User Guide links need no backend; report controls are exposed -only when `houseplan/config/get.integration_version` exactly matches the card. - -`houseplan/support/preview` takes bounded frontend capability enums and a -dialog-scoped random id. Under the shared write lock it loads one coherent -config/layout pair, validates disposable copies and passes them to -`support_package.py`. That module is a strict projection boundary: it creates a -new allowlisted object with package-local pseudonyms, canonical sorted JSON and -a trailing newline. It never serializes raw storage and then redacts it. - -The resulting bytes, SHA-256 and expiry are held in `HouseplanData` memory, -bound to the HA user and draft for ten minutes. The browser preview, JSON -download and submit all use those exact bytes. Refresh replaces only the same -draft; discard and confirmed submit consume the token. Authorization uses the -same `may_write` policy as every House Plan write. - -`houseplan/support/submit` validates text again, resolves only an owned live -token and calls `support_transport.py`. That transport has one compile-time -HTTPS URL, disables redirects, bounds timeouts/response bytes and maps every -remote failure to a stable local code without reflecting response content. -The relay under `scripts/support-relay/` is independently deployable and is -excluded from the HACS artifact. It spools before private delivery, enforces -rate/idempotency limits and is purged on the schedule documented in -`docs/SUPPORT-PRIVACY.md`. - -The three WebSocket commands are: - -| Command | Input authority | Result | -|---|---|---| -| `houseplan/support/preview` | `may_write`, strict facts + draft id | opaque token, TTL, byte count, SHA-256 and exact JSON text | -| `houseplan/support/preview/discard` | `may_write`, token owner | idempotent cleanup | -| `houseplan/support/submit` | `may_write`, text + optional owned token | bounded private report id | +Help & feedback is rendered by the lazy editor runtime even in View; form state +is component memory only. Report controls appear only when +`config/get.support_api` equals `SUPPORT_API_VERSION` (1); release versions are +diagnostic. `houseplan/support/preview` (`may_write`, bounded capability enums, +dialog-scoped id) loads one coherent config/layout pair under the shared write +lock and passes disposable validated copies to `support_package.py`, a strict +projection boundary: a new allowlisted object with package-local pseudonyms and +canonical sorted JSON, never raw storage redacted afterwards. Bytes, SHA-256 and +expiry stay in `HouseplanData` memory, bound to user and draft for ten minutes; +preview, download and submit use those bytes; `preview/discard` (idempotent) or a +confirmed submit consumes the token. `houseplan/support/submit` re-validates text, +resolves only an owned live token and calls `support_transport.py`: one +compile-time HTTPS URL, no redirects, bounded timeouts and response size, stable +local failure codes that never reflect the response. The relay +(`scripts/support-relay/`) deploys separately and is excluded from the HACS +artifact. Content and retention: [SUPPORT-PRIVACY](SUPPORT-PRIVACY.md). ## Mutation tooling boundaries (#558) -The stable CLI remains `scripts/mutation-gate.mjs`, but it is only an -orchestrator and compatibility export surface. Mutation declarations live in -`scripts/mutation-registry.mjs`; diff/guard-input selection in -`scripts/mutation-selection.mjs`; witness fingerprints and the caught ledger -in `scripts/mutation-evidence.mjs`; worktree mutation execution in -`scripts/mutation-execution.mjs`. Dependencies point from the CLI toward these -boundaries, never from the registry or executor back to orchestration. - -Guard-input caching is invocation-scoped. One resolver owns one tracked-file -snapshot and one result per exact guard string; creating a resolver is the -cache boundary for another source tree/material. Selection and ledger -fingerprinting share that resolver during one plan, while persisted success -continues to exist only in the explicit caught-witness ledger. +`scripts/mutation-gate.mjs` stays the stable CLI but is only an orchestrator and +compatibility export surface over `mutation-registry.mjs` (declarations), +`mutation-selection.mjs` (diff/guard-input selection), `mutation-evidence.mjs` +(witness fingerprints, caught ledger) and `mutation-execution.mjs` (worktree +runs); dependencies never point back to the CLI. Guard-input caching is +invocation-scoped (one resolver, one tracked-file snapshot); persisted success +exists only in the explicit caught-witness ledger. Usage: [TESTING](TESTING.md). diff --git a/docs/CANVAS.md b/docs/CANVAS.md index afa5e43b..0c2da9f5 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -42,8 +42,10 @@ A finer grid changes precision only. Raw SVG constants inherited from the historical 5 cm renderer are **visual units** and use `gridVisualScale(cell_cm) = 5 / cell_cm`. Physical sizes already converted from centimetres, screen-fixed strokes/handles, plan-relative icon sizes and the -grid pitch must not receive that factor again. Full, static and hidden -isometric renderers share this classification. +grid pitch must not receive that factor again. Full, static and 2.5D +renderers share this classification. Full and static roots expose the factor as +`--hp-cell-visual-scale`; 2.5D heights and user-space shadows include it in +their structural cache inputs. ### Persisted coordinate canonicalisation @@ -71,6 +73,10 @@ startup-recovery writers. A canonical read/write echo is a no-op: optimistic locking is still checked, but the revision, update event and maintenance Undo snapshot do not move. Existing stores are not rewritten on read; Optimize Plans remains the explicit bulk-cleanup path. +`scripts/coordinate-write-barrier-guard.mjs` inventories every outbound +config/layout writer and permits direct plan-Store writes only inside +`async_save_config_state()`/`async_save_layout_state()`; the trail recorder's +operational Store is the one explicit exception (#291). ## Model @@ -374,6 +380,12 @@ from `--dev-size`. The full card and the static no zoom, but its frame is the content now, so a bare `iconPct` would have made its markers shrink as the frame tightened. +Public `icon_size` (default 2.5, UI range 1–6) is a compatibility unit: a +value > 8 is a legacy pixel size and falls back to 2.5, and the surface boundary +converts it through `effectiveDeviceBaseSize()` (2.5 → 2.25, #212/#213) before +`device-face.ts` sees it; the face applies no late visual factor. Marker sizes +are `cqw` inside `.stage { container-type: inline-size }`. + **Auto-placement spacing** (`defaultPositions` -> `declump`) is measured in render units and uses the same `iconUnit`, so the icon's footprint and the distance markers are pushed apart by can never drift apart — and diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index d67e3a4b..2e1422a2 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -23,6 +23,24 @@ this registry resolves to a path of the generated schema manifest or to an explicit passport. A field missing here is therefore a documentation gap, not an unknown schema. +## Schema manifest and parity (#33) + +The Voluptuous schema in `custom_components/houseplan/validation.py` is the only +owner of the persisted config/layout shape. `scripts/dump-config-schema.py` +walks it into the deterministic `scripts/config-schema.json`; +`tests_backend/test_config_schema_manifest.py` regenerates the manifest and fails +on uncommitted drift. `test/config-schema-parity.test.mjs` compares manifest +enums with the exported frontend lists (`DISPLAY_MODES`, `TAP_ACTIONS`, +`SPACE_FILL_MODES`/`ROOM_FILL_MODES`, `OPENING_TYPES`, `VACUUM_TRAIL_MODES`, +`ZERO_WALL_STYLES`, `BG_MODES`, `SUN_RAY_ORIGINS`). Every divergence is blessed +in `scripts/schema-compat-allowlist.mjs` with a reason and an owning issue; an +entry that no longer matches a real divergence fails the test, so the list +cannot rot. Registry entries resolve to a manifest path or carry an explicit +`schema: 'allow-extra' | 'lovelace-card'` passport; implemented mechanisms cite +their code point in `enforcedBy`. `test/fixtures/config-lifecycle/` +(`oldest-supported`, `current`, `future-fields`) pins the load contract: each +passes the schema losslessly and future fields round-trip exactly. + ## Offline inventory Exported JSON can be inspected without uploading it or changing it: @@ -65,6 +83,7 @@ revision. Without a server-issued token, an old client and a stale concurrent writer produce the same request; accepting either would reopen last-writer-wins data loss. This changes only the WebSocket write contract. Stored config, model/store versions, exports and read compatibility are unchanged. +The same rule applies to `layout/set` (#356). ## Crash-resumable config/layout pairs (#491) @@ -170,6 +189,17 @@ clock values are browser runtime data and never enter server config, exports or support packages. `config/get.summary_panel_api === 1` is a runtime capability, not persisted user configuration and not a store/model version bump. +Browser-local summary preferences are keyed by HA user, route, host and logical +card slot; the resolved summary-local key is the scale authority and the legacy +kiosk key only seeds its first load. A native Masonry slot is the top-level +card's index in `hui-masonry-view.cards` (dashboard config order, stable while +HA moves cards between responsive columns); a nested stack/conditional appends +only its composed descendant path. The `masonry-v2` marker separates these keys +from the older ambiguous DOM-path keys (#561), so earlier per-card Masonry +choices may need to be set again. While the canonical array or matching ancestor +is not available yet, preferences stay session-only and no guessed persistent +key is read or written (`summary-panel-identity.ts`). + The writer authority is explicit: | Writer | Summary-panel authority | @@ -577,6 +607,8 @@ unrelated marker field preserves the literal `cover` token; once the user edits the action selector, the current canonical `toggle` token is written. The UI never creates new `cover` values. Unknown or unavailable cover capabilities remain a safe no-op and are never replaced by a guessed service call. +An absent action on a primary `light.*` likewise stays absent on an untouched +Open → Save. The universal `toggle` resolver uses the current HA registry as its capability boundary. A disabled, orphaned or not-yet-verified device target is therefore @@ -1032,3 +1064,28 @@ does not absorb this storage-only work. One undo is available until the next config or layout edit. It restores the stored snapshot; re-running optimization itself is never treated as undo because a grid projection is not invertible. + +## Square canvas and legacy `aspect` (v1.48) + +Until v1.48 a space stored `aspect` and coordinates were normalised x by width, +y by height. The render space is now `NORM_W × NORM_W`; a plan image is fitted +by its own ratio (`fitInSquare`) and that ratio is stored as `plan_aspect` so +the layout does not jump before the file loads. Setup still upgrades a legacy +`aspect` once through `geometry_migration`: the box is padded to a square and +every coordinate re-expressed as one uniform scale plus offset in render units +(angles and proportions exact), with `cell_cm` scaled for tall plans. A durable +`geom_pending` intent is written to the layout store before the config half +removes `aspect`; the next start finishes whichever half is missing +(HP-1490-01), and update events fire only after both halves are durable. +Installations stranded before that intent existed are repaired only explicitly +with `houseplan/geometry/repair` (HP-1500-01). + +## Legacy content URLs + +`/houseplan_files/…` is a public static path for frontend code only (card, +panel, lazy chunks), because a Lovelace resource must load without +authentication. Plans and marker files are served only by the authenticated +`HouseplanContentView` (`/api/houseplan/content//…`, signed URLs). +Stored configs may still hold older `/houseplan_files/plans|files/…` URLs: +`contentUrl()` rewrites them on every read and portable import accepts both +prefixes, so there is no storage migration. diff --git a/docs/DECOR-EDITOR.md b/docs/DECOR-EDITOR.md index d5fbc865..44c11463 100644 --- a/docs/DECOR-EDITOR.md +++ b/docs/DECOR-EDITOR.md @@ -361,6 +361,23 @@ replace it without losing position, size, rotation, mirror or layer order. Export v2 records hashes and availability but never embeds image bytes; an import with missing bytes requires confirmation and preserves that placeholder. +**Backend invariants.** Raster input is fully decoded and SVG passes a strict +allowlist before promotion. `houseplan/assets/resolve` takes its reference +snapshot under the config write lock, does file I/O after releasing it and reads +only the requested sidecars. Resolve and HTTP GET share one memory-only +`AssetIntegrityVerifier` per HA instance: streamed SHA-256, at most 256 digests +keyed by canonical path plus size/mtime/ctime, per-file-version single-flight +with a bounded follower wait. Both stat signatures must be a regular file, so +bytes changed mid-read never enter the cache; missing, changed, non-regular or +corrupt files fail dark. The frontend `ContentSigner` batches `` +signatures. Quota counts every regular `` blob by actual +size, including blobs with absent or malformed sidecars; a sidecar without a +blob does not count, and entries vanishing mid-scan are skipped. Delete rechecks +references across every space under the config write lock and removes only the +exact sidecar and allow-listed blob names under the reference/upload locks: +never a prefix, temp file, directory or unknown extension. There is no automatic +orphan collector. Failed resolve transport calls are not cached. + ## 8. Code ownership | Concern | File | diff --git a/docs/DEVICE-PRESENTATION.md b/docs/DEVICE-PRESENTATION.md index 49b15626..079eefe7 100644 --- a/docs/DEVICE-PRESENTATION.md +++ b/docs/DEVICE-PRESENTATION.md @@ -19,6 +19,10 @@ cover → light sources → device role, шторы и медиаплееры) - `device-pulse.ts` — единственный владелец эффекта activity. - `device-face.ts` только рисует готовую проекцию. - View, static card и preview получают один `ResolvedDevicePresentation`. +- Черновик диалога маркера проецируется `deviceFromMarkerDraft()` через + `buildDevices` по полному сохранённому roster, где заменён только + редактируемый маркер; поэтому владение целями, tombstones и лицо контроллера в + preview совпадают с планом (#274). `decisionIds` — внутренний bounded trace. Он не показывается человеку, не содержит entity IDs и не сохраняется в конфигурацию. @@ -106,6 +110,46 @@ cover → light sources → device role, шторы и медиаплееры) только тогда, когда меняет победившее решение или наблюдаемый результат; полное декартово произведение binding × source × display × activity запрещено. +## Implementation notes + +- `device-value-badge.ts` owns candidate discovery, source keys, HA formatting, + units and unavailable handling for `marker.value_source` (inner Value face) + and `marker.value_badge` (satellite). Absent `value_source` keeps the legacy + automatic face resolver; absent `value_badge` keeps the legacy + temperature/humidity satellite. Bottom badges stack above the system LQI row; + a derived-LQI badge or value source suppresses that row. +- LQI: `devices.ts` averages Z2M `*_linkquality`, ZHA `*_lqi`/unit `lqi` sensors + or a `linkquality`/`lqi` attribute. `logic.ts::lqiColor()` maps 40→180 to hue + 0→120; `markerLqiColor()` delegates to it; `markerLqiBand()` is marker-only + accessibility metadata. +- Face geometry: the saved coordinate is the icon-core centre; Text is + shell-centred and a Double shell extends around the anchored core. The + 101.5/80 shell/core ratio (`--device-shell-size`), shared centre, Light/Dark + context, full-text fitting and the 44×44 core-centred interaction floor are + renderer facts, not surface DOM. A positioned shell frame owns the whole + visible capsule hit area and bubbles to the marker's one action path; + Enter/Space call the same `_clickDevice()` path as pointer activation. +- Hit ownership: painted shells share one layer above all invisible 44 px + floors, so DOM order never decides. `device-hit-owner.ts` resolves the owner in + screen coordinates (painted capsule, else nearest core, stable id tie-break) + from a small spatial index measured only after render/resize invalidation, + never per pointer move, and latches it from pointerdown through + hover/action/long-press/context menu and Devices-editor drag. +- Pulse storage: `ripple_color`/`ripple_size` remain the stored names for every + pulse kind (alarm keeps safety red); absent size is 1.5; explicit colour/size + wins, then live RGB, then presence-green/work-amber/transition-blue. The + backend accepts legacy `display: ripple` only for compatibility; + `normalizeDeviceDisplay()` maps it to `icon_ripple`. +- Activity baselines are seeded as soon as a rebuilt registry becomes + authoritative, before the next HA snapshot is classified; a source-key change + resets any finite effect immediately. +- The marker dialog builds its draft through `buildDevices`; `hp-device-preview` + shows the actual projection, integration provenance from registry/config-entry + metadata and isolated short/continuous demonstrations, fitting and centring the + complete face bounding box so satellites never clip. +- Glow never replaces the yellow working plate: a light pool is spatial + information, not a status indicator. + ## Source precedence: what a marker shows A marker's live indication — status plate, state-morphed icon and semantic @@ -195,6 +239,22 @@ only when that same result selected the cover, so the option, hint, service call and state shown cannot disagree. A no-target or unsupported result falls through to ordinary light/device-role presentation and never invents a service target. +### Action authority (#94, #381) + +`src/device-toggle.ts` is the only authority for Toggle: origin, exact target, +capability/security filtering, next effect and service command. The dialog hint, +click path, confirmation re-resolution and cover presentation consume the same +immutable `resolveToggleIntent` result. Exact `entity:` bindings never retarget +to siblings, persisted `controls` never fall back to the controller's own +entity, and secure targets are explicit no-ops. `POWER_ADAPTERS` is the explicit +domain allow-list and carries per-entity HA feature masks where a domain-wide +service is no capability proof; the HA service catalog is a second fail-closed +guard. `_clickDevice()`, shared by pointer and Enter/Space, stops propagation, +re-resolves the current marker by stable id (a retained #73 visual snapshot is +read-only presentation) and returns on explicit `tap_action: none` before +capability lookup, confirmation, cards/toasts, press feedback or HA dispatch; +hold and context-menu handlers stay independent. + ### A media player is powered, not "working" (owner 2026-08-07) The resolved role stays `media_player.*` for every TV, receiver, speaker and diff --git a/docs/FILTERING.md b/docs/FILTERING.md index 3c0e5a50..93dd905f 100644 --- a/docs/FILTERING.md +++ b/docs/FILTERING.md @@ -52,6 +52,15 @@ as the SEEDER of initial hidden flags. "Group", scene-like models, bridges, myheat children, and individual lamp devices in an area covered by a light group (when group folding is on). +The two inputs of that rule are visible settings, not hidden knobs (#44): +`settings.group_lights` and `settings.exclude_integrations` are edited in the +Devices catalog's **Discovery filters** section. One resolver, +`effectiveExcludedIntegrations()` (`devices.ts`), feeds discovery, the +materialisation seed and room climate on both cards; the section's preview diffs +the real `seedHiddenBindings()` and `buildDevices()` outputs for current and +draft settings, so no second copy of the filter logic exists. Both fields are +`current` in the field registry (#33). + The seeder runs on the editing client (write permission required) whenever devices rebuild, and creates `hidden: true` stub markers for non-physical devices in BOUND areas that have NO marker. It is idempotent: marked devices @@ -89,6 +98,11 @@ the old behaviour until an editing client materialises it. `device-inbox.ts` projection. Exact bindings stay in one user-intent category (`on_plan`, `available`, `hidden`, `readd`); HA disabled/orphaned/unverified is an independent operational status and never silently moves a row. + The binding picker and the catalog share the pure `bindingCandidates()` + eligibility helper; filtering and paging run only after the full candidate + snapshot, so large registries cannot hide later exact entities. + `buildDeviceInbox()` combines runtime devices, markers, tombstones, HA binding + statuses and `new_device_ids` without owning persistence or Lit state. - **Show hidden on plan** in that catalog is LOCAL, ephemeral state of the current editor session. A disabled ghost is grey and explicitly labelled; it cannot be dragged or shown until the binding is activated in HA. Its diff --git a/docs/FURNITURE.md b/docs/FURNITURE.md index 7d7467bb..eb8ac0f5 100644 --- a/docs/FURNITURE.md +++ b/docs/FURNITURE.md @@ -161,6 +161,9 @@ be loaded, **nothing** is drawn until the page reloads, and a toast says so once. Before #593 the 12 primitive symbols drew regardless; that promise was withdrawn deliberately when they became designer artwork, so the failure now behaves the same way for all 60 pieces instead of for 48 of them. Front-view -menu art is imported only after the editor runtime is requested. Touch View/kiosk support is +menu art is imported only after the editor runtime is requested. The editor +hands its statically imported drawings over synchronously (`adopt`), a chunk +from another build counts as a failed load, and the boot veil waits for the +artwork only up to the card's `BOOT_MAX_MS` cap. Touch View/kiosk support is blocking; editor ergonomics on touch remain best effort under `docs/TOUCH-SUPPORT.md`. diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index 64fc43f0..f919b6b2 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -107,6 +107,12 @@ data. The saved iso preference is retained; an explicit iso request retries the fingerprint, and changed geometry receives a new fingerprint. Flat rendering is the rollback path and does not depend on the iso cache. +Internal fail-closed evidence, not public API (`STYLING-HOOKS.md` §7.7): +`.stage[data-hp-iso-stage="4"]` with `data-hp-iso-structural-builds`, +`data-hp-iso-overlay-kind|raised|nudged` on low-plane roots, and +`data-hp-iso-material-def` on shared material definitions. The structural LRU is +`_isoGeometryCache`. + ## Limits - No volumetric editor and no volumetric `houseplan-space-card`: editors and @@ -177,6 +183,8 @@ material config, network request or HA service path. door has one jamb-hinged leaf, gate has two leaves with the established 0–10° exterior-face turn, and window has two light neutral casements. A saved `passage` keeps the same full-height masonry cut but has zero leaves/panels. +Door/gate leaves are matte prisms `0.04H` thick with state-independent +full-depth reveals. Heights are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field. The saved opening axis and Flat symbol remain on their canonical centreline. Derived 2.5D door/gate leaves pivot on the selected physical host @@ -216,6 +224,9 @@ The camera is orthographic `rotDeg=0`, `tiltDeg=20`, with the `[500,500]` pivot and scale-aware 84-unit wall height. Floor SVG, wall/opening projection, inverse hit mapping, invisible collision footprints and fit bounds share that one affine authority. +`isoPlaneMatrix()` (`src/iso-projection.ts`) is that authority. The projected +frame also includes the low overlay plane; blur and shadow extents never enter +fit. Device markers, room labels/cards and opening-lock badges keep their canonical floor anchors but render on a low plane four visual units above the floor. @@ -226,6 +237,16 @@ the affine projection of the Flat layout rather than a per-marker fan toward a room safe point. Room labels never enter a cluster and stay below interactive roots. +`src/iso-overlays.ts` is the pure placement boundary. A device accepts its +explicit room only when that room strictly contains its floor anchor, otherwise +the smallest strictly containing room (stable id tie-break); room labels use +their own room; lock badges inherit the physical room side selected by +opening-host geometry. Wall clearance uses a 4 CSS px safety gap +(`ISO_OVERLAY_SAFETY_GAP_CSS_PX`), and every candidate path must stay strictly +inside the owner and outside its island holes. Boundary candidates are evaluated +on the integer CSS-pixel lattice through a bounded spatial grid (#585), so +sub-4 px legal slits are found without an all-pairs or disk scan. + The reference-fit view, not the current live view, converts CSS safety values into scene units. Wheel/button zoom, pinch and pan therefore transform an already resolved scene and cannot invalidate placement. Structural changes — diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 2c59a0b8..29c75a2a 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -123,6 +123,17 @@ A shadow currently keeps no light at all. This follows from "objects do not let light through" and is the owner's standing decision; a residual term would be one constant if a pitch-black corner next to a bright area ever needs softening. +The formula is `paletteAlpha × 0.7 × (0.4 + 0.6 × bri^(1/2.2))` +(`GLOW_SCALE_MAX`, `GLOW_MIN_FRAC`, `GLOW_GAMMA`); `resolveGlowAppearance()` +supplies the marker-owned live or manual colour and brightness, and the radius +is `settings.glow_radius_cm` unless the marker sets `glow_radius_cm`. Sources add +up (#19): each circle keeps its own gradient and clip, and all circles share one +isolated parent with no outer opacity and `mix-blend-mode: screen`. Screen +blending is enabled only after a cached per-`Document` raster probe +(`src/glow-blend.ts`) proves real SVG pixels; pending, unsupported, error and +timeout states render normal blending, and a successful probe requests one +update. + ## Penumbra One SVG `feGaussianBlur` over the whole light layer, sized in SCREEN pixels @@ -359,6 +370,20 @@ cycles and cross-space transfer — `tests_backend/`. - Golden: `lighting-opaque-glow-two-doorways-dark` and the other `lighting-*` scenes, re-shot and approved 2026-08-11. +## Glow over the data fill (#55) + +Glow is an overlay, not a fill mode: space `settings.glow_enabled` and room +`settings.glow` (`null` inherits) are independent of the data `fill_mode`; the +legacy `fill_mode: 'glow'` projection is in `CONFIG-COMPATIBILITY.md`. Floor +order is paper → resolved data room/tunnel fill → Glow base → decor +(`DECOR-EDITOR.md` §3.3) → radial pools → sun and interactive layers. The dark, +pointer-free Glow base is painted only for rooms whose fill resolver returned +nothing or a fully transparent colour: explicit `none`, a zero-opacity +`custom`, a dynamic mode without usable data. A resolved `lqi`, `light`, `temp` +or `custom` fill never receives it, so its exact colour and alpha stay visible. +Pools render independently of the base; the static card uses the same data/base +projection and omits empty base groups. + ## Which surfaces render pools The full `houseplan-card` always renders pools for rooms where Glow is enabled. diff --git a/docs/PDF-EXPORT.md b/docs/PDF-EXPORT.md index 760e929c..02d6c1fa 100644 --- a/docs/PDF-EXPORT.md +++ b/docs/PDF-EXPORT.md @@ -84,3 +84,18 @@ interface languages. The bundled font is distributed under the Apache License The resulting file is named `houseplan--.pdf`. Browser and Home Assistant mobile-app download handling determines its final Downloads location. + +## Implementation boundary + +PDF export is a lazy read-only runtime (`src/pdf/`, `lazyPdfFiles`); View +carries only the administrator trigger and the shared exact-build loader. It +reads the already normalised current space through the same physical-geometry +resolvers as View, produces one deterministic A4 document and never writes +config or layout. Dimension input is normalised before collinear compaction and +keeps only canonical horizontal/vertical edges; text bounds use the writer's +real font metrics and transform; lanes test exact box/segment intersections +against wall rings, never sampled points; parallel facade steps stay in +independent collinear groups. Only exterior extension lines use the +source-aware collision state machine; dimension lines, shelves, labels and +internal dimensions stay on the strict path. Physical walls are even-odd clipped +paths with a page-anchored hatch. diff --git a/docs/RADAR.md b/docs/RADAR.md index 14fc6cc5..761e6211 100644 --- a/docs/RADAR.md +++ b/docs/RADAR.md @@ -98,6 +98,33 @@ the source integration may independently keep their normal entity history. Virtualized plan imports intentionally drop hardware-specific radar bindings. Unknown future radar versions remain preserved but inert until supported. +## Implementation map + +- Persistence: `marker.radar` is saved only by the ordinary revisioned config + transaction. `radar_validation.py` validates only a changed known version; + untouched future versions stay inert. `settings.radar.show_live` is a display + preference and never authorizes discovery, recording or hardware writes. +- Backend: one `RadarCoordinator` (`radar.py`) per integration entry owns exact + HA source listeners, report-time freshness, independent-pair skew, + source/calibration epochs, projection, real-room clipping and bounded public + frames; it reconciles on config revision and closes all listeners/timers on + unload. The explicit profile/source-role inventory (`occupancy_entity`, + `count_entity`, `availability_entity`; Cartesian `x_entity`/`y_entity`; polar + `distance_entity`/`angle_entity`; range/zone `entity_id`; slot/range + `presence_entity`) is the single authority for live listeners, setup listeners + and per-entity read ACLs; unknown future fields never become sources by naming + convention. Only the backend interprets raw HA states. +- Eager View: `radar-model.ts` (frame validation/ordering/leasing), + `radar-live.ts` (one active-space subscription and lifecycle), + `radar-render.ts` (pointer-transparent SVG below ordinary device markers). + Range segments arrive already clipped; an empty list is authoritative and never + falls back to an unclipped arc. Transition eligibility is a server fact: only a + current same-slot step of at most 100 cm in a convex room may animate. +- Lazy editor: `radar-editor.ts` (recognition/draft round-trip), + `editors/radar-section.ts` (marker dialog), `radar-setup.ts` (session-only + on-plan wizard). A two-reference result changes only the open draft; the + ordinary marker Save is the only write. + ## Troubleshooting - **The section is absent:** only positively recognized hardware or an already diff --git a/docs/SCOPE.md b/docs/SCOPE.md index 3089319c..4f22051d 100755 --- a/docs/SCOPE.md +++ b/docs/SCOPE.md @@ -98,6 +98,25 @@ The asymmetry is the whole argument. Wasted disk is visible, cheap and reversible; a deleted file is none of those. Where the evidence is weak, keep the file — and if a future version wants to reclaim that space, it asks. +Collection classifies by **owner**, not by "is it referenced" (HP-1465-01): +`config/set` removes only what its own commit replaced. + +| Case | What it means | Rule | +|---|---|---| +| Space in both, plan A → plan B | the user picked another image | removed immediately | +| Space in both, plan → none | detached; one click undoes it | **kept** | +| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** | +| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them | +| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately | +| Marker gone | same call as a deleted space's plan | **kept** | +| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) | +| Marker there, file it never listed | a rejected upload | **kept**, same reason | + +Nothing is deleted for being old except a per-dialog staging folder (`up_*`) +after `PLAN_ORPHAN_TTL_S`; `houseplan/plans/list` and `houseplan/plans/delete` +(refusing while a space still references the plan) make "we never delete" +livable. + ## Out of scope — never build, point users to the right tool - Automations, scenes, scripts, notifications → HA core. diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index 422824cf..08475b2e 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -39,7 +39,11 @@ above room fills and Glow base and below live Glow, walls, devices and labels `DECOR-EDITOR.md` §1; inert everywhere outside its editor). - **View** is the implicit default state: no editor tab is active. Navigation - persistence remembers only the last space. Reloading the page or leaving + persistence remembers only the last space. Cold-start precedence for an + unpinned card is a valid `#space=` hash → the `LS_NAV` space → `default_floor` + → the first space, each checked against the live model (`resolveInitialSpace`, + `src/initial-load.ts`); a card with `floor` neither reads nor writes `LS_NAV` + (#93, #210). Reloading the page or leaving `/houseplan` (or a dashboard hosting the card) for another Home Assistant route and returning always opens View for that space; editor mode, selection and open editor dialogs are session diff --git a/docs/VACUUM.md b/docs/VACUUM.md index 289dd5cf..666d6790 100644 --- a/docs/VACUUM.md +++ b/docs/VACUUM.md @@ -229,6 +229,35 @@ successful config change, so an interrupted browser-side cleanup is repaired. An initial position sampled during integration startup follows the same debounced persistence and live-update path as a later state event. +## Code ownership + +- `src/vacuum-routes.ts` is the only answer to "which map is on which floor": + `effectiveRoutes()` (explicit `map_routes`, or the legacy `calibration` + dictionary as routes into the dock's space) and `resolveRoute()` (the six + results above, never a guess). The result is computed once per frame into + `render-device-snapshot.ts` (`facts.get('vacuum:')`) so `render()` cannot + derive a second answer; `planVacuumOverlay()` decides what the visible space + draws. +- Route editing lives only in the lazy editor graph (`vacuum-route-edit.ts`, + `editors/vacuum-maps-section.ts`); the View card never loads it. +- `custom_components/houseplan/vacuum_routes.py` mirrors the resolver and the + legacy-run adoption rule byte for byte, driven by `test/fixtures/vacuum-routes/`; + a divergence shows up as a robot on the wrong floor. +- `src/vacuum.ts` owns pure normalization/arbitration: paths are always + `Pt[][]`; `resolveCurrentVacPath()` is the only integration → server → local + decision; `resolveVacSource()` pins saved sources and limits automatic + selection to compatible same-device entities; the card adds registry status + through `resolveHaBindingStatus()`. +- `smoothVacPath()` takes calibrated flat plan coordinates and returns typed + `move|line|quadratic` commands for a caller-supplied physical radius; the card + then projects (flat/2.5D) and serializes. Each corner is a quadratic inside the + adjacent-segment convex hull, bounded by half of both segment lengths, which + makes the 17.5 cm limit, exact endpoints and subpath gaps structural. +- Auto-calibration uses the same shoelace `areaCentroid()` for plan polygons and + robot outlines; residuals go through resolved grid pitch and `cell_cm`. + `trails.py` owns current/previous runs and the refresh-time `(marker, source)` + health state. + ## Troubleshooting 1. Open the vacuum's device settings and read the source diagnostics. diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index e190becc..48c01d1c 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -45,6 +45,15 @@ keeps the parent ID on the child containing the old midpoint (then the old first endpoint on a tie), while the other child receives a new UUID. Merge, Resize, room deletion, opening edits, Undo/Redo/recovery, Optimize and import/export apply the same lineage and validation rules before one atomic persistence write. +Initial legacy IDs are deterministic, so frontend, backend and repeated +migrations converge; only genuinely new segments get UUIDs. +`src/wall-segment-model.ts` and `custom_components/houseplan/wall_segment_model.py` +share `test/fixtures/282-wall-identity-parity.json`. Writer-bypass mutants in +`scripts/mutation-registry.mjs` guard every structural writer entrance. A `cm:0` +atom or partition keeps its structural axis and stable identity but contributes +no masonry body, floor subtraction, paper, opening tunnel or opening host; one +resolver, `src/zero-walls.ts`, applies `space.zero_wall_style` to flat, static, +2.5D and light. Per space: `walls: [{ key, cm, a?, b? }]`. `key` remains the quantised midpoint and direction (modulo 180°) compatibility lookup; new or rewritten entries also @@ -80,6 +89,9 @@ shared rooms. A different thickness, outer/shared transition or change of shared-room pair remains a real break. Adjacent zero atoms follow the same role-aware compaction; the canonical current model never writes compatibility `open_spans` or `open_to`. +`normalizeWallIntervals()` is the single implementation, shared by explicit +Optimize and the room-deletion transaction; ambiguous multi-owner geometry is a +hard breakpoint and fails closed per atom (#299). When a maximal wall run crosses a collinear vertex belonging to another room, its exact endpoints cover that room's shorter child side too; lookup does not depend on the compacted run's midpoint remaining inside every room. @@ -138,6 +150,13 @@ dimensionless edge fraction before breakpoint comparison or de-duplication. This preserves the same `0 ↔ h` and `h1 ↔ h2` transition at normalized and production (`coordScale = 1000`) scales. +Per-room rings remain the interior-join and nested-room representation; when an +acute child ring cannot be subtracted, its atomic interval quads provide the +safe physical fallback. The full card keeps the structural result in +`_wallUnionCache`; static cards use a weak server-snapshot cache guarded by a +structural geometry fingerprint, and cursor or HA state updates never repeat the +O(N²) `physicalBodySet()` node search. + ## 3. Body render Production body is the **ring** `outset(poly, half) − inset(poly, half)` per @@ -370,6 +389,20 @@ invoke it. - Displayed **m²** = area of the inner contour (clean floor). Wall-length rulers and opening anchors stay on the centreline. - With no thickness, inner = poly (parity with pre-thickness behaviour). +- View room hover is a late plain-SVG wash plus wide/narrow accent strokes over + the clean-floor geometry; the room information window shows the room's average + LQI and the formatted clean-floor area. The hover deliberately uses no CSS/SVG + filters: promoting a filtered sibling makes Chromium briefly recompose and + brighten the isolated screen-blended Glow layer. +- Opening-tunnel faces are one simple union contour per connected physical span: + thickness steps are vertices on the outer envelope, never touching translucent + rectangles, and both halves use the same nonzero winding across their tiny + centre overlap, so fractional rasterisation can neither cancel the fill into a + seam nor stack its opacity. `render/opening-tunnels.ts` only projects these + immutable inputs. +- A fully nested room is legal (`polyContainsPoly`): the parent's floor, data + fill and Glow base are evenodd paths with the island rings as holes + (`islandsOf`). ## 5. Sun @@ -386,7 +419,10 @@ modes identical. `hide_openings` hides the symbol only. Plan-editor tool «Thickness»; hover whole wall; cm/in from HA; exact `0..100` is valid for room walls and partitions, while empty/invalid input is -rejected; apply-to-room. Hooks: `data-hp="wall"`. i18n en/ru. +rejected; apply-to-room. Hooks: `data-hp="wall"`. i18n en/ru. Changing a +positive wall to `0` is rejected atomically while any opening uses it. Hit +widths and junction ambiguity are measured in CSS pixels through the live +viewBox, so a zero wall stays editable although it paints as a one-pixel line. **Draw with thickness.** The Plan toolbar's **Walls** button carries its session thickness field immediately on the right (default **15 cm**, or inches when HA @@ -412,6 +448,13 @@ partitions and openings are one Undo/Redo and persistence transaction. The remaining wall profile is normalised against its post-delete ownership, so an equal thickness cannot be compacted across an outer/shared boundary or across two different shared-room pairs. +`src/room-deletion.ts` plans the whole operation before any mutation; +confirmation is an accessible `hp-dialog`, never native `confirm()`. The same +command rewrites room references in every marker: a direct `room_id` is deleted +(remapped to the survivor for Merge) and exact values in cross-space +`vacuum.segment_map` are deleted/remapped. History snapshots only these fields, +so Undo/Redo and write rollback stay atomic with geometry while unrelated marker +and vacuum fields remain live (#477). ## 7. Out of scope @@ -476,6 +519,11 @@ Boundary/Thickness targets (`demo/smoke_optimize_coincident_partition.mjs`). The copying their contents into Git and checks raw, Optimize preview, applied canonical storage and reload states. +`OptimizeDependencies` (`src/plan-optimizer.ts`) is a narrow seam: the +large-house benchmark substitutes a no-op and the unit contract counts +per-space calls; a source-ownership assertion fails if a render/pointer module +imports the reconciliation helper. + ### Junction tooling (#302) Purpose-built checks for node material: `junctionContractHoles` (the objective @@ -507,6 +555,8 @@ Plan, View, Static, hidden Iso, paper and light use the same components, while `roomGeom` excludes independent bodies so room area remains unchanged. This is read-only recovery: strict Optimize and every physical-geometry edit reject a degraded candidate and never silently delete or rewrite the offending object. +A core room-body failure is `failed-core`: it sits outside the optional-union +fallback and activates fail-dark rendering. An opening with explicit `host:{kind:'partition',id,t}` is resolved from that partition alone and subtracted full-depth from its raw body before the joined @@ -598,6 +648,8 @@ physical gap created by an `open_span` or absent wall remains a gap. A door, window, gate or passage is a property of a wall and preserves connectivity. A clean divider across one room reuses the Split contract: the larger side keeps the room identity, metadata and device binding, and only the smaller side is offered. +`splitRoomPath()` is a strict partition: the two parts' areas must sum to the +original within a relative 1e-6 epsilon, otherwise the cut is rejected. The terminal active path remains session-local while the resulting room dialogs are open. Create/Keep-as-walls answers are buffered; Cancel/Esc discards all @@ -606,3 +658,32 @@ revalidates the whole batch and applies accepted rooms while consuming only the coincident partitions used by those rooms in one history/config transaction. Graph construction never runs on pointermove, Home Assistant state updates or ordinary rendering. + +**Finishing a chain (#294, #477).** Changing Plan tool, editor or floor, `Esc`, +the tray's Reset, route/hash departure and a room-face batch whose every face +was rejected all finish an open chain through one bounded lossless finalizer +(`finalizeWallChainSpace`, `src/writer-fixed-point.ts`). It merges only the +seed-connected compatible collinear run, rehosts its openings and reconciles +only surviving positive seed partitions proven coincident with room masonry; the +cloned candidate crosses the current-model identity barrier, one local +physical/junction proof and storage canonicalization before atomic adoption. +Rejection keeps the visible chain and the original config. It adds no history +command and never sweeps unrelated legacy debt. Pan, pinch, pointer cancellation +and suppressed clicks never finish a chain or append a segment; a finished chain +is not resumed after reload or remount. + +Active-chain Undo preserves the complete record of every surviving partition, +including its stable id; only a genuinely new edge receives a new identity. Each +segment history snapshot carries session-only chain seed ids: while the chain is +active the snapshot is literal and Undo removes one point; after finish, +Undo/Redo passes that seed scope through the same lossless finalizer, so hidden +collinear seams cannot return as durable Optimize debt (#477). + +**Room from an existing face.** With no active chain, a Walls click queries the +smallest exact unoccupied bounded face at the raw point; boundary/snap hits and +desktop `Shift+click` still draw. Without an exact face, `src/wall-face-repair.ts` +may plan one endpoint→endpoint or endpoint→solid-line move of at most 2 physical +cm. Room vertices and endpoints of partitions that host an opening never move, +multiple valid repairs fail closed, and the immutable proposal is revalidated +against current geometry before use. Move and room are one history/config +transaction; cancelling or rejecting the room applies nothing. diff --git a/docs/WARM-REMOUNT.md b/docs/WARM-REMOUNT.md index 3092c481..981e83cf 100644 --- a/docs/WARM-REMOUNT.md +++ b/docs/WARM-REMOUNT.md @@ -258,3 +258,42 @@ hover. После долгого сна общий `VisualContinuityController` (`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост (`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так прерывает. + +## 5. Холодная загрузка и визуальная непрерывность (#73, #131) + +`src/visual-continuity.ts` владеет одним машинным состоянием с токенами, общим +для полной и статической карточек. Готовый кадр остаётся на экране через +возобновление, переподключение, структурную перепроверку и изменения размера с +положительной площадью; наблюдения `0×0` viewport не меняют. Config и layout +несут независимые идентичности «ревизия + отпечаток содержимого»: эхо, где +сменилась только ревизия, сохраняет авторитетные объекты и кэши геометрии, а +изменённое содержимое не спрячется за равной ревизией. Кандидат готов только +после того, как Lit осел, обязательные подписанные ресурсы загрузились и для его +токена прошли две возможности кадра анимации; `data-continuity-state`, +`data-continuity-token`, `data-frame-fingerprint` и условный +`data-recovery-reason` показывают это без entity id и URL. Рантайм подписанных +ресурсов привязан к `hass.connection`, ограничен и общий для размещений, поэтому +тёплый ре-маунт может синхронно переиспользовать загруженную подложку; +обновление — stale-while-decode. Только когда сохранить нечего — ни готового, ни +устаревшего кадра, — через 150 мс появляется локализованный непрозрачный оверлей +восстановления; он никогда не забирает начальный фокус и, пока виден, делает +сцену `inert`. + +Обязательная загрузка заканчивается, когда `houseplan-card` принял config и +layout, построил модель, выбрал одно точное пространство, закэшировал снимок и +восстановил viewport. Подписки на config, след и layout стартуют после этого +вместе, как независимые необязательные обогащения: отказ одного канала не +блокирует остальные, не стирает снимок и не планирует повтор полной загрузки; +недостающие каналы повторяются при следующей загрузке или переподключении. + +`src/initial-load.ts` — единственный источник решения о пространстве +закэшированного снимка, живого снимка и его кандидата защищённой подложки. +`floor` в конфиге карточки абсолютен (`resolveFixedFloor()`: строка — точный id, +конечное неотрицательное целое — индекс с нуля по серверу); неверные значения +отказывают закрыто, числовой индекс ждёт свежую серверную модель до первого +пространственного кадра, а закреплённый экземпляр никогда не читает и не пишет +`houseplan_card_nav_v1` — через этот гард проходит каждый переход `_space`. Без +`floor` холодная загрузка перебирает валидные id в порядке: хеш URL, сохранённая +навигация, `default_floor`, первое живое пространство; после того как начальный +хеш использован, валидный выбор в том же маршруте сохраняется; план без +пространств оставляет авторитет `null`.