Files
houseplan-card/docs/ARCHITECTURE.md
T
Claude 600330e187 fix(daycycle): stage-sized plan layers without a frozen raster; revert #685 hatch
With the day/night background the plan went blurry after zooming from 100 %
(sharp when the page was opened at 800 %) and navigating a strongly zoomed
plan flashed the page white. Both came from #582's composition, not from the
wall hatch that #685 replaced:

- `.stage.daycycle.hp-safe-daycycle-outline .plan-svg` promoted the scene
  with `will-change: transform`; Chromium freezes the raster scale of such a
  layer, so the 100 % raster was shown stretched. The explicit layer #582
  needs is now `will-change: opacity` (re-rasters at the current scale).
- The filtered outline had `overflow: visible` and a gesture exposed every
  scene (#544) without bound, so the promoted layers grew with zoom squared
  (CDP LayerTree, ~460 %: plan-svg 15.9x, outline 13.2x the stage; 39x after
  navigating at 800 %). The full card clips its outline to its box and marks
  it data-hp-live-overflow="clip" (never exposed); the live viewport bounds
  every other exposure with an inline clip-path: inset(-25%) that leaves
  with it, so idle DOM stays byte-identical (#531).

Owner-verified in Chrome 152 (built-in browser, DPR 2): sharp after 100 ->
800 %, no white flashes after reloading at 800 %.

Owner decision: #685's analytic gradient is reverted (13af1d5e), the single
<pattern> is back at every zoom; its close-up golden scenes stay and check
the pattern, its terminal-frame smoke checks the pattern.

Witnesses: demo/smoke_daycycle_zoom_layers.mjs (800 % x DPR 2: layers vs
stage, the hint, reload path, button/wheel/pinch); #582/#532 smokes now pin
the opacity hint; test/live-viewport.test.mjs (bounded exposure, clipped
scene); test/daycycle-layers.test.mjs (cascade). Four Node-guarded mutants.

Issue: #689
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 17:51:06 +03:00

44 KiB
Raw Permalink Blame History

House Plan architecture

One HACS repository (category Integration) ships the backend (custom_components/houseplan), both Lovelace cards and the /houseplan panel (src/ → dist/). This document is the map: each subsystem gets a short description and a link to its canonical document, which owns the details.

Styles (#266)

src/styles.ts composes cardStyles = [base, plan, devices, chrome, dialogs, isoTiles] from src/styles/*.styles.ts; the order is a cascade contract (the golden set is accepted against it; isoTiles last so 2.5D tiles beat equal-weight Flat marker rules, #649). base holds host, variables and rules shared by two surfaces; plan stage ink, devices markers, chrome toolbars/tabs/menus, dialogs dialogs/forms/pickers. form-kit.styles.ts (#594), editor-secondary.styles.ts and the summary-panel sheets live outside that aggregator. test/styles-split.test.mjs pins composition, no cross-file duplicate selectors and @media wrappers; scripts/dev/styles-diff.mjs proves a move refactor-only. Public selectors: STYLING-HOOKS.md.

Layout

houseplan-card/
├─ src/                                  # card sources (TypeScript + Lit 3)
│  ├─ houseplan-card.ts                  # eager full card: View shell, HA lifecycle, lazy-runtime hosts
│  ├─ houseplan-panel.ts                 # /houseplan sidebar host around one full card (#486)
│  ├─ space-card.ts · space-render.ts    # read-only houseplan-space-card and its static renderer
│  ├─ houseplan-editor-runtime.ts        # lazy Plan/Devices/Background composition root
│  ├─ houseplan-onboarding-runtime.ts    # lazy first-space/import dialogs, independent of the editor
│  ├─ iso-*.ts · pdf/ · stairs*.ts · furniture*.ts  # 2.5D, PDF export, stairs, furniture (own docs)
│  ├─ devices.ts · rules.ts              # device list from registries; icon rules and exclusions
│  ├─ ha-binding-status.ts · device-presentation*.ts  # binding authority; marker face decisions
│  ├─ space-geometry.ts · wall-*.ts · physical-geometry.ts · logic.ts · types.ts  # pure, no Lit/DOM
│  ├─ config-store.ts · config-adoption.ts · command-stack.ts  # config cache, #500 boundary, Undo
│  ├─ sun.ts · day-cycle-render.ts       # window rays, four-phase background (SUN.md)
│  ├─ vacuum*.ts · radar-*.ts · zigbee-topology*.ts · summary-panel*.ts  # live subsystems
│  ├─ hp-dialog.ts · hp-confirm.ts · danger-confirm.ts · hp-help.ts · floating-surface*.ts
│  ├─ editor-runtime-loader.ts · version-recovery*.ts · editor-secondary.ts  # lazy loader; #462; tray
│  ├─ editor.ts · space-editor.ts        # Lovelace GUI config editors of both cards
│  └─ editors/ · render/ · styles/ · i18n/  # settings dialogs, SVG projections, sheets, locales
├─ dist/                                 # two stable entries, houseplan-assets.json, hashed chunks
├─ demo/                                 # demo rig and smokes; golden/ (HP-QA-01), performance/
├─ scripts/ · .github/workflows/         # build/bundle gates, release-*.mjs; CI and release
├─ custom_components/houseplan/          # the HA integration
│  ├─ __init__.py · store.py             # setup/unload; versioned stores and per-entry runtime data
│  ├─ websocket_api.py · http_api.py · auth.py  # WS commands, uploads, the one may_write policy
│  ├─ validation.py · coordinate_canonicalization.py  # pure schema validation, write canonicalisation
│  ├─ frontend_registration.py · frontend_assets.py · panel_registration.py  # resource, chunks, panel
│  ├─ import_export.py · decor_assets.py · plans.py  # backup/import, decor images, plan blobs
│  ├─ trails.py · vacuum_routes.py · radar*.py · virtual_lights.py  # live-subsystem backends
│  ├─ config_flow.py · const.py · system_health.py · diagnostics.py · repairs.py · support_*.py
│  └─ frontend/                          # release-only snapshot of dist (#657)
└─ docs/                                 # this documentation

Rollup emits two stable roots, houseplan-card.js and houseplan-panel.js, plus hashed chunks and dist/houseplan-assets.json (graph, sizes, SHA-256). The panel imports the card chunk by its hashed name, never through the card's unversioned facade: entries are served without Cache-Control, and a stale facade once ran an old card against the current backend (#535). Both roots keep the fail-loud stale-load wrapper. View loads only the initial graph; editor, onboarding, 2.5D (#649), PDF, locale and furniture-art graphs are lazy, each passing the exact-build fingerprint handshake (one cache-busted retry, atomic install), and scripts/bundle-budget.mjs fails a build whose lazy graph is missing or leaks into the initial graph. The backend serves only manifest-listed chunks. Commands and gates: DEVELOPMENT.md › Build, › Tests, › Release.

Key decisions

  1. One repository — integration + panel + cards. The integration serves its own JS; the exact versioned module URL is the resource identity, a writable Lovelace resource registry is authoritative and add_extra_js_url the truthful fallback. Each houseplan/config/get carries the authoritative integration_version: View offers a manual reload, kiosk reloads once per backend target in a safe idle state, the space card has no version controller (DEVELOPMENT.md › Resource registration and version recovery (#462)). After migrations succeed, setup registers the public /houseplan panel (require_admin=False, #486): a thin app-bar host around one ordinary full card, fail-soft, removed on unload only by the exact owner and setup generation (UX-MODES.md › Principle; TESTING.md › Installation / upgrade / removal). In a Sections layout="grid" slot HA owns the height: .stage is the shrinking flex child and transitions use the measured card height, never window.innerHeight; getGridOptions() is full width, 10 rows, min 6 (#648).
  2. Server-side storage, no token. store.py keeps .storage/houseplan.config, houseplan.layout (marker positions) and houseplan.virtual_lights; trails.py keeps houseplan.trails. All traffic uses the frontend hass connection (Integration WS API below); House Plan opens no socket and holds no token. localStorage keeps a start-up snapshot and, only if no backend ever answered, a local layout. Registries come from one ha-binding-status fetch and subscription pair per HA connection, shared by every card on the page; live rows augment an older snapshot at once and a debounced full reload reconciles disabled_by even without registry events (FILTERING.md › Behaviour).
  3. Reactivity. Every hass update re-renders. Registry rebuilds also run the pure device-area-relocation resolver: pending ids override stale layout in full and static cards at once; the writer deletes those points before advancing Area provenance and restores them if the config write is rejected (a failed restore leaves the attention marker); only the moved device's Undo/Redo is invalidated; limited snapshots never infer movement (CONFIG-COMPATIBILITY.md › Marker Area provenance (#126)).
  4. One modal contract. Card modals render through hp-dialog: ha-dialog if registered when the instance connects, otherwise a first-class native <dialog> for its lifetime (top layer, backdrop, reopened once if :modal was lost); a late registration never swaps an open surface, and alert confirmations stay native for real alertdialog semantics. The wrapper owns title, initial focus, Escape and a shadow-root-scoped restore-focus session (nested dialogs return to their trigger, a replacement to the first opener). flex-content forwards flexcontent so an inner scroller is height-bound (#508); the footer stays a full-width slot item; titles wrap; destructive actions stay left while Cancel/Save wrap right together. Dangerous actions use one eager HpConfirmController on HouseplanCard plus stateless hp-confirm (#32): replace-not-queue, every dismissal cancels, a token rejects stale decisions, callers re-resolve targets after await. Native confirm() is not a supported surface.
  5. Open passages are negative architecture (#157). type=passage shares placement, wall cut and tunnels with other openings but has no leaf, binding or 2.5D panel; static wall fingerprints add only passage cuts, so door/window/ gate output is unchanged (CONFIG-COMPATIBILITY.md › Open-passage opening type).
  6. One transient-surface contract (#68, #57). hp-dialog keeps a scoped LIFO registry for help and colour-picker surfaces: Escape/toast close the top one first; a new one replaces the previous only inside the same dialog. hp-help and hp-color-opacity share floating-surface.ts placement and floating-surface-controller.ts (Popover top layer, dialog-owned portal fallback). Help is localized by the owning card and exists only when body and accessible label are both non-empty. Page-wide lazy runtimes — locales (#62, #400) and furniture artwork (#474, #593) — settle after two failed attempts into English / no artwork with one toast, never an inert card.
  7. One four-phase environment resolver. resolveDayCycle() (src/sun.ts) and src/day-cycle-render.ts serve View, kiosk and the space card; plan content is never phase-filtered (SUN.md › Current four-phase background). The stage-sized outline root owns the triple drop-shadow and will-change: filter; its child is only the exact paper alpha silhouette, and the plan root gets an explicit stage-sized transform layer so Chromium never rediscovers it as an implicit overlap layer (#532, #582).

Coordinate system

Plan geometry and marker positions are stored normalised on an unbounded canvas: 0..1 = NORM_W (1000) render units, ±5000 validation, 1/240 lattice (src/canvas-constants.ts). Source of truth: CANVAS.md › Principle, › Model, › Grid precision and visual units, › Persisted coordinate canonicalisation.

Card data model (runtime)

Optional space model (#113). Without authoritative spaces there is no SpaceModel: _spaceModel() returns undefined and never invents one. Its active-or-first choice serves rendering and navigation only; commands carrying a stable space id use the exact _spaceModelById() and abort on a stale id before any config, layout, file or service effect (space-model-selection.ts). The first update that sees an authoritative empty spaces runs one cleanup (pointer capture, gestures, draft/history, space dialogs, pending config write, back to View); recreating a space re-arms it. Pure helpers may return empty results; mutation entry points guard explicitly.

DevItem (src/types.ts) is rebuilt by buildDevices() (src/devices.ts) from the active registry projection plus config markers: id, name, model, area, space, icon, entities[] (active runtime entities only), allEntities[] (metadata only), primary (first resolved state entity for single-target actions; marker faces use the resolved role, DEVICE-PRESENTATION.md), temp/hum, marker metadata and effective controls. bindingStatus (active, ha_disabled, orphaned, unverified) comes only from resolveHaBindingStatus(), never mutates markers, and gates every plan-level consumer (Glow, climate, LQI, live text, openings, controls, vacuum). Auto-discovery takes non-service devices whose HA Area is bound to a space. The base icon is the first matching icon rule ("<name> <model>") → entity device_class → mdi:chip; a device with a lock.* entity is always mdi:lock. Duplicate name|area pairs are numbered; HA light groups and Z2M group devices become lg_<entity> items that fold their lamps. Hiding is FILTERING.md.

Live data

State, values, LQI and activity are resolved once per frame into one ResolvedDevicePresentation (see Device markers); explicit marker.value_source and marker.value_badge go through src/device-value-badge.ts. Decision table, plate colours and LQI scale: DEVICE-PRESENTATION.md.

Presence radars (#485 Stage 1)

marker.radar is an optional versioned namespace saved only by the ordinary config transaction. One backend RadarCoordinator per entry alone reads raw HA states and streams clipped, ACL-filtered frames over houseplan/radar/subscribe; observations, calibration samples and trails never reach config, uploads, support reports or browser storage. Contract and code map: RADAR.md; normative limits: specs/485-radar-presence-stage1.md.

Robot vacuums

src/vacuum-routes.ts is the only map-to-space authority (six fail-closed results, #162; mirrored byte-for-byte by vacuum_routes.py), src/vacuum.ts owns telemetry normalization, path arbitration and smoothing, and trails.py records runs server-side. Contract and code map: VACUUM.md.

Sizes

icon_size is a percentage of the plan (default 2.5; legacy px values > 8 fall back to 2.5), converted once at the surface boundary and rendered in cqw inside .stage { container-type: inline-size } — CANVAS.md §6.

Sticky header

.head is position: sticky; top: var(--header-height, 56px), so ordinary dashboard cards keep ha-card { overflow: visible } (hidden overflow breaks sticky). The bounded panel-host and Sections layout="grid" branches own their complete height chain and clip at the external slot instead (Key decision 1).

Device markers

config.markers[] records are {id, binding: 'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?, removed?, name?, icon?, …} plus presentation, light, control, vacuum and radar fields (validation.py is authoritative). Registry devices appear on their own; a device: marker overrides one, entity: covers groups/helpers, virtual is a manual icon. The id (device id, lg_<eid> or v_<rand>) keys the layout. hidden is the reversible hide flag, removed a never-rendered binding tombstone (FILTERING.md).

View, kiosk, the static card and the dialog preview share one pipeline: device-visual.ts classifies, device-presentation.ts resolves sources, the pure device-presentation-policy.ts owns priority, device-pulse.ts owns activity and device-face.ts only paints. normalizeDeviceDisplay() is the mandatory read gate for display (badge|icon_ripple|value|static_icon|value_static_icon; legacy ripple → icon_ripple). The saved coordinate is the icon-core centre; overlapping 44 px targets get one screen-space owner (device-hit-owner.ts), latched for the whole pointer sequence. Rules: DEVICE-PRESENTATION.md; stored fields: CONFIG-COMPATIBILITY.md.

Attachments are staged in up_*, promoted into <config>/houseplan/files/<id>/ on Save and served by signed /api/houseplan/content/files/… URLs (Integration WS API). Custom Background images use the content-addressed <config>/houseplan/assets/ store (asset_id = SHA-256 of canonical bytes; config never carries bytes or URLs) — DECOR-EDITOR.md §7.

Server-side configuration

.storage/houseplan.config holds {model_version, spaces[], markers[], settings}. A space carries plan-image fields, rooms[] (with ordered wall_ids), wall_segments[], partitions[], wall_columns[], openings[], decor[], stairs[] and settings. Coordinates keep the historical normalization (1.0 = NORM_W = 1000 render units) on an unbounded plane with a ±5000 guard (CANVAS.md); plan_aspect letterboxes the image and plan_x/y, plan_scale_x/y, plan_angle transform it (DECOR-EDITOR.md §3). Layout v2 maps device_id | rl_<roomId> to {s, x, y}. Plan files are copy-on-write <config>/houseplan/plans/<space>.<token>.<ext>, never deleted for age (SCOPE.md). Migrations: CONFIG-COMPATIBILITY.md.

Persisted colour boundary

Every stored colour is exactly #RRGGBB: src/color.ts resolves it and validation.py::_COLOR guards writes. Render-time resolvers re-apply a safe default because old, imported or hand-edited stores are not migrated on read. HA rgb_color is live state: three finite channels, clamped/rounded to 0–255 and emitted only as generated rgb(R, G, B). The final inline-style sink accepts only those two forms; arbitrary CSS colour syntax would need a separate product/security decision and must not be added to an individual sink.

Room and independent wall geometry

Model v10 (#282, #306, #478): wall_segments[] is the authoritative catalog of atomic room-wall intervals with stable ids, rooms[].wall_ids[] orders each contour, and room polygons plus walls[] are read-compatibility projections. Every accepted Walls-chain edge is an ordinary partition; wall_columns are square/circular columns; neither creates or implicitly splits a room or HA area. cm:0 keeps a structural axis and identity without masonry; space.zero_wall_style makes it dashed (transmits light) or solid (a zero-area barrier). Reads are projection-only: a physical-geometry mutation builds a local candidate, canonicalizes it, materialises the catalog (src/wall-segment-model.ts ↔ wall_segment_model.py, shared parity fixture), validates references and commits one config transaction; ambiguity fails closed with no partial config, history or revision. Model and lineage: WALL-THICKNESS.md §1; migrations and stale-client guard: CONFIG-COMPATIBILITY.md (model v8–v10); rationale: adr/282-wall-geometry-representation.md.

Rooms may not partially overlap (lying on a shared wall is legal, a fully nested island is supported). Merge/Split use polyclip-ts (not polygon-clipping, see DEVELOPMENT.md): Merge accepts a pair only when the union is one hole-free outline; Split cuts wall-to-wall and the larger part keeps the room identity (name, area, devices).

wallBodiesGeometry() is the single physical masonry for flat full/static rendering, 2.5D, paper, clean floor and Glow/sun occlusion. Its exterior shell comes from the union of room centrelines plus surviving outer atoms; junctions follow the bounded mitre/bevel rules (#249, #271, #272, #275, #288, #302, #309, #310); independent bodies join through physicalBodySet() while raw quads keep editor identity. The result is a typed component set (ok, degraded-extra, failed-core; #197, #278): rendering keeps every valid component, mutation preflight rejects degraded-extra, failed-core fails dark. It is computed state only — cached per structural geometry, never written back, never rebuilt by HA state ticks. Contract: WALL-THICKNESS.md §2–§4, §9–§11.

Markup editor

Card state: _mode, _tool (select|draw|column|merge|split|resize|opening| stairs|wallthick|delroom), session _path on the GRID_N = 240 lattice (_snap, CANVAS.md §9). Committed geometry enters the named 50-command Undo/Redo stack shared with Background (DECOR-EDITOR.md §6, §9); it survives the echo of its own writes and clears on a newer external revision. Every physical-geometry writer crosses one fail-closed boundary (#278, checkSpacePhysicalGeometry()) before history/save, rechecked at the deferred write; presentation edits bypass it. Contracts: Walls chain, faces, room deletion, partitions — WALL-THICKNESS.md §6, §9–11, CONFIG-COMPATIBILITY.md (#478, #461), TOUCH-SUPPORT.md; Resize (#277, #300) — RESIZE.md; near-axis (#290, src/near-axis.ts) — CANVAS.md §9.3. reconcileCoincidentPartitions (#276/#296/#477) and normalizeWallIntervals (#299) run plan-wide only in explicit Optimize, locally in chain finish/room deletion, never in render or ordinary saves. Saves strip the legacy root space.segments.

Stairs (#663)

Separate Plan entity, not decor (STAIRS.md). Eager StairViewRuntime: symbols, tooltip, guarded navigation; lazy StairEditorRuntime: drawing, transforms, magnets, properties (a plan never loads the editor graph). The root card keeps lifecycle, shared history/persistence and stage pointer terminals; pure model, tread/trapezoid geometry and style resolution stay in stairs.ts.

The optional color/opacity and fill_color/fill_opacity fields are a snapshot owned by the stair. Missing legacy fields resolve at render/dialog time from the current decor default but are not written until the user saves that stair. Screen renderers consume the colour fields; PDF deliberately uses the same geometry with its existing monochrome ink palette.

Editor chrome and contextual controls

One stable .editbar per editor: .editbar-tools (persistent tools, Undo/Redo) and pinned .editbar-end (Close); transient controls never enter this measured row. Selection actions, tool parameters, hints, palettes and the approved groups (Opening, Stairs) come from one EditorSecondaryModel in the single light-DOM .editor-secondary-host inside .stage (editor-secondary.ts): outside the _hdrH measurement, pointer events only on its visible surface, empty in the Device editor (future marker quick actions go there). Mutating actions revalidate a deterministic contextId; Delete/Backspace never fall through from it. Product rule: UX-MODES.md.

Doors, windows, gates & passages

space.openings[] is plan geometry, not markers: {id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}. host {kind: wall|partition, id, t} is authoritative and x/y/angle its atomically refreshed projection; an absent host is the legacy room-wall association, and no host ever falls back to a nearest wall (CONFIG-COMPATIBILITY.md › #132/#157). One OpeningWallIndex feeds symbol, cut, tunnel fill, Glow and the 2.5D face (WALL-THICKNESS.md §3–4, ISOMETRIC.md, LIGHT.md). The Flat symbol follows easy-floorplan (MIT). openingAmount() maps contact state to 0..1 — no sensor: door/gate open, window closed; unknown/unavailable keep that default. Contact and lock are opening-owned exact references: candidates follow HA binding status, render reads the frozen active-registry projection (never raw hass), neither consults marker tombstones. The .oplock badge never toggles a lock (resolveToggleIntent → no-op, SCOPE.md); openings are edited only in Plan.

Integration WS API

auth.may_write() is the single writer policy for WS and HTTP: administrators always write; admin_only (default true when unset) restricts writing to them; with admin_only: false other users write unless they belong to system-read-only. A missing entry or incomplete group data fails closed. Reads are deliberately broader: every authenticated user receives the complete config/layout (no per-entity projection), trails without source entity ids (#626) and may toggle virtual lights. W = writer-only (else unauthorized); runtime-backed commands answer not_ready before setup.

houseplan/… Parameters Result · domain errors
config/get space_id?, fields?, marker_fields? (#256; project only the document) {config, rev, virtual_lights:{rev,config_rev,off[]}, can_write, can_optimize_undo, undo_kind, integration_version, support_api, decor_assets_api, summary_panel_api, radar_stage1_api?}
config/set W config, expected_rev {ok, rev} · conflict, too_large (2 MiB), invalid_format, missing_plan, semantic codes below
layout/get space_id? {layout:{id:{x,y,s?}}, rev, can_optimize_undo, undo_kind}
layout/set W layout, expected_rev {ok, rev} · conflict (external clients; the card writes points)
layout/update W device_id, pos {ok, rev, ignored?: removed|missing_virtual} — a tombstoned owner's late drag is acknowledged, not stored
layout/delete W device_id {ok, rev} (rev: null when nothing was stored)
space/delete W space_id, expected_config_rev, expected_layout_rev {ok, config_rev, layout_rev, removed_layout} · space_in_use, space_not_found, invalid_space_id
plan/optimize W config, layout, both expected revs {ok, config_rev, layout_rev, can_undo} — also the server-side wall-model migration barrier
plan/optimize_undo W both expected revs restores the one-deep Optimize/full-import backup · no_backup after any later edit
geometry/repair W space_id, aspect, dry_run?, undo?, expected_rev? manual re-transform of one space's positions (HP-1500-01) with one-deep repair_backup · nothing_to_repair, no_backup
export/create W kind: full|space, space_id?, plan_only?, card_version {document, filename}
import/revalidate W token, duplicate_policy? refreshed preview and current revisions
import/apply W token, both expected revs, duplicate_policy?, confirm_missing_content? paired commit; full import gets one-deep undo · conflict, preview_expired, content_confirmation_required, missing_plan, missing_content
virtual_light/toggle marker_id {marker_id, on, rev} · not_toggleable
trail/get / trail/delete W — / marker_id {trails:{marker:{current,previous}}} / {ok, removed}
plans/list W / plans/delete W — / name newest 60 {name,url,size,modified,used_by} + total / {ok, removed} · in_use, invalid_name
plan/set W space_id, ext, data (base64) {ok, url} — kept only for pre-#617 cards; current cards use HTTP
files/migrate W from_id, to_id {ok, mapping, copied} — copies, never moves or overwrites
files/cleanup W marker_id {ok, removed, kept} — removes only files the stored config does not reference
assets/list W / assets/delete W — / asset_id catalog with authoritative used_by / {ok, removed} · in_use
assets/resolve asset_ids[] (≤200) {assets, missing}; non-writers resolve only ids the saved config uses
content/sign paths[] {urls} — 24 h authSig, only /api/houseplan/content/…, first 200 paths
support/* W, radar/* — SUPPORT-PRIVACY.md, RADAR.md

Semantic write codes: invalid_config, invalid_passage_fields, invalid_partition_opening_host, invalid_partition_opening_jamb_margin, wall_model_client_outdated, wall_model_migration_blocked (Optimize), junction_limit_<rule>, invalid_radar, invalid_light_entity, invalid_vacuum_map_route, *marker_control*, invalid_value_badge*; paired writers add commit_failed. Events: houseplan_config_updated, houseplan_layout_updated ({rev}), houseplan_virtual_light_updated, houseplan_trail_updated.

HTTP views (requires_auth, same policy): POST /api/houseplan/upload (marker attachment), /plans/upload (#617), /assets/upload (DECOR-EDITOR.md §7), /import/preview and GET /api/houseplan/content/{kind}/{sub}/{name} (nosniff; SVG only gets a sandbox CSP, HP-1454-01). Only the manifest-gated bundle under /houseplan_files/ is public; legacy /houseplan_files/plans|files URLs are still recognised in stored config but no longer served (CONFIG-COMPATIBILITY.md › Legacy content URLs).

Invariants

  • Optimistic locking. Each config/layout writer takes write_lock, resolves a pending pair, then checks revisions before validation, no-op detection or file collection. expected_rev may be omitted only at rev = 0 (#340, #356). External writers read rev via config/get/layout/get, send it as expected_rev, and on conflict re-read and retry (#368). A canonical no-op keeps revision, events and the maintenance backup.
  • Paired writes (Optimize, Optimize Undo, full import, space delete) use the durable optimize_pending intent and one-deep optimize_backup (kind: optimize|import) — CONFIG-COMPATIBILITY.md › #491. Events fire only after both halves are durable; Store exceptions are resolved by reloading and comparing exact payloads; layout-store writes go through async_save_layout_state so unknown metadata survives.
  • Validation is the server's. Schema/semantic checks run in the executor under the lock; the browser never parses an import. Frontend preflight (src/plan-geometry-preflight.ts) only avoids doomed calls.
  • Files (SCOPE.md › Standing rule): uploads claim a fresh name (reserve_filename, O_EXCL), plans are copy-on-write <space>.<token>.<ext> (a space id cannot contain .), and check_quota bounds bytes/files/free disk at upload. config/set collects, under its lock, only what its own commit replaced; a daily pass runs the collectors with the stored config on both sides and sweeps .upload- temporaries. A newly referenced internal plan must exist (missing_plan; import also checks attachments). A usable Content-Length is checked before streaming, the staged size again under upload_lock, which also serialises image decoding. Collection classifies by owner, not by "is it referenced" (HP-1465-01); nothing is deleted for being old except up_* staging after PLAN_ORPHAN_TTL_S (1 h), and plans/list/plans/delete make "we never delete" livable:
    Case Rule
    Space in both, plan A → plan B (the user picked another image) removed immediately
    Space in both, plan → none (detached; one click undoes it) kept
    Space gone (the image was imported and may be nowhere else) kept
    Space has a plan plus another file of its own (a rejected save) kept — ageing these out raced the retry
    Marker in both, attachment dropped from its list removed immediately
    Marker gone kept
    Attachment in up_* (a dialog never saved) removed after PLAN_ORPHAN_TTL_S
    Marker there, file it never listed (a rejected upload) kept
  • Import preview streams ≤8 MiB, rejects duplicate/prototype keys, non-finite numbers and future model versions, and keeps the candidate in memory for 10 min behind a token bound to the user, candidate digest and both revisions (global and per-user caps). Export deep-copies one coherent pair under the lock and builds outside it.
  • Virtual-light state lives in its own Store (CONFIG-COMPATIBILITY.md); toggles reply immediately and coalesce into one delayed durable write, flushed before config transitions and unload. A cached snapshot never authorizes an optimistic toggle.

Client side

  • _writeConfig() keeps one config/set in flight, each carrying the previous reply's revision (HP-1454-03). A rejected physical transaction restores the earliest server-backed snapshot of every affected space and reloads (#314).
  • src/config-adoption.ts (#500) owns config/layout body + revision + fingerprint. It changes only by authoritative adoption (adoptAuthoritativeGated, profiles reload/post-write), own-write acceptance (acceptConfigWrite, acceptPairWrite) or warm-cache restore; paired writers take revisions from the re-read. Local staging is limited to files pinned by test/config-adoption-ownership.test.mjs. Ordinary debounced editor saves remain optimistic: a rejection that is neither a revision conflict (which reloads) nor a physical-geometry rollback keeps the local edit with a failure toast until the next authoritative reload. Writers that must undo on rejection capture an OptimisticAttempt (beginOptimistic/rollbackOptimistic), which restores the server-backed body only while that failed candidate is still current (#442, #500).
  • src/config-reload-authority.ts (#543): each reload holds a generation-scoped claim; a superseded one ends with no side effect.
  • ContentSigner (src/signing.ts) is the only signer for both cards: MAX_SIGN_PATHS (200) is shared with const.py; the cache is age-aware and pruned to live URLs; queued/in-flight are distinct, failures back off and in-flight entries expire after SIGN_INFLIGHT_MS.
  • Room climate is one roomClimateMap() pass per hass snapshot (#317) shared by full and static cards; exact entity: placement beats its parent device: (no double vote); never call the areaClimate() wrapper in render.
  • Load, continuity and fixed-floor rules: WARM-REMOUNT.md § 5.

Second card: houseplan-space-card (read-only)

One bundle registers houseplan-card (interactive) and the read-only houseplan-space-card (one space; src/houseplan-card.ts imports ./space-card). User options, fit and the deep link: USER-GUIDE.md §18, CANVAS.md §4.4, LIGHT.md › Which surfaces render pools. Shared modules keep the two views from diverging: space-geometry.ts (pure model/position math), space-render.ts (renderSpaceStatic(): plan, rooms and markers through buildDevices, ResolvedDevicePresentation and renderDeviceFace, no marker handlers), glow-scene.ts (opt-in light_pools, per-card bounded caches) and config-store.ts — one module-level {config, rev, configFingerprint, layout, layoutRev, layoutFingerprint} cache and one subscription for all embedded cards, seeded from houseplan_card_cfg_v1 and refreshed on houseplan_config_updated/houseplan_layout_updated without first clearing the visible snapshot. .hp-static-stage and every descendant (*, *::before, *::after) are pointer-events:none, overriding the markers' 44 px opt-in (#564, #664); only the footer button is interactive, asserted by demo/smoke_space_card.mjs with elementFromPoint. A card with floor ignores #space=.

Subsystems with their own canonical documents

  • Decor, plan image, furniture, custom images — space.decor[] is a purely visual layer with one selection/transform/history pipeline; image bytes live only in the asset store: DECOR-EDITOR, FURNITURE.
  • Light and Glow — one visibility region per source (#71); Glow is an overlay independent of the data fill (#55): LIGHT. Room fill — resolveEffectiveRoomFill() is the single projection for room floors, clean-floor holes and opening tunnels; room_color styles only borders and names; custom colour and legacy tokens (#56, #581): CONFIG-COMPATIBILITY.
  • Device state, light membership, action — resolvedDeviceStateEntities, resolvedLightSources and src/device-toggle.ts are the only resolvers (#94, #251, #318, #381): DEVICE-PRESENTATION.
  • Zero-thickness walls, nested rooms — resolveZeroWalls() feeds every renderer, Glow and sun (#306): WALL-THICKNESS. Kiosk, navigation — kiosk is a card flag, not a mode; LS_NAV stores only the space (#93, #210): UX-MODES.

Camera and mode transitions (#101, #82)

Two one-token/one-RAF controllers own every animated camera change and leave no CSS timers or WAAPI animations behind. ModeTransitionController (src/mode-transition.ts) is the only timeline for entering, leaving and switching editors: it interpolates measured chrome height, stage geometry, world-space camera centre, logarithmic pixels-per-unit, stage/paper colours, day/night brightness and presentation weights together, deriving each viewBox from the current stage aspect; the stage is inert meanwhile, header tabs stay live for a retarget. src/viewport-transition.ts animates discrete zoom in a settled mode with the same easing but only {zoom, viewBox} (no chrome, background, layer opacity or CSS transform) and lives in the core View bundle. The component stays the sole camera writer; ownership boundaries and timings: CANVAS › View/editor camera handoff and §5.

Settings tiers (owner's principle, 2026-07-26)

Four levels: global (config.settings) → space (space.settings) → room (room.settings) → device (marker.*). Duplicated options are deliberate: the more specific tier wins and "unset" always means "inherit". Resolution lives in pure helpers (spaceDisplayOf, roomFillModeOf, roomGlowOf, roomTempRangeOf, sourceValue, resolveToggleIntent), never inline in render; each tier keeps its own dialog (General settings, space, room, marker).

Schema as the source of truth (#33)

The Voluptuous schema in custom_components/houseplan/validation.py is the single owner of the persisted config/layout shape. The generated manifest scripts/config-schema.json, the enum parity test with its self-checking allow-list, the decision registry scripts/config-field-registry.mjs and the lifecycle fixtures keep every other world honest against it: CONFIG-COMPATIBILITY › Schema manifest and parity.

No hidden discovery knobs (#44)

Every stored key that shapes device discovery is a visible, supported setting or does not exist. settings.group_lights and settings.exclude_integrations live in the device catalog's Discovery-filters section and are resolved only by effectiveExcludedIntegrations(): FILTERING › Seeding.

Contextual Zigbee topology (#54, #457, #464)

The initial View graph holds only the fail-closed settings reader and a dynamic overlay bridge; the overlay chunk loads only for a saved settings.zigbee_topology.enabled === true, a real HA admin, full-card View and a non-kiosk surface. General Settings loads provider transport only when an enabled setting needs status or the admin presses a provider action. zigbee-topology.ts normalises ZHA and Zigbee2MQTT into unordered edge pairs with directional observations, maps IEEE nodes through exact registry ownership and resolves only edges incident to the hovered marker, never inventing neighbours. zigbee-topology-runtime.ts keeps a per-connection memory cache with in-flight dedupe: ZHA reads zha/devices without a scan; Z2M checks the retained bridge-info topic, sends one correlated raw routes:false request through mqtt.publish, rejects retained/foreign/late replies and always unsubscribes. The pointer-transparent overlay is a child of the .devlayer camera, projected once with the markers by live-viewport.ts; only the source and drawable neighbour markers are promoted above it, through transient attributes the overlay owns and clears. Unknown-LQI links are a 4 px #2e2e2e casing under the 2 px grey core. Persistence, privacy: CONFIG-COMPATIBILITY.

Live viewport: a transform per frame, a viewBox on a budget (#531, #579)

Rewriting the SVG viewBox re-rasterises the whole scene, so per-frame writes made panning crawl. paintLiveViewport keeps an anchor (the viewBox in the DOM and when it was written) and moves scene nodes each frame with the HTML layers' projective transform (liveLayerProjection, transform-origin: 0 0); needsViewBoxRefresh rewrites the anchor only after LIVE_VIEWBOX_REFRESH_MS (100 ms) or a LIVE_VIEWBOX_REFRESH_SHIFT (15 %) shift/scale change — module constants, not settings. Scene nodes project from the anchor, HTML layers from the last settled Lit frame; both land on the current view, keeping #451's one-CSS-pixel marker contract on every frame. .stage stays the outer clip, but a transformed scene SVG gets inline overflow: visible so rasterised content covers the incoming edge, even with the pointer held still (#544); HTML layers never do. The exposure is bounded by an inline clip-path: inset(-25%) (LIVE_SCENE_EXPOSURE_CLIP) set and removed with it — beyond the 15 % refresh threshold, but never the whole plan: unbounded, a promoted scene grew with zoom² and at 800 % × DPR 2 exhausted GPU memory (white frames, #689). A scene marked data-hp-live-overflow="clip" — the filtered day-cycle outline — is projected but never exposed. From the first live paint to the terminal commit a scene SVG stays in one compositor lifecycle: a refresh or Lit frame may replace its anchor but never demote/re-promote it — HA Companion WebView shows that as a blank frame (#579). The day-cycle paper outline joins these roots before the first camera move; the static card uses its stage-sized form from the first frame (#582, Key decision 7). Unchanged values are never rewritten, so idle frames stay byte-identical; commitHouseplanViewport removes the transforms and forces the final viewBox.

English and Russian ship whole (#400)

en and ru are synchronous dictionaries in the initial chunk; de and fr load lazily (src/i18n/registry.ts), editor-only strings included. At the decision the 38 settings-help entries of #86 cost 2 654 B gzip (0.9 % of the initial-View budget); splitting would need a second dictionary half, a runtime merge, an extra request and a second source for the en.json key type (#391). Budget planning assumes whole dictionaries; revisit only if editor text grows by tens of kilobytes.

Backend quality gates (#42)

tests_backend/requirements.txt is the single source of backend CI dependencies; ruff, strict mypy, the sys.modules guard, the coverage baseline and the geometry_parity job: TESTING › Backend quality gates. const.ERROR_CODES / ERROR_CODE_FAMILIES are THE stable error contract: every emitted code is registered and localized (scanner test); invalid_passage_fields and invalid_partition_opening_jamb_margin carry structured JSON details; the frontend renders unknown codes localized, code first, raw messages to the console.

Summary panel boundary (#437)

settings.summary_panel is the only shared persistence of this read-only overlay; prepare_ordinary_summary_candidate() is the common backend boundary of config/set and plan/optimize (full import bypasses it), and config/get.summary_panel_api, never cached config, grants editing. Persistence and local keys: CONFIG-COMPATIBILITY › Summary panel namespace. summary-panel.ts owns defaults, stable ids, fit predicates and local-key encoding; summary-panel-picker.ts a non-DOM entity_id + friendly_name index reused across state changes; summary-panel-runtime-loaded.ts the lazy View controller — a screen-space, non-SVG sibling of the camera layer outside content bounds, with one minute-aligned timer and a lifecycle generation binding dialog, draft, picker and async work to one route/user/permission/kiosk identity. summary-runtime-loader.ts shares code, never state, and attaches a warm runtime before the first render (#506). summary-panel-editor.ts loads on the settings button; its revision-checked shared write precedes the local show choice. #505 styles and the 190 ms phases stay lazy and never move the camera; editors and the space card never load the panel. Device totals need an authoritative registry snapshot and dedupe parent device ids before visual filters; clean area unions canonical room floors per space with that space's cell_cm.

Private support boundary (#43)

Help & feedback is rendered by the lazy editor runtime even in View; form state is component memory only. Report controls appear only when config/get.support_api equals SUPPORT_API_VERSION (1); release versions are diagnostic. houseplan/support/preview (may_write, bounded capability enums, dialog-scoped id) loads one coherent config/layout pair under the shared write lock and passes disposable validated copies to support_package.py, a strict projection boundary: a new allowlisted object with package-local pseudonyms and canonical sorted JSON, never raw storage redacted afterwards. Bytes, SHA-256 and expiry stay in HouseplanData memory, bound to user and draft for ten minutes; preview, download and submit use those bytes; preview/discard (idempotent) or a confirmed submit consumes the token. houseplan/support/submit re-validates text, resolves only an owned live token and calls support_transport.py: one compile-time HTTPS URL, no redirects, bounded timeouts and response size, stable local failure codes that never reflect the response. The relay (scripts/support-relay/) deploys separately and is excluded from the HACS artifact. Content and retention: SUPPORT-PRIVACY.

Mutation tooling boundaries (#558)

scripts/mutation-gate.mjs stays the stable CLI but is only an orchestrator and compatibility export surface over mutation-registry.mjs (declarations), mutation-selection.mjs (diff/guard-input selection), mutation-evidence.mjs (witness fingerprints, caught ledger) and mutation-execution.mjs (worktree runs); dependencies never point back to the CLI. Guard-input caching is invocation-scoped (one resolver, one tracked-file snapshot); persisted success exists only in the explicit caught-witness ledger. Usage: TESTING.