v1.50.1: the v1.50.0 review (HP-1500-01..03)

- HP-1500-02: the stage budget was the absolute document coordinate, so any
  tall dashboard content before the card was billed as header and the stage
  collapsed to 0px. Measure our own chrome relative to the card plus a
  bounded (<=120px) allowance for what the viewport keeps above us; re-measure
  on window resize, remove the listener in disconnectedCallback.
- HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon)
  up to a 200-unit floor and ignores extra points outside a canvas envelope
  (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates
  to +-4 — any finite float used to pass, and one 1e100 hid the plan from
  every viewer. A thin real room keeps its tight frame; the gate sensor past
  the edge still stretches it.
- HP-1500-01: no automatic double-transform — a correct layout and a stranded
  one are indistinguishable, and guessing wrong corrupts good data. Explicit
  admin command houseplan/geometry/repair: dry_run previews, the backup rides
  the same store write, undo restores, and routine layout writes now preserve
  unrelated store keys instead of eating the backup.

Tests: contentBounds guards (unit), layout coordinate bounds + repair
lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64.
This commit is contained in:
Matysh
2026-07-29 01:39:10 +03:00
parent 9282c28830
commit a8ce6020f4
19 changed files with 409 additions and 75 deletions
+22 -7
View File
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.50.0';
const CARD_VERSION = '1.50.1';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -246,6 +246,7 @@ class HouseplanCard extends LitElement {
private _suppressClick = false;
private _roViewport?: ResizeObserver;
private _roHdr?: ResizeObserver;
private _onWinResize?: () => void;
private _hdrH = 118; // measured px above the stage (see the observer in updated())
private _onboardingShown = false; // the auto space dialog is shown once per session
@@ -436,6 +437,10 @@ class HouseplanCard extends LitElement {
this._roViewport = undefined;
this._roHdr?.disconnect();
this._roHdr = undefined;
if (this._onWinResize) {
window.removeEventListener('resize', this._onWinResize);
this._onWinResize = undefined;
}
if (this._unsubCfg) {
this._unsubCfg();
this._unsubCfg = null;
@@ -729,15 +734,23 @@ class HouseplanCard extends LitElement {
this._roViewport = new ResizeObserver(() => this._refitView());
this._roViewport.observe(stage);
}
// The stage fills the rest of the viewport. What sits above it the HA
// toolbar, card margins, our own headerDEPENDS ON THE MODE: the editor
// bars used to be billed against a hard-coded 118px, so entering an editor
// pushed the plan down by the difference and cut its bottom off below the
// fold. Measure where the stage actually starts instead of assuming.
// The stage fills the rest of the viewport. What sits above it inside the
// CARD depends on the mode — the editor bars used to be billed against a
// hard-coded 118px, so entering an editor pushed the plan down by the
// difference. Measure our own chrome (stage top relative to the card) and
// allow a BOUNDED amount for what the dashboard puts above us (HA's
// toolbar). The first version used the absolute document coordinate here:
// put anything tall before the card and the "header budget" swallowed the
// whole viewport, leaving a 0px stage (HP-1500-02). Content above the card
// is the dashboard's business — it scrolls; it is not header.
const hdr = this.renderRoot.querySelector('.hdr') as HTMLElement | null;
if (hdr && stage && !this._roHdr) {
const measure = () => {
const t = Math.round(stage.getBoundingClientRect().top + (window.scrollY || 0));
const card = this.renderRoot.querySelector('ha-card');
if (!card) return;
const own = stage.getBoundingClientRect().top - card.getBoundingClientRect().top;
const above = Math.min(Math.max(card.getBoundingClientRect().top, 0), 120);
const t = Math.round(own + above);
if (t >= 0 && Math.abs(t - this._hdrH) > 1) this._hdrH = t;
};
// a frame later: setting state straight from the observer callback makes
@@ -745,6 +758,8 @@ class HouseplanCard extends LitElement {
// notifications" — the render it triggers resizes the stage again
this._roHdr = new ResizeObserver(() => requestAnimationFrame(measure));
this._roHdr.observe(hdr);
this._onWinResize = () => requestAnimationFrame(measure);
window.addEventListener('resize', this._onWinResize);
measure();
}
if (stage && !this._view) this._refitView();
+21 -3
View File
@@ -86,10 +86,28 @@ export function contentBounds(
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
// things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here)
for (const p of extra || []) add(p[0], p[1]);
// Things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here).
// But only within a bounded envelope around the canvas: a stored position is
// any finite number the layout schema took, and one absurd coordinate used
// to stretch the frame until the plan was a dot (HP-1500-03). A point past
// the envelope is still rendered wherever it is — it just does not command
// the opening view.
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
for (const p of extra || []) {
if (p[0] >= lo && p[0] <= hi && p[1] >= lo && p[1] <= hi) add(p[0], p[1]);
}
if (minX > maxX || minY > maxY) return null;
// A single marker (or a collinear row of them) has no area, and an SVG
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
// essentially no span — nothing there but icons — opens up to a floor:
// enough canvas around a lone marker to see where it stands. A REAL thin
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
// case is padded, so the threshold sits at about an icon's size.
const FLOOR = NORM_W * 0.2;
const DEGENERATE = NORM_W * 0.03;
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };