17 KiB
#373 — Tight house framing for houseplan-space-card
Issue: #373
Scenario
The home administrator embeds custom:houseplan-space-card in a compact
Home Assistant dashboard for household members, a wall tablet or a phone. The
stored space also contains a large backdrop or auxiliary objects outside the
building, but this card is meant to show the house itself as large as possible.
The administrator selects a tighter frame for this one card instance. View and kiosk consumers then see the complete structural plan without the ordinary outer breathing room; no editor is involved.
What the person sees before and after
Before, the static card keeps a 5% outer frame and lets non-house content widen the view; after opting in, the complete house geometry touches the available frame without intentional padding, while backdrop, decor, labels and devices outside the house no longer make the house smaller.
Problem
renderSpaceStatic() currently calls spaceFrame(space, placed, 0.05) (or the
per-edge #372 variant for title: ""). That frame is deliberately the broad
content frame from docs/CANVAS.md: rooms, the transformed backdrop and visible
placed objects can all vote, and the result is padded by 5% of its longer side.
This is correct for the existing default and for the full interactive card, but
it wastes scarce dashboard area when the embedded card is intended to show the
building rather than every auxiliary object.
The reporter chose the non-cropping alternative in the latest issue comment:
remove the 5% padding, keep all house geometry visible, and derive the fit from
room/house geometry instead of backdrop or decorative objects. A CSS-cover
mode that can crop the house is explicitly not requested.
Scope
- only
custom:houseplan-space-card; - one opt-in Lovelace card setting,
fit: house; - visual-editor choice between the existing frame and the tight house frame;
- a zero-intentional-padding structural frame containing every rendered room, room wall, independent wall/draft, column, zero-thickness wall and visible door/window/gate symbol;
- exclusion of backdrop image, decor, room labels, device markers and their
badges from the
houseframe vote; - safe fallback to the existing frame when the space has no valid structural geometry;
- consistent geometry, themes and touch/desktop View behaviour;
- en/ru/de/fr UI strings, RU/EN user guide, architecture/canvas contract, changelogs and targeted tests.
Non-scope
- any change to the full
custom:houseplan-card, its Fit all action, View, kiosk, editors, zoom, pan or outlier hint; - CSS
cover, aspect-ratio distortion or any mode allowed to crop structural house geometry; - hiding, deleting or moving backdrop, decor, labels, markers or devices;
- changing marker size, room-label position, Glow, sunlight, wall geometry, opening state or HA actions;
- changing the stored House Plan model, backend schema,
space.view_box, config/layout revisions, exports or imports; - arbitrary user-configurable padding values;
- changing the #372 meaning of
title: ""in the default frame; - automatically enabling the new mode for existing cards.
Behaviour contract
Public card configuration
type: custom:houseplan-space-card
space: ground_floor
fit: house
fitacceptscontent | house.- Missing, empty or unknown
fitresolves fail-safe tocontent. contentis the exact current behaviour: the current content voters, main-mass/outlier policy and 5% padding remain unchanged. Existing YAML withoutfitmust produce the same frame.houseselects the new tight structural frame described below. It never becomes the implicit default.
Tight structural frame
- The structural frame contains the union of all finite, sane visible
architectural extents for the selected space:
- room polygon/rectangle floors;
- the complete outer envelope of positive-thickness room walls;
- independent walls, saved wall drafts and columns;
- zero-thickness room and independent wall axes;
- the full visible state-independent envelope of door, window and gate symbols, including wall jambs, leaf tips and swing arcs in either state. An open passage contributes through its structural wall/tunnel geometry and has no extra standalone symbol.
- All structural items participate. The broad content-frame outlier vote may
not discard a detached room or wing in
housemode: sane structural geometry is part of the house and must stay visible. - The resolved structural union receives zero intentional padding on all four sides. Bounds still include the actual rendered wall/symbol stroke envelope, so “zero padding” may not clip a stroke by half its width.
- Backdrop image, decor, room labels, device markers, value/LQI badges, pulse shells and purely visual Glow/sun extents do not enlarge this frame. They remain rendered in the same coordinate system and may be clipped when they lie outside the structural house frame; this is the user-confirmed trade-off.
- The SVG, marker layer, room-label layer, backdrop, day-cycle environment and
continuity overlay all consume the single resolved
viewBox. Nothing is independently translated or scaled after the fit is chosen. - If no valid structural item exists (for example an image-only or empty
space),
housefalls back atomically to the ordinarycontentframe. It must not create an empty, zero-sized, NaN or infiniteviewBox. - Structural computation is state-independent. Opening sensor changes, device state updates, Glow animation and language/theme changes do not resize the card or move the house inside it.
Interaction with existing options
title,show_button,light_pools, state/value options and footer deep links retain their existing semantics.- With
fit: content, explicittitle: ""keeps #372: only the top 5% padding disappears, while side/bottom padding remains. - With
fit: house, all four intentional frame paddings are already zero;title: ""only removes the header and does not change the structuralviewBoxfurther.
UX and accessibility
- The Lovelace visual editor exposes one dropdown labelled Framing with:
- All visible content →
content; - Tight to house geometry →
house.
- All visible content →
- A missing legacy value is displayed as All visible content. A subsequent
visual-editor save may materialise
fit: content; this is semantically lossless. - No crop/cover choice is shown.
- The schematic remains
pointer-events:none, non-focusable and read-only. The existing footer button remains the only interactive element. - No new hover, gesture or animation is introduced. The frame is identical on fine/coarse pointers, narrow/wide cards and light/dark themes.
Data model, migration and compatibility
The field belongs only to the Lovelace card instance:
interface SpaceCardConfig {
fit?: 'content' | 'house';
}
- no House Plan server-config or layout field is added;
- no model/store/schema version or backend validation changes;
- no data migration and no write to Home Assistant storage;
- old cards and old YAML remain on the
contentpath; - current code normalises only at read/render and visual-editor boundaries;
- an older frontend ignores the unknown Lovelace key and renders its historical content frame, without damaging House Plan data;
- rollback is therefore data-free.
i18n
Add complete keys to src/i18n/{en,ru,de,fr}.json for:
- Framing;
- All visible content;
- Tight to house geometry.
The technical YAML literals content and house are not translated. Missing
translations may not leave a raw key or an unlabelled select option.
Performance and bundle
- Frame resolution remains one bounded O(rooms + walls + openings + columns) pass per structural render candidate.
- HA state-only ticks reuse/calculate the same structural fingerprint; they may
not introduce DOM measurement or
getBBox()layout reads. - No second hidden SVG, canvas raster scan, network request or dependency.
- Existing
fit: contentcost and output remain unchanged. - Initial View bundle budget is not raised.
Touch and themes
houseplan-space-cardis a View surface, so narrow phone and wall-tablet rendering is release-blocking.- The new mode is presentation-only and must not cause horizontal overflow, clipped structural strokes or a zero-height card at representative 320 px and 900 px widths.
- Pointer/tap behaviour is unchanged; the stage stays inert and the footer tap target stays usable.
- Light/dark and day-cycle modes share exactly the same structural frame.
Affected files and modules
src/space-card.ts— publicfittype/default and static-render projection;src/space-editor.ts— localised framing dropdown and legacy/default projection;src/space-render.ts— select ordinary vs structural frame and keep onevb;- a narrow pure helper in
src/space-geometry.ts,src/render/opening-symbol.tsor a dedicated static-frame module — finite structural items and opening envelopes without DOM measurement; src/i18n/{en,ru,de,fr}.json— editor labels;test/**— pure frame/config/opening-envelope regressions;demo/smoke_space_card.mjs— real-card comparison and View/touch contract;docs/USER-GUIDE.md,docs/USER-GUIDE.ru.md,docs/ARCHITECTURE.md,docs/CANVAS.md— public and canonical contracts;docs/CHANGELOG.md,docs/CHANGELOG.ru.md— release bullet linked to #373.
Acceptance criteria
- AC1 — default compatibility: missing/empty/unknown
fitand explicitfit: contentproduce the same numericviewBox, stage aspect and #372 title interaction as the pre-change control fixture. Evidence: unit tests plusdemo/smoke_space_card.mjscomparing four configurations. - AC2 — tight structural frame: on a fixture with an oversized transformed
backdrop, far decor, far marker and room label,
fit: houseexcludes those items from its bounds, removes all four intentional 5% pads and yields a strictly tighter frame thancontent. Evidence: pure unit assertions and browser smoke over the rendered SVGviewBox. - AC3 — no structural crop: rooms, positive/zero walls, independent walls,
drafts, columns and door/window/gate envelopes in their extreme states are
all within the
houseframe including visible strokes. Evidence: pure geometry table tests and a browser semantic-pixel/bounds witness that fails when any structural edge is clipped. - AC4 — stable and safe fallback: an empty space, image-only space,
collinear structural geometry and invalid/unknown
fitalways produce a finite positive frame; image-onlyhouseequals the ordinary content frame. Opening/device state ticks do not change it. Evidence: unit + browser smoke with state substitution. - AC5 — editor/i18n contract: the GUI displays the default and house choices
in en/ru/de/fr, saves exact literals, reloads them, and never offers cover.
Evidence: unit/source contract plus browser editor smoke or review of an
existing faithful
ha-formharness. - AC6 — View/touch parity: tight mode renders without horizontal overflow at 320 px and 900 px, in light/dark/day-cycle, keeps the stage inert and keeps the footer action unchanged. Evidence: targeted browser smoke and code review.
- AC7 — performance: state-only rerenders do not perform DOM geometry reads
and the structural pass stays bounded; bundle budget remains green.
Evidence: code review,
npm run bundle:budgetand existing static-card performance profile before beta. - AC8 — documentation and release: RU/EN guide documents
fit, its default, the intentional auxiliary-object crop and its interaction withtitle: ""; architecture/canvas docs describe the second static-only frame; both changelogs link #373. Evidence: docs/process gates and code review.
Automated test plan
- Pure unit: validate the
fitresolver (undefined,content,house, unknown) and prove the default branch retains current padding. - Pure unit: build a synthetic structural scene containing a polygon room, thick wall envelope, zero wall, independent wall/draft, column and every opening type; compare exact finite structural bounds and zero padding.
- Pure unit: substitute closed/open/extreme opening amounts and prove the state-independent envelope and resulting frame do not change.
- Pure unit: add far backdrop/decor/marker/label items; prove ordinary content widens while house bounds do not. Add a detached valid room and prove it is retained rather than outlier-rejected.
- Pure unit: empty/image-only/collinear/invalid inputs fall back to a finite positive ordinary frame.
- Extend
demo/smoke_space_card.mjswith control, explicitcontent,houseandhouse + title:""cards. Assert numeric frames, no clipped structural edge, common overlay coordinates, unchanged footer deep link and inert stage. - Repeat tight mode at narrow light and wide dark widths and change a door sensor/device state; the viewBox remains stable and no overflow appears.
- In the implementation cycle run
npm run typecheck,npm test,npm run build,npm run bundle:sync,npm run bundle:budget,node scripts/no-new-any.mjs --base origin/dev --head HEAD,node scripts/check-docs.mjs, smoke selection and the named target smoke. Run full golden/smoke/performance and exact-SHA Linux Validate before beta, following the process.
Risks
- Zero padding clips the outer half of a wall or opening stroke. Mitigated by structural visible-envelope math and AC3, not by reintroducing arbitrary padding.
- Backdrop/decor still votes through
spaceFrame(). Mitigated by a separate explicit structural-item path and AC2; the ordinary helper remains untouched. - Stateful door movement resizes the card. Mitigated by one conservative state-independent opening envelope and AC3/AC4.
- A detached building is mistaken for an outlier. Mitigated by using all sane structural items for the house union and the detached-room test.
- The default changes accidentally. Mitigated by an explicit resolver whose
fallback is
content, numeric compatibility tests and no implicit opt-in. - Auxiliary content is intentionally clipped but undocumented. Mitigated by the GUI wording, guide warning and AC8.
- Static and full cards drift. The new path is static-only by contract; all existing shared geometry primitives stay canonical, while the full card's content frame remains unchanged and covered by existing canvas tests.
- Docs screenshot fingerprint changes after
src/**. The canonical Docs screenshots workflow/acceptance remains required by the repository process.
Rollback
Remove the fit projection/editor option and always call the existing content
frame path. Existing cards without the option are already on that path; cards
with fit: house become ordinary content-framed cards when read by the rolled
back frontend. No House Plan config/layout data, migration or cleanup is needed.
Release artifacts
docs/CHANGELOG.mdanddocs/CHANGELOG.ru.md: opt-in tight house frame with a link to #373;docs/USER-GUIDE.mdanddocs/USER-GUIDE.ru.md: YAML/table/default, auxiliary crop andtitle: ""interaction;docs/ARCHITECTURE.mdanddocs/CANVAS.md: static-only structural frame and its relation to the canonical content frame;- targeted
demo/smoke_space_card.mjsresult with numeric and semantic visual evidence for standard/tight, narrow/wide and light/dark; - full existing golden verify to prove no default-path visual regressions; a new baseline is added only if targeted semantic evidence cannot make AC2/AC3 unambiguous, and may be accepted only from the reviewed complete Linux artifact;
- canonical Docs screenshots artifact accepted with
npm run docs:accept -- --reviewed --from=<artifact>becausesrc/**changes; - no backend, migration or security artifact; initial bundle budget and the static-card performance profile remain mandatory before beta.
Assumptions accepted provisionally; reviewer may change freely
- the public literals are
fit: content | house; names of internal helpers and exact file placement are technical choices; contentis explicit current semantics rather than a newly invented mode;- “house geometry” means the complete structural list in contract item 5, not room polygons alone, because room-only bounds would cut thick outer walls and opening leaves;
- objects deliberately excluded from bounds remain in DOM/SVG and may be clipped; they are not hidden or deleted;
housefalls back tocontentwhen no structure exists rather than framing an arbitrary stored square or rendering an empty card;- a conservative analytic opening envelope is acceptable if it is tight to the
maximum rendered symbol and state-independent; DOM
getBBox()is forbidden; - exact synthetic fixture names and sub-pixel tolerances belong to the test implementation, provided a one-pixel crop causes a deterministic failure.