# House Plan architecture 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` 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 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` 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 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 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) **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. **`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 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 radars (#485 Stage 1) `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). ### Robot vacuums `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` 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` 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 `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)). 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. ## Server-side configuration `.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 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 (#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). 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). `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. ## Markup editor 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) 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; pure model, tread/trapezoid geometry and style resolution stay in `stairs.ts`. The optional `color`/`opacity` and `fill_color`/`fill_opacity` fields are a snapshot owned by the stair. Missing legacy fields resolve at render/dialog time from the current decor default but are not written until the user saves that stair. Screen renderers consume the colour fields; PDF deliberately uses the same geometry with its existing monochrome ink palette. ## Editor chrome and contextual controls 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`. ## Doors, windows, gates & passages `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 `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. | `houseplan/…` | Parameters | Result · domain errors | |---|---|---| | `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` | 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`. 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). **Invariants** - *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. Collection classifies by **owner**, not by "is it referenced" (HP-1465-01); nothing is deleted for being old except `up_*` staging after `PLAN_ORPHAN_TTL_S` (1 h), and `plans/list`/`plans/delete` make "we never delete" livable: | Case | 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 (the image was imported and may be nowhere else) | **kept** | | Space has a plan plus another file of its own (a rejected save) | **kept** — ageing these out raced the retry | | Marker in both, attachment dropped from its list | removed immediately | | Marker gone | **kept** | | Attachment in `up_*` (a dialog never saved) | removed after `PLAN_ORPHAN_TTL_S` | | Marker there, file it never listed (a rejected upload) | **kept** | - *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. **Client side** - `_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. ## Second card: houseplan-space-card (read-only) 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=`. ## Subsystems with their own canonical documents - **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). ## 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 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). ## 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. 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. ## 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` 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) 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). ## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579) 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`. After the camera leaves its byte-compatible 100% view, the flat wall hatch uses an analytic user-space repeating gradient rather than a repeated stroked bitmap tile (#685). Therefore the terminal transform-free SVG is rasterised directly for its final fractional scale; the live transformed frames may still be temporarily soft, but the idle frame must not retain a resampled hatch texture. Door, window and gate strokes share that terminal SVG, and passages remain real negative wall geometry. ## 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`), 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. ## Backend quality gates (#42) `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 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) 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) `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).