Files
houseplan-card/docs/ARCHITECTURE.md
T
Claude 2fe20a095b docs: свести документацию с кодом и каноном (#667)
Пятнадцать расхождений из #667. Бюджет initial View назван одним
источником (scripts/bundle-budget.mjs). CONTRIBUTING описывает бандл после
#657, HA-харнесс после #630 и релиз через release-contract и release.yml.
Мутанты указывают на реестр scripts/mutation-registry.mjs. STATUS и
ROADMAP больше не держат PR в HACS «в очереди» и инструкции прежней
песочницы. SCOPE называет три редактора, форматы плана и радар #485.
README выводят первую комнату через «Стены». Русское руководство сверено с
v1.78.0-beta.5, таблица «Источник плана» склеена. UX-MODES и STYLING-HOOKS
следуют коду: проёмы в просмотре инертны, space-card рисует проёмы и
декор-изображения. Починены якорь в DEVICE-PRESENTATION и пол зума 1/3.
ADR 089/122/160 и ISOMETRIC помечают активацию через hp_alpha исторической.
Из раскладки ARCHITECTURE убран несуществующий src/data. legacy/README не
называет docs/superpowers действующими спецификациями.

Паритет английского руководства (п. 8) выделен в #668.

Issue: #667
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-26 18:19:52 +00:00

2298 lines
151 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`).
## 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.
## 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
│ ├─ 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
```
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.
## 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 `<dialog>` 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`.
## 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 `<image href=svg>` 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).
## 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.
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).
## 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.
### Presence-radar runtime (#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.
`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.
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:<id>')`), 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.
## 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.
## 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.
## Device markers (v1.6.0+)
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:<id>` 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.
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.
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'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:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
`lg_<eid>` / `v_<rand>` (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 `<config>/houseplan/files/<id>/`
(staging `up_*` folders promoted on save), served via signed
`/api/houseplan/content/files/…` urls.
Custom Background images use a separate content-addressed store at
`<config>/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 `<image>` 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 `<sha256><allowed-extension>` 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+)
### 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/BACKDROP.md
"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":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|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/BACKDROP.md).
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_<roomId>: {"s": space, "x", "y"}}` (normalized,
bounded ±5000). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
(copy-on-write, never overwritten), served via signed
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
## 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.
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.
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).
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.
`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/
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.
## 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.
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.
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.
## 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.
| Command | Parameters | Response |
|---|---|---|
| `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 `<space>.<token>.<ext>`, 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 `<image>`/`<a>` 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/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).
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.
`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.
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.
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`.
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.
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.
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.
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.
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.
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 — `<image>` 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 `<space>.<token>.<ext>` 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=<id>")`
(default target `/plan-doma`). An unpinned full card reads `#space=<id>` 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` and `BACKDROP.md`.
- **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.
## 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).
## 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/<plans|files>/…`,
`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)
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:
- `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)
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`.
## Contextual Zigbee topology (#54, #457, #464; 2026-09-06)
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.
`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.
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.
`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)
`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.
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.
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).
## 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.
## 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 |
## 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.