A hidden device no longer stretches the plan's frame

Audit dev@2c947f4, DEV-2C947-01 (P2). One visible room and one marker with a
saved position 90 canvases out, then the marker is hidden: the auditor's probe
measured a frame 112.375x wider than the room it drew — the house opened as a
dot in the corner of empty canvas. The same on `houseplan-space-card`.

Both cards filtered the devices for RENDERING and framed the unfiltered list.
The full card's `_contentItems` walked `_devices` without looking at `hidden`,
while the renderer a few lines later drew `!d.hidden`; `space-render.ts` said
it out loud — `devs = spaceDevs.filter(d => !d.hidden)` for the markers,
`spaceDevs` for the frame.

The frame is PRESENTATION (docs/CANVAS.md §4), so it follows what is drawn.
Hidden devices keep everything the filtering contract gives them: they are
still built, still counted by room LQI, still hold their cell in the auto-grid
roster (so hiding one does not move a visible neighbour) — they are simply not
content items. The device editor's ghosts are not items either: reaching a
ghost is what the §5 pan slack is for, and making the frame follow a local,
ephemeral editor toggle would have made the opening view depend on which tab
had it switched on.

demo/smoke_canvas_frame.mjs is the auditor's probe, both cards: with the
marker visible the frame holds it (2 items is below MIN_VOTERS, so the outlier
vote cannot quietly rescue the test); hidden, the marker is gone from the DOM,
the frame is exactly the room's 60..940 and the room fills the stage. Three of
its checks are red on the parent commit.
This commit is contained in:
Matysh
2026-08-04 01:42:44 +03:00
parent a7d58f0552
commit f4ad843619
9 changed files with 159 additions and 10 deletions
File diff suppressed because one or more lines are too long
+118
View File
@@ -0,0 +1,118 @@
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
//
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
// frame — hiding a marker that had wandered into the yard left the visible
// house a dot in the corner (the full card AND the static
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = {};
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
// Load a config/layout into the card AND into the demo's WS stub, so the
// static card (which fetches its own copy) sees exactly the same house.
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
const c = window.__card;
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
for (const k of Object.keys(srvLay)) delete srvLay[k];
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
c._serverCfg = srvCfg;
c._layout = JSON.parse(JSON.stringify(l2));
c._modelCache = null;
c._frame = null;
c._showFar = false;
c._space = sp;
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c._view = null;
c._zoom = 1;
c.requestUpdate();
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
}, [cfg, layout, space]);
// ---------------------------------------------------------------- 01: hidden
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
// never runs and the far position is guaranteed to stay in the core — the
// frame either sees the marker or it does not.
const oneRoom = (hidden) => ({
spaces: [{
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
}],
// a made-up area: no auto device of the demo house lands in this space, so
// the only content is the room and the one marker below
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
settings: { filter_seeded: true },
});
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
await load(oneRoom(false), FAR_POS, 'h1');
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const b = c._baseVb();
return {
// premise: while it IS drawn, the marker is content and the frame holds it
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
visibleMarkerWidensTheFrame: b[2] > 50000,
};
}));
await load(oneRoom(true), FAR_POS, 'h1');
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const b = c._baseVb();
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
// the room is 100..900 render units, padded by 5 % of the longer side
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
// and the room fills the screen instead of being a dot 112x too small
const st = sr.querySelector('.stage').getBoundingClientRect();
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
o.roomFillsTheStage = poly.width > st.width * 0.7;
return o;
}));
// the same house on the STATIC card (space-render.ts drew `devs` but framed
// `spaceDevs` — the identical split)
Object.assign(out, await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const o = {};
const host = document.createElement('div');
host.style.cssText = 'width:400px;height:400px';
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
el.hass = window.__card.hass;
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 80));
}
await el.updateComplete;
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
o.staticFrameIsTheVisibleRoom = vb.length === 4
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
host.remove();
return o;
}));
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -81,7 +81,12 @@ tested. Input is a list of **items**, one per drawn/placed object:
* the backdrop image rectangle, when the space has one;
* every opening (door/window) end-to-end segment;
* every decor shape;
* every device the layout actually places in this space.
* every device the layout actually places in this space **and that the card
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
frame is presentation, and an object nobody can see must not decide what the
plan opens on. It keeps its auto-grid cell and every aggregation it feeds;
it simply is not an item here. Ghosts in the device editor are not items
either — reaching them is §5's job, not the opening view's.
Output:
+9
View File
@@ -51,6 +51,15 @@ the old behaviour until an editing client materialises it.
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
the current tab.
- Room LQI counts hidden devices (owner's decision).
- Hidden devices are NOT content for the CONTENT FRAME (docs/CANVAS.md §4,
audit DEV-2C947-01). The frame is presentation: an object the plan does not
draw may not decide what the plan opens on. Hiding a marker that had once
been dragged into the yard used to leave the visible house a dot in the
corner of a frame 112x too wide — on the full card and on
`houseplan-space-card` alike. They keep their place in the auto-grid roster
(so a visible neighbour does not move when one is hidden) and in every
aggregation listed here; only the frame stops seeing them, ghosts in the
device editor included — reaching a ghost is the pan slack's job (§5).
- Light fill and glow do NOT count hidden devices — an invisible device casts
no visible light (owner's decision). Room climate is registry-wide and
unaffected, as before.
+6
View File
@@ -992,6 +992,12 @@ require hands on real hardware — they remain for the human pass.
drawn plus a small margin, on every floor, with and without a backdrop
image (with one the IMAGE sets the extent — it must not be cropped to
the outlined rooms)
- [ ] **What is not drawn does not frame** (audit DEV-2C947-01): tick «hide
from plan» on a marker standing far from the house and the view snaps
back to the house — the hidden marker neither renders nor stretches the
frame, on the full card and on `houseplan-space-card`. Untick it and the
frame takes it in again; room LQI counted it the whole time
[auto: smoke_canvas_frame]
- [ ] **A far stray does not break the view** (§4.1): a marker dragged an
order of magnitude away leaves the opening view alone and raises the
inline chip «Объектов далеко от плана: N» with «Показать». No modal.
+9 -1
View File
@@ -1796,11 +1796,19 @@ class HouseplanCard extends LitElement {
* model, openings/decor/devices are added here because the model does not
* carry them. Devices use their RESOLVED position, so a marker parked in
* the far corner of the yard frames with the rest.
*
* HIDDEN devices are NOT content (DEV-2C947-01): the frame is presentation,
* and a device the plan does not draw must not decide what the plan opens
* on — hiding a marker that once wandered into the yard used to leave the
* house a dot in the corner of an empty frame. They keep counting for room
* LQI/climate and they keep their auto-grid cell (docs/FILTERING.md); only
* the frame stops seeing them, including the ghosts of the device editor,
* whose reach is the pan slack's job, not the opening view's.
*/
private _contentItems(m: SpaceModel): ContentItem[] {
const extra: ContentItem[] = [];
for (const d of this._devices) {
if (d.space !== m.id) continue;
if (d.space !== m.id || d.hidden) continue;
const p = this._pos(d);
extra.push({ minX: p.x, minY: p.y, maxX: p.x, maxY: p.y });
}
+5 -2
View File
@@ -83,9 +83,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// docs/CANVAS.md §4: the static card frames the CONTENT, exactly like the
// full one — `space.vb` is only the stored hint now, and on a plan drawn
// past the old unit square it framed empty canvas with the house off-screen.
// Markers placed outside every room count too (a gate sensor by the fence).
// Markers placed outside every room count too (a gate sensor by the fence)
// — but only the ones this card DRAWS: a hidden device is never painted
// here, so it must not stretch the frame either (DEV-2C947-01). It keeps
// its grid cell above; the frame is presentation, the roster is not.
const placed: ContentItem[] = [];
for (const d of spaceDevs) {
for (const d of devs) {
const sv = o.layout[d.id];
if (sv && sv.s === o.spaceId) {
const x = sv.x * NORM_W, y = sv.y * NORM_W;