mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
Stage 5 of #780. - Import summary: «Strips left unbound after import: {n}» from the backend `unbound_led_strips` count; «Optimize plans» reports strips passing through walls per space and edits none (AC16). - Linear field for long strips (ТЗ §13.2): pieces of at most the radius along the polyline, emitters thinned to r/4, each piece clipped to the visibility fans of its own emitters as separate clipPath children (no boolean pass per piece), one floor clip for the whole layer, no fan at all where nothing blocks within the radius; a grid index of body faces and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the large house: first stable frame ~1.4 s, warm space ~1.1 s locally. - led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table in demo/performance/budgets-led-strips.json, exact counters (zero recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over 20 cycles); added to the full performance workflow. - Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB (measured + 10 %, rounded up); overlaps with the initial and editor graphs refused; the lazy editor graph stays inside its ceiling. - Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants in the registry (7 browser guards in the inventory); config field registry entry `spaces[].led_strips`. - Golden: five new scenes on the `golden-led` space of the visual fixture (`ledStrips` option, the designer's four strips on #868D94), matrix v71. - Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES, ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand README, performance README; docs/design/led-strips with the unchanged designer archive, two paired frames and ACCEPTANCE.md; both changelogs. Issue: #780 User-Visible: yes
711 lines
46 KiB
Markdown
711 lines
46 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.
|
||
- `warm-mode-adoption.ts` completes immediate and delayed editor adoption with
|
||
the original View return snapshot and a request-owned refit hold. Explicit
|
||
mode/space navigation invalidates pending work before lazy-runtime awaits;
|
||
warm header/stage dimensions are published only after the corresponding
|
||
render, including the card-local header offset for pending chrome (#762).
|
||
Adoption completion lives in the lazy editor graph: it only executes after
|
||
that runtime is ready; View retains just cancellation and camera comparison.
|
||
The room draft payload is also built by the installed editor runtime.
|
||
This moves existing room-state reads across the already-defined editor port;
|
||
it adds no room state. #762 records the resulting host-reference/ported-private
|
||
counts in the monolith baseline, without widening its tolerance bands.
|
||
|
||
## 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).
|
||
|
||
## LED strips: lazy boundaries (#780)
|
||
|
||
`space.led_strips` belongs to the space; the link is one-way strip → marker
|
||
and the device model stays the only owner of state and services. Four modules:
|
||
|
||
| Module | Graph | Holds |
|
||
|---|---|---|
|
||
| `led-strip-gate.ts` | initial | which markers a space shows as a strip, the anchor, the page-wide loaders of both chunks |
|
||
| `led-strip-card.ts` | initial | delegation only: the Devices toolbar button, the device-dialog section, notes, the LED branch of the device history |
|
||
| `led-strip-runtime.ts` (+ `led-strip-geometry.ts`) | lazy `led` | frame, stripe, hit/focus, 2.5D, static card |
|
||
| `led-strip-field.ts` | lazy `led-field` | the linear field, loaded by the runtime only for an on strip in a Glow room |
|
||
| `led-strip-editor.ts` (+ `i18n/led`) | lazy `led-editor` | the Devices tool, tray, picker, representation switch, LED history commands |
|
||
|
||
A View without a displayed active strip, and the Devices editor without the
|
||
tool or an editable strip, load none of them. Each chunk checks the entry
|
||
build fingerprint; a failed load is fail-dark for the strips only and retried
|
||
on the next explicit entry. Budgets: `LAZY_LED_GZIP_CEILING` and
|
||
`LAZY_LED_EDITOR_GZIP_CEILING` in `scripts/bundle-budget.mjs`.
|