mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
681 lines
44 KiB
Markdown
681 lines
44 KiB
Markdown
# 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`](DEVICE-PRESENTATION.md).
|
||
|
||
### Presence radars (#485 Stage 1)
|
||
|
||
`marker.radar` is an optional versioned namespace saved only by the ordinary
|
||
config transaction. One backend `RadarCoordinator` per entry alone reads raw HA
|
||
states and streams clipped, ACL-filtered frames over `houseplan/radar/subscribe`;
|
||
observations, calibration samples and trails never reach config, uploads,
|
||
support reports or browser storage. Contract and code map: [`RADAR.md`](RADAR.md);
|
||
normative limits: [`specs/485-radar-presence-stage1.md`](specs/485-radar-presence-stage1.md).
|
||
|
||
### Robot vacuums
|
||
|
||
`src/vacuum-routes.ts` is the only map-to-space authority (six fail-closed
|
||
results, #162; mirrored byte-for-byte by `vacuum_routes.py`), `src/vacuum.ts`
|
||
owns telemetry normalization, path arbitration and smoothing, and `trails.py`
|
||
records runs server-side. Contract and code map: [`VACUUM.md`](VACUUM.md).
|
||
|
||
## Sizes
|
||
|
||
`icon_size` is a percentage of the plan (default 2.5; legacy px values > 8 fall
|
||
back to 2.5), converted once at the surface boundary and rendered in `cqw` inside
|
||
`.stage { container-type: inline-size }` — [`CANVAS.md`](CANVAS.md) §6.
|
||
|
||
## Sticky header
|
||
|
||
`.head` is `position: sticky; top: var(--header-height, 56px)`, so ordinary
|
||
dashboard cards keep `ha-card { overflow: visible }` (hidden overflow breaks
|
||
sticky). The bounded `panel-host` and Sections `layout="grid"` branches own their
|
||
complete height chain and clip at the external slot instead (Key decision 1).
|
||
|
||
## Device markers
|
||
|
||
`config.markers[]` records are
|
||
`{id, binding: 'device:<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`](FILTERING.md)).
|
||
|
||
View, kiosk, the static card and the dialog preview share one pipeline:
|
||
`device-visual.ts` classifies, `device-presentation.ts` resolves sources, the pure
|
||
`device-presentation-policy.ts` owns priority, `device-pulse.ts` owns activity and
|
||
`device-face.ts` only paints. `normalizeDeviceDisplay()` is the mandatory read gate
|
||
for `display` (`badge|icon_ripple|value|static_icon|value_static_icon`; legacy
|
||
`ripple` → `icon_ripple`). The saved coordinate is the icon-core centre;
|
||
overlapping 44 px targets get one screen-space owner (`device-hit-owner.ts`),
|
||
latched for the whole pointer sequence. Rules: [`DEVICE-PRESENTATION.md`](DEVICE-PRESENTATION.md);
|
||
stored fields: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md).
|
||
|
||
Attachments are staged in `up_*`, promoted into `<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`](DECOR-EDITOR.md) §7.
|
||
|
||
## Server-side configuration
|
||
|
||
`.storage/houseplan.config` holds `{model_version, spaces[], markers[], settings}`.
|
||
A space carries plan-image fields, `rooms[]` (with ordered `wall_ids`),
|
||
`wall_segments[]`, `partitions[]`, `wall_columns[]`, `openings[]`, `decor[]`,
|
||
`stairs[]` and `settings`. Coordinates keep the historical normalization
|
||
(`1.0` = `NORM_W` = 1000 render units) on an unbounded plane with a ±5000 guard
|
||
([`CANVAS.md`](CANVAS.md)); `plan_aspect` letterboxes the image and `plan_x/y`,
|
||
`plan_scale_x/y`, `plan_angle` transform it ([`DECOR-EDITOR.md`](DECOR-EDITOR.md) §3).
|
||
Layout v2 maps `device_id | rl_<roomId>` to `{s, x, y}`. Plan files are
|
||
copy-on-write `<config>/houseplan/plans/<space>.<token>.<ext>`, never deleted for
|
||
age ([`SCOPE.md`](SCOPE.md)). Migrations: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md).
|
||
|
||
### Persisted colour boundary
|
||
|
||
Every stored colour is exactly `#RRGGBB`: `src/color.ts` resolves it and
|
||
`validation.py::_COLOR` guards writes. Render-time resolvers re-apply a safe
|
||
default because old, imported or hand-edited stores are not migrated on read.
|
||
HA `rgb_color` is live state: three finite channels, clamped/rounded to 0–255 and
|
||
emitted only as generated `rgb(R, G, B)`. The final inline-style sink accepts
|
||
only those two forms; arbitrary CSS colour syntax would need a separate
|
||
product/security decision and must not be added to an individual sink.
|
||
|
||
## Room and independent wall geometry
|
||
|
||
Model v10 (#282, #306, #478): `wall_segments[]` is the authoritative catalog of
|
||
atomic room-wall intervals with stable ids, `rooms[].wall_ids[]` orders each
|
||
contour, and room polygons plus `walls[]` are read-compatibility projections.
|
||
Every accepted Walls-chain edge is an ordinary `partition`; `wall_columns` are
|
||
square/circular columns; neither creates or implicitly splits a room or HA area.
|
||
`cm:0` keeps a structural axis and identity without masonry; `space.zero_wall_style`
|
||
makes it dashed (transmits light) or solid (a zero-area barrier). Reads are
|
||
projection-only: a physical-geometry mutation builds a local candidate,
|
||
canonicalizes it, materialises the catalog (`src/wall-segment-model.ts` ↔
|
||
`wall_segment_model.py`, shared parity fixture), validates references and commits
|
||
one config transaction; ambiguity fails closed with no partial config, history or
|
||
revision. Model and lineage: [`WALL-THICKNESS.md`](WALL-THICKNESS.md) §1;
|
||
migrations and stale-client guard: [`CONFIG-COMPATIBILITY.md`](CONFIG-COMPATIBILITY.md)
|
||
(model v8–v10); rationale: [`adr/282-wall-geometry-representation.md`](adr/282-wall-geometry-representation.md).
|
||
|
||
Rooms may not partially overlap (lying on a shared wall is legal, a fully nested
|
||
island is supported). Merge/Split use **polyclip-ts** (not `polygon-clipping`,
|
||
see [`DEVELOPMENT.md`](DEVELOPMENT.md)): Merge accepts a pair only when the union
|
||
is one hole-free outline; Split cuts wall-to-wall and the larger part keeps the
|
||
room identity (name, area, devices).
|
||
|
||
`wallBodiesGeometry()` is the single physical masonry for flat full/static
|
||
rendering, 2.5D, paper, clean floor and Glow/sun occlusion. Its exterior shell
|
||
comes from the union of room centrelines plus surviving `outer` atoms; junctions
|
||
follow the bounded mitre/bevel rules (#249, #271, #272, #275, #288, #302, #309,
|
||
#310); independent bodies join through `physicalBodySet()` while raw quads keep
|
||
editor identity. The result is a typed component set (`ok`, `degraded-extra`,
|
||
`failed-core`; #197, #278): rendering keeps every valid component, mutation
|
||
preflight rejects `degraded-extra`, `failed-core` fails dark. It is computed state
|
||
only — cached per structural geometry, never written back, never rebuilt by HA
|
||
state ticks. Contract: [`WALL-THICKNESS.md`](WALL-THICKNESS.md) §2–§4, §9–§11.
|
||
|
||
## Markup editor
|
||
|
||
Card state: `_mode`, `_tool` (`select|draw|column|merge|split|resize|opening|
|
||
stairs|wallthick|delroom`), session `_path` on the `GRID_N = 240` lattice
|
||
(`_snap`, `CANVAS.md` §9). Committed geometry enters the named 50-command
|
||
Undo/Redo stack shared with Background (`DECOR-EDITOR.md` §6, §9); it survives
|
||
the echo of its own writes and clears on a newer external revision. Every
|
||
physical-geometry writer crosses one fail-closed boundary (#278,
|
||
`checkSpacePhysicalGeometry()`) before history/save, rechecked at the deferred
|
||
write; presentation edits bypass it. Contracts: Walls chain, faces, room
|
||
deletion, partitions — `WALL-THICKNESS.md` §6, §9–11, `CONFIG-COMPATIBILITY.md`
|
||
(#478, #461), `TOUCH-SUPPORT.md`; Resize (#277, #300) — `RESIZE.md`; near-axis
|
||
(#290, `src/near-axis.ts`) — `CANVAS.md` §9.3. `reconcileCoincidentPartitions`
|
||
(#276/#296/#477) and `normalizeWallIntervals` (#299) run plan-wide only in
|
||
explicit Optimize, locally in chain finish/room deletion, never in render or
|
||
ordinary saves. Saves strip the legacy root `space.segments`.
|
||
|
||
### Stairs (#663)
|
||
|
||
Separate Plan entity, not decor (`STAIRS.md`). Eager `StairViewRuntime`:
|
||
symbols, tooltip, guarded navigation; lazy `StairEditorRuntime`: drawing,
|
||
transforms, magnets, properties (a plan never loads the editor graph). The root
|
||
card keeps lifecycle, shared history/persistence and stage pointer terminals;
|
||
pure model, tread/trapezoid geometry and style resolution stay in `stairs.ts`.
|
||
|
||
The optional `color`/`opacity` and `fill_color`/`fill_opacity` fields are a
|
||
snapshot owned by the stair. Missing legacy fields resolve at render/dialog
|
||
time from the current decor default but are not written until the user saves
|
||
that stair. Screen renderers consume the colour fields; PDF deliberately uses
|
||
the same geometry with its existing monochrome ink palette.
|
||
|
||
## Editor chrome and contextual controls
|
||
|
||
One stable `.editbar` per editor: `.editbar-tools` (persistent tools,
|
||
Undo/Redo) and pinned `.editbar-end` (Close); transient controls never enter
|
||
this measured row. Selection actions, tool parameters, hints, palettes and the
|
||
approved groups (Opening, Stairs) come from one `EditorSecondaryModel` in the
|
||
single light-DOM `.editor-secondary-host` inside `.stage`
|
||
(`editor-secondary.ts`): outside the `_hdrH` measurement, pointer events only
|
||
on its visible surface, empty in the Device editor (future marker quick
|
||
actions go there). Mutating actions revalidate a deterministic `contextId`;
|
||
`Delete`/`Backspace` never fall through from it. Product rule: `UX-MODES.md`.
|
||
|
||
## Doors, windows, gates & passages
|
||
|
||
`space.openings[]` is plan geometry, not markers: `{id, type:
|
||
door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?,
|
||
flip_h?, flip_v?}`. `host` `{kind: wall|partition, id, t}` is authoritative and
|
||
`x/y/angle` its atomically refreshed projection; an absent host is the legacy
|
||
room-wall association, and no host ever falls back to a nearest wall
|
||
(`CONFIG-COMPATIBILITY.md` › #132/#157). One `OpeningWallIndex` feeds symbol,
|
||
cut, tunnel fill, Glow and the 2.5D face (`WALL-THICKNESS.md` §3–4,
|
||
`ISOMETRIC.md`, `LIGHT.md`). The Flat symbol follows easy-floorplan (MIT).
|
||
`openingAmount()` maps contact state to 0..1 — no sensor: door/gate open,
|
||
window closed; `unknown`/`unavailable` keep that default. Contact and lock are
|
||
opening-owned exact references: candidates follow HA binding status, render
|
||
reads the frozen active-registry projection (never raw `hass`), neither
|
||
consults marker tombstones. The `.oplock` badge never toggles a lock
|
||
(`resolveToggleIntent` → no-op, `SCOPE.md`); openings are edited only in Plan.
|
||
|
||
## Integration WS API
|
||
|
||
`auth.may_write()` is the single writer policy for WS and HTTP: administrators
|
||
always write; `admin_only` (default `true` when unset) restricts writing to
|
||
them; with `admin_only: false` other users write unless they belong to
|
||
`system-read-only`. A missing entry or incomplete group data fails closed.
|
||
Reads are deliberately broader: every authenticated user receives the complete
|
||
config/layout (no per-entity projection), trails without `source` entity ids
|
||
(#626) and may toggle virtual lights. **W** = writer-only (else
|
||
`unauthorized`); runtime-backed commands answer `not_ready` before setup.
|
||
|
||
| `houseplan/…` | Parameters | Result · domain errors |
|
||
|---|---|---|
|
||
| `config/get` | `space_id?`, `fields?`, `marker_fields?` (#256; project only the document) | `{config, rev, virtual_lights:{rev,config_rev,off[]}, can_write, can_optimize_undo, undo_kind, integration_version, support_api, decor_assets_api, summary_panel_api, radar_stage1_api?}` |
|
||
| `config/set` **W** | `config`, `expected_rev` | `{ok, rev}` · `conflict`, `too_large` (2 MiB), `invalid_format`, `missing_plan`, semantic codes below |
|
||
| `layout/get` | `space_id?` | `{layout:{id:{x,y,s?}}, rev, can_optimize_undo, undo_kind}` |
|
||
| `layout/set` **W** | `layout`, `expected_rev` | `{ok, rev}` · `conflict` (external clients; the card writes points) |
|
||
| `layout/update` **W** | `device_id`, `pos` | `{ok, rev, ignored?: removed\|missing_virtual}` — a tombstoned owner's late drag is acknowledged, not stored |
|
||
| `layout/delete` **W** | `device_id` | `{ok, rev}` (`rev: null` when nothing was stored) |
|
||
| `space/delete` **W** | `space_id`, `expected_config_rev`, `expected_layout_rev` | `{ok, config_rev, layout_rev, removed_layout}` · `space_in_use`, `space_not_found`, `invalid_space_id` |
|
||
| `plan/optimize` **W** | `config`, `layout`, both expected revs | `{ok, config_rev, layout_rev, can_undo}` — also the server-side wall-model migration barrier |
|
||
| `plan/optimize_undo` **W** | both expected revs | restores the one-deep Optimize/full-import backup · `no_backup` after any later edit |
|
||
| `geometry/repair` **W** | `space_id`, `aspect`, `dry_run?`, `undo?`, `expected_rev?` | manual re-transform of one space's positions (HP-1500-01) with one-deep `repair_backup` · `nothing_to_repair`, `no_backup` |
|
||
| `export/create` **W** | `kind: full\|space`, `space_id?`, `plan_only?`, `card_version` | `{document, filename}` |
|
||
| `import/revalidate` **W** | `token`, `duplicate_policy?` | refreshed preview and current revisions |
|
||
| `import/apply` **W** | `token`, both expected revs, `duplicate_policy?`, `confirm_missing_content?` | paired commit; full import gets one-deep undo · `conflict`, `preview_expired`, `content_confirmation_required`, `missing_plan`, `missing_content` |
|
||
| `virtual_light/toggle` | `marker_id` | `{marker_id, on, rev}` · `not_toggleable` |
|
||
| `trail/get` / `trail/delete` **W** | — / `marker_id` | `{trails:{marker:{current,previous}}}` / `{ok, removed}` |
|
||
| `plans/list` **W** / `plans/delete` **W** | — / `name` | newest 60 `{name,url,size,modified,used_by}` + `total` / `{ok, removed}` · `in_use`, `invalid_name` |
|
||
| `plan/set` **W** | `space_id`, `ext`, `data` (base64) | `{ok, url}` — kept only for pre-#617 cards; current cards use HTTP |
|
||
| `files/migrate` **W** | `from_id`, `to_id` | `{ok, mapping, copied}` — copies, never moves or overwrites |
|
||
| `files/cleanup` **W** | `marker_id` | `{ok, removed, kept}` — removes only files the stored config does not reference |
|
||
| `assets/list` **W** / `assets/delete` **W** | — / `asset_id` | catalog with authoritative `used_by` / `{ok, removed}` · `in_use` |
|
||
| `assets/resolve` | `asset_ids[]` (≤200) | `{assets, missing}`; non-writers resolve only ids the saved config uses |
|
||
| `content/sign` | `paths[]` | `{urls}` — 24 h `authSig`, only `/api/houseplan/content/…`, first 200 paths |
|
||
| `support/*` **W**, `radar/*` | — | `SUPPORT-PRIVACY.md`, `RADAR.md` |
|
||
|
||
Semantic write codes: `invalid_config`, `invalid_passage_fields`,
|
||
`invalid_partition_opening_host`, `invalid_partition_opening_jamb_margin`,
|
||
`wall_model_client_outdated`, `wall_model_migration_blocked` (Optimize),
|
||
`junction_limit_<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](DECOR-EDITOR.md), [FURNITURE](FURNITURE.md).
|
||
- **Light and Glow** — one visibility region per source (#71); Glow is an overlay
|
||
independent of the data fill (#55): [LIGHT](LIGHT.md). **Room fill** —
|
||
`resolveEffectiveRoomFill()` is the single projection for room floors,
|
||
clean-floor holes and opening tunnels; `room_color` styles only borders and
|
||
names; custom colour and legacy tokens (#56, #581):
|
||
[CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md).
|
||
- **Device state, light membership, action** — `resolvedDeviceStateEntities`,
|
||
`resolvedLightSources` and `src/device-toggle.ts` are the only resolvers
|
||
(#94, #251, #318, #381): [DEVICE-PRESENTATION](DEVICE-PRESENTATION.md).
|
||
- **Zero-thickness walls, nested rooms** — `resolveZeroWalls()` feeds every
|
||
renderer, Glow and sun (#306): [WALL-THICKNESS](WALL-THICKNESS.md). **Kiosk,
|
||
navigation** — kiosk is a card flag, not a mode; `LS_NAV` stores only the space
|
||
(#93, #210): [UX-MODES](UX-MODES.md).
|
||
|
||
## Camera and mode transitions (#101, #82)
|
||
|
||
Two one-token/one-RAF controllers own every animated camera change and leave
|
||
no CSS timers or WAAPI animations behind. `ModeTransitionController`
|
||
(`src/mode-transition.ts`) is the only timeline for entering, leaving and
|
||
switching editors: it interpolates measured chrome height, stage geometry,
|
||
world-space camera centre, logarithmic pixels-per-unit, stage/paper colours,
|
||
day/night brightness and presentation weights together, deriving each `viewBox`
|
||
from the current stage aspect; the stage is inert meanwhile, header tabs stay
|
||
live for a retarget. `src/viewport-transition.ts` animates discrete zoom in a
|
||
settled mode with the same easing but only `{zoom, viewBox}` (no chrome,
|
||
background, layer opacity or CSS transform) and lives in the core View bundle.
|
||
The component stays the sole camera writer; ownership boundaries and timings:
|
||
[CANVAS](CANVAS.md) › View/editor camera handoff and §5.
|
||
|
||
## Settings tiers (owner's principle, 2026-07-26)
|
||
|
||
Four levels: **global (`config.settings`) → space (`space.settings`) → room
|
||
(`room.settings`) → device (`marker.*`)**. Duplicated options are deliberate:
|
||
the more specific tier wins and "unset" always means "inherit". Resolution
|
||
lives in pure helpers (`spaceDisplayOf`, `roomFillModeOf`, `roomGlowOf`,
|
||
`roomTempRangeOf`, `sourceValue`, `resolveToggleIntent`), never inline in render;
|
||
each tier keeps its own dialog (General settings, space, room, marker).
|
||
|
||
## Schema as the source of truth (#33)
|
||
|
||
The Voluptuous schema in `custom_components/houseplan/validation.py` is the
|
||
single owner of the persisted config/layout shape. The generated manifest
|
||
`scripts/config-schema.json`, the enum parity test with its self-checking
|
||
allow-list, the decision registry `scripts/config-field-registry.mjs` and the
|
||
lifecycle fixtures keep every other world honest against it:
|
||
[CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md) › Schema manifest and parity.
|
||
|
||
## No hidden discovery knobs (#44)
|
||
|
||
Every stored key that shapes device discovery is a visible, supported setting
|
||
or does not exist. `settings.group_lights` and `settings.exclude_integrations`
|
||
live in the device catalog's Discovery-filters section and are resolved only by
|
||
`effectiveExcludedIntegrations()`: [FILTERING](FILTERING.md) › Seeding.
|
||
|
||
## Contextual Zigbee topology (#54, #457, #464)
|
||
|
||
The initial View graph holds only the fail-closed settings reader and a dynamic
|
||
overlay bridge; the overlay chunk loads only for a saved
|
||
`settings.zigbee_topology.enabled === true`, a real HA admin, full-card View and
|
||
a non-kiosk surface. General Settings loads provider transport only when an
|
||
enabled setting needs status or the admin presses a provider action.
|
||
`zigbee-topology.ts` normalises ZHA and Zigbee2MQTT into unordered edge pairs
|
||
with directional observations, maps IEEE nodes through exact registry ownership
|
||
and resolves only edges incident to the hovered marker, never inventing
|
||
neighbours. `zigbee-topology-runtime.ts` keeps a per-connection memory cache with
|
||
in-flight dedupe: ZHA reads `zha/devices` without a scan; Z2M checks the retained
|
||
bridge-info topic, sends one correlated raw `routes:false` request through
|
||
`mqtt.publish`, rejects retained/foreign/late replies and always unsubscribes.
|
||
The pointer-transparent overlay is a child of the `.devlayer` camera, projected
|
||
once with the markers by `live-viewport.ts`; only the source and drawable
|
||
neighbour markers are promoted above it, through transient attributes the
|
||
overlay owns and clears. Unknown-LQI links are a 4 px `#2e2e2e` casing under the
|
||
2 px grey core. Persistence, privacy: [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md).
|
||
|
||
## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579)
|
||
|
||
Rewriting the SVG `viewBox` re-rasterises the whole scene, so per-frame writes
|
||
made panning crawl. `paintLiveViewport` keeps an anchor (the `viewBox` in the DOM
|
||
and when it was written) and moves scene nodes each frame with the HTML layers'
|
||
projective transform (`liveLayerProjection`, `transform-origin: 0 0`);
|
||
`needsViewBoxRefresh` rewrites the anchor only after `LIVE_VIEWBOX_REFRESH_MS`
|
||
(100 ms) or a `LIVE_VIEWBOX_REFRESH_SHIFT` (15 %) shift/scale change — module
|
||
constants, not settings. Scene nodes project from the anchor, HTML layers from
|
||
the last settled Lit frame; both land on the current view, keeping #451's
|
||
one-CSS-pixel marker contract on every frame. `.stage` stays the outer clip, but
|
||
a transformed scene SVG gets inline `overflow: visible` so rasterised content
|
||
covers the incoming edge, even with the pointer held still (#544); HTML layers
|
||
never do. 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](TESTING.md) › Backend quality gates.
|
||
`const.ERROR_CODES` / `ERROR_CODE_FAMILIES` are THE stable error contract: every
|
||
emitted code is registered and localized (scanner test); `invalid_passage_fields`
|
||
and `invalid_partition_opening_jamb_margin` carry structured JSON details; the
|
||
frontend renders unknown codes localized, code first, raw messages to the console.
|
||
|
||
## Summary panel boundary (#437)
|
||
|
||
`settings.summary_panel` is the only shared persistence of this read-only overlay;
|
||
`prepare_ordinary_summary_candidate()` is the common backend boundary of
|
||
`config/set` and `plan/optimize` (full import bypasses it), and
|
||
`config/get.summary_panel_api`, never cached config, grants editing. Persistence
|
||
and local keys: [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md) › Summary panel namespace.
|
||
`summary-panel.ts` owns defaults, stable ids, fit predicates and local-key
|
||
encoding; `summary-panel-picker.ts` a non-DOM `entity_id + friendly_name` index
|
||
reused across state changes; `summary-panel-runtime-loaded.ts` the lazy View
|
||
controller — a screen-space, non-SVG sibling of the camera layer outside content
|
||
bounds, with one minute-aligned timer and a lifecycle generation binding dialog,
|
||
draft, picker and async work to one route/user/permission/kiosk identity.
|
||
`summary-runtime-loader.ts` shares code, never state, and attaches a warm runtime
|
||
before the first render ([#506](specs/506-startup-performance.md)).
|
||
`summary-panel-editor.ts` loads on the settings button; its revision-checked
|
||
shared write precedes the local show choice. #505 styles and the 190 ms phases
|
||
stay lazy and never move the camera; editors and the space card never load the
|
||
panel. Device totals need an authoritative registry snapshot and dedupe parent
|
||
device ids before visual filters; clean area unions canonical room floors per
|
||
space with that space's `cell_cm`.
|
||
|
||
## Private support boundary (#43)
|
||
|
||
Help & feedback is rendered by the lazy editor runtime even in View; form state
|
||
is component memory only. Report controls appear only when
|
||
`config/get.support_api` equals `SUPPORT_API_VERSION` (1); release versions are
|
||
diagnostic. `houseplan/support/preview` (`may_write`, bounded capability enums,
|
||
dialog-scoped id) loads one coherent config/layout pair under the shared write
|
||
lock and passes disposable validated copies to `support_package.py`, a strict
|
||
projection boundary: a new allowlisted object with package-local pseudonyms and
|
||
canonical sorted JSON, never raw storage redacted afterwards. Bytes, SHA-256 and
|
||
expiry stay in `HouseplanData` memory, bound to user and draft for ten minutes;
|
||
preview, download and submit use those bytes; `preview/discard` (idempotent) or a
|
||
confirmed submit consumes the token. `houseplan/support/submit` re-validates text,
|
||
resolves only an owned live token and calls `support_transport.py`: one
|
||
compile-time HTTPS URL, no redirects, bounded timeouts and response size, stable
|
||
local failure codes that never reflect the response. The relay
|
||
(`scripts/support-relay/`) deploys separately and is excluded from the HACS
|
||
artifact. Content and retention: [SUPPORT-PRIVACY](SUPPORT-PRIVACY.md).
|
||
|
||
## Mutation tooling boundaries (#558)
|
||
|
||
`scripts/mutation-gate.mjs` stays the stable CLI but is only an orchestrator and
|
||
compatibility export surface over `mutation-registry.mjs` (declarations),
|
||
`mutation-selection.mjs` (diff/guard-input selection), `mutation-evidence.mjs`
|
||
(witness fingerprints, caught ledger) and `mutation-execution.mjs` (worktree
|
||
runs); dependencies never point back to the CLI. Guard-input caching is
|
||
invocation-scoped (one resolver, one tracked-file snapshot); persisted success
|
||
exists only in the explicit caught-witness ledger. Usage: [TESTING](TESTING.md).
|