mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
v1.55.3
This commit is contained in:
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.55.2"
|
||||
VERSION = "1.55.3"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.55.2"
|
||||
"version": "1.55.3"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ const st = () => page.evaluate(() => {
|
||||
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
|
||||
gears: sr.querySelectorAll('.tabedit').length,
|
||||
markupBar: !!sr.querySelector('.editbar'),
|
||||
stageClass: sr.querySelector('.stage').className,
|
||||
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
|
||||
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
|
||||
};
|
||||
});
|
||||
// 1) старт: view, чистая шапка
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
|
||||
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
|
||||
//
|
||||
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
|
||||
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
|
||||
// but kept its truthy id, so the reconnect never restarted the watcher and
|
||||
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
|
||||
// connectedCallback: the veil must lift no later than the hard cap.
|
||||
// B. layout shifts before, between and after the old poll ticks (150, 300,
|
||||
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
|
||||
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
|
||||
// the veil holds for the full protective window with trailing quiescence:
|
||||
// not a single frame may show the plan at a non-final stage height.
|
||||
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
|
||||
// for a short grace the stage height transitions and the plan refits
|
||||
// along with it (no transition at all on v1.55.2 — instant jump).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
// ---------- A: detach before the first tick, reattach, veil must lift ----------
|
||||
const rec = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
await sleep(50); // the first boot tick has not fired yet
|
||||
out.bootingWhenDetached = c._booting === true;
|
||||
c.remove(); // disconnect: timers die here
|
||||
await sleep(30);
|
||||
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
|
||||
const t0 = performance.now();
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
|
||||
out.revealMs = Math.round(performance.now() - t0);
|
||||
out.bootSettledAfterReconnect = c._booting === false;
|
||||
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
|
||||
await sleep(300); // let the fade finish
|
||||
const stage = sr().querySelector('.stage');
|
||||
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
|
||||
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
|
||||
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
|
||||
out.veilGone = !sr().querySelector('.bootveil');
|
||||
|
||||
// A2: a second detach DURING the fade must not leave a zombie veil either
|
||||
const c2 = document.createElement('houseplan-card');
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
c2.hass = window.__card.hass;
|
||||
wrap.appendChild(c2);
|
||||
const t1 = performance.now();
|
||||
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
|
||||
// _bootFading is true for ~220 ms after the reveal — detach right inside it
|
||||
c2.remove();
|
||||
await sleep(30);
|
||||
wrap.appendChild(c2);
|
||||
await sleep(400);
|
||||
const sr2 = c2.shadowRoot || c2.renderRoot;
|
||||
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
|
||||
|
||||
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
|
||||
const shifts = [150, 300, 450, 590];
|
||||
const outB = {};
|
||||
for (const shiftMs of shifts) {
|
||||
const res = await page.evaluate(async (shiftAt) => {
|
||||
const out = {};
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
const frames = [];
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let grown = false;
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const t = performance.now() - t0;
|
||||
if (!grown && t >= shiftAt) {
|
||||
grown = true; // the "HA panel" arrives
|
||||
spacer.style.height = '56px';
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
const stage = sr().querySelector('.stage');
|
||||
if (stage) {
|
||||
const veil = sr().querySelector('.bootveil');
|
||||
const wrapEl = stage.querySelector(':scope > .zoomwrap');
|
||||
const planHidden =
|
||||
!wrapEl ||
|
||||
getComputedStyle(wrapEl).visibility === 'hidden' ||
|
||||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
|
||||
frames.push({ t: Math.round(t), planVisible: !planHidden, stageH: stage.clientHeight });
|
||||
}
|
||||
if (t < 1900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const seen = frames;
|
||||
const finalH = seen[seen.length - 1].stageH;
|
||||
// THE assertion: no frame may show the plan at a stale (pre-panel) height
|
||||
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
|
||||
out.noStaleFrame = bad.length === 0
|
||||
? true
|
||||
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
|
||||
const shown = seen.filter((f) => f.planVisible);
|
||||
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
|
||||
out.heightReacted = finalH !== seen[0].stageH;
|
||||
wrap.remove();
|
||||
return out;
|
||||
}, shiftMs);
|
||||
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
|
||||
outB[`shift${shiftMs}_planShown`] = res.planShown;
|
||||
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
|
||||
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
|
||||
check(`shift${shiftMs}_planShown`, res.planShown);
|
||||
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
|
||||
}
|
||||
|
||||
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
|
||||
const late = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.append(spacer, c);
|
||||
document.body.appendChild(wrap);
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
|
||||
out.revealed = c._booting === false;
|
||||
await sleep(120); // clearly after the reveal, still inside the soft grace
|
||||
const stage = sr().querySelector('.stage');
|
||||
const h0 = stage.clientHeight;
|
||||
spacer.style.height = '56px'; // VERY late panel — slower than the cap
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
const heights = [];
|
||||
await new Promise((done) => {
|
||||
const t1 = performance.now();
|
||||
const sample = () => {
|
||||
heights.push(stage.clientHeight);
|
||||
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
const hEnd = heights[heights.length - 1];
|
||||
out.heightMoved = Math.abs(hEnd - h0) > 30;
|
||||
// the glide: intermediate frames strictly between the endpoints
|
||||
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
|
||||
out.lateShiftGlides = mid.length >= 3
|
||||
? true
|
||||
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
|
||||
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
check('late_revealed', late.revealed);
|
||||
check('late_heightMoved', late.heightMoved);
|
||||
check('late_lateShiftGlides', late.lateShiftGlides);
|
||||
check('late_settledAtFinal', late.settledAtFinal);
|
||||
|
||||
await finish(browser, { ...rec, ...outB, ...late });
|
||||
File diff suppressed because one or more lines are too long
Vendored
+12
-3
File diff suppressed because one or more lines are too long
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v1.55.3 — 2026-08-02
|
||||
|
||||
Patch release: two fixes for the v1.55.2 first-open veil, found by an
|
||||
adversarial lifecycle audit (AUD-1552-01/02).
|
||||
|
||||
- **A dashboard rebuild during the first open can no longer leave the
|
||||
plan hidden forever** (AUD-1552-01). Disconnecting the card while the
|
||||
boot veil was up (Lovelace recreates its DOM, a view switch remounts
|
||||
the card) killed the settle timers but kept their ids, so nothing ever
|
||||
lifted the veil again. The veil lifecycle now restarts from every
|
||||
reconnect — with a fresh clock and an unconditional hard cap — and the
|
||||
fade-out also survives a mid-fade remount.
|
||||
- **The veil no longer opens early, right before a late panel lands**
|
||||
(AUD-1552-02). Two equal height reads at 200/400 ms used to reveal the
|
||||
plan at ~400 ms, so Home Assistant chrome arriving at 450+ ms jumped
|
||||
on a visible plan — exactly what the veil was meant to prevent. The
|
||||
veil now holds for a full protective window with trailing quiescence
|
||||
(height changes near the cap extend the wait), and for a short grace
|
||||
after the reveal any later shift glides via a height transition
|
||||
instead of snapping. Deliberate height changes (entering an editor)
|
||||
still apply instantly.
|
||||
|
||||
## v1.55.2 — 2026-08-02
|
||||
|
||||
Patch release: a calmer first open — no zoom flash and no layout jumps
|
||||
|
||||
@@ -6,6 +6,29 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.55.3 — 2026-08-02
|
||||
|
||||
Патч-релиз: два исправления вуали первого открытия из v1.55.2,
|
||||
найденные adversarial-аудитом жизненного цикла (AUD-1552-01/02).
|
||||
|
||||
- **Пересборка дашборда во время первого открытия больше не оставляет
|
||||
план скрытым навсегда** (AUD-1552-01). Отключение карточки при
|
||||
поднятой вуали (Lovelace пересоздаёт DOM, переключение вью
|
||||
перемонтирует карточку) убивало таймеры стабилизации, но сохраняло их
|
||||
id — и вуаль уже никогда не снималась. Теперь жизненный цикл вуали
|
||||
перезапускается при каждом повторном подключении — с новым отсчётом и
|
||||
безусловным жёстким таймаутом; затухание тоже переживает размонтирование
|
||||
посреди fade.
|
||||
- **Вуаль больше не открывается рано, прямо перед поздней панелью**
|
||||
(AUD-1552-02). Два одинаковых замера высоты на 200/400 мс открывали
|
||||
план уже на ~400 мс, и хром Home Assistant, пришедший на 450+ мс,
|
||||
прыгал на видимом плане — ровно то, от чего вуаль должна защищать.
|
||||
Теперь вуаль держится полное защитное окно с trailing quiescence
|
||||
(изменения высоты у края окна продлевают ожидание), а короткое время
|
||||
после открытия поздний сдвиг применяется плавно (transition высоты),
|
||||
а не скачком. Намеренные изменения высоты (вход в редактор)
|
||||
по-прежнему применяются мгновенно.
|
||||
|
||||
## v1.55.2 — 2026-08-02
|
||||
|
||||
Патч-релиз: спокойное первое открытие — без вспышки зума и прыжков
|
||||
|
||||
+3
-3
@@ -11,16 +11,16 @@
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-08-01)
|
||||
## Snapshot (2026-08-02)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.55.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.55.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Closes the v1.55.2 audit findings AUD-1552-01/02 (boot-veil lifecycle: reconnect restart + full protective window with trailing quiescence + post-reveal soft grace; regressions in `demo/smoke_preloader_lifecycle.mjs`) |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.2** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.3** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.55.2",
|
||||
"version": "1.55.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.55.2",
|
||||
"version": "1.55.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.55.2",
|
||||
"version": "1.55.3",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+75
-12
@@ -47,7 +47,20 @@ import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.55.2';
|
||||
const CARD_VERSION = '1.55.3';
|
||||
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
|
||||
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
|
||||
* trailing-quiescence requirement (chrome still settling near the cap
|
||||
* extends the wait); BOOT_MAX_MS lifts the veil unconditionally.
|
||||
* BOOT_MIN_MS exceeds the old 600 ms window by a frame-latency margin: a
|
||||
* panel applied right at the window's edge (~590 ms) only materializes in
|
||||
* the stage height a couple of rAF/render frames later. */
|
||||
const BOOT_MIN_MS = 700;
|
||||
const BOOT_QUIET_MS = 250;
|
||||
const BOOT_MAX_MS = 1200;
|
||||
/** AUD-1552-02: post-reveal grace during which late chrome shifts glide
|
||||
* (CSS height transition on the stage) instead of snapping. */
|
||||
const BOOT_SOFT_MS = 1500;
|
||||
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';
|
||||
@@ -293,14 +306,21 @@ class HouseplanCard extends LitElement {
|
||||
/** HP-1552: first-open boot veil. In normal (non-kiosk) mode the stage is
|
||||
* calc(100dvh - _hdrH), and _hdrH is measured from HA's chrome — which
|
||||
* finishes loading AFTER the card's first paint, so every late panel
|
||||
* nudged the height and the plan visibly jumped. Until the height reads
|
||||
* the same twice in a row (or ~600 ms pass) the plan hides behind a
|
||||
* pulsing-house veil. First open only; kiosk is 100dvh and never jumps. */
|
||||
* nudged the height and the plan visibly jumped. The plan hides behind a
|
||||
* pulsing-house veil for a full protective window (AUD-1552-02: the old
|
||||
* early reveal on "two equal reads" let a panel landing at 400-600 ms
|
||||
* jump on a VISIBLE plan); the whole lifecycle restarts from
|
||||
* connectedCallback (AUD-1552-01: timers die on disconnect — a Lovelace
|
||||
* DOM rebuild mid-boot used to leave the plan hidden forever).
|
||||
* First open only; kiosk is 100dvh and never jumps. */
|
||||
private _booting = true;
|
||||
private _bootFading = false; // veil kept one beat for the opacity-out
|
||||
private _bootTimer?: number;
|
||||
private _bootLastH = -1;
|
||||
private _bootStart = 0;
|
||||
private _bootLastChange = 0; // when the stage height last moved (quiescence clock)
|
||||
private _bootSoft = false; // post-reveal grace: late chrome shifts glide, not jump
|
||||
private _bootSoftTimer?: number;
|
||||
/** The accidental-tap guard: pending confirmation for a toggle/run tap. */
|
||||
private _tapConfirm: { text: string; exec: () => void } | null = null;
|
||||
private _onboardingShown = false; // the auto space dialog is shown once per session
|
||||
@@ -454,6 +474,7 @@ class HouseplanCard extends LitElement {
|
||||
_hdrH: { state: true },
|
||||
_booting: { state: true },
|
||||
_bootFading: { state: true },
|
||||
_bootSoft: { state: true },
|
||||
_tapConfirm: { state: true },
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
@@ -515,6 +536,21 @@ class HouseplanCard extends LitElement {
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
}
|
||||
window.addEventListener('hashchange', this._onHashChange);
|
||||
// AUD-1552-01: the boot-veil timers die in disconnectedCallback, so a
|
||||
// disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
|
||||
// switch remounts the card) used to strand _booting=true with no watcher
|
||||
// — the plan stayed hidden forever. Restart the veil lifecycle from every
|
||||
// connect: a fresh watch (fresh clock, so the hard cap counts from the
|
||||
// reconnect) while booting, or the tail timers if we detached mid-fade.
|
||||
if (this._booting) this._bootWatch();
|
||||
else if (this._bootFading) {
|
||||
clearTimeout(this._bootTimer);
|
||||
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
|
||||
}
|
||||
if (this._bootSoft) {
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
@@ -530,6 +566,8 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._toastTimer);
|
||||
clearTimeout(this._slideTimer);
|
||||
clearTimeout(this._bootTimer);
|
||||
this._bootTimer = undefined; // AUD-1552-01: a cleared id must not block the reconnect watcher
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
@@ -1601,24 +1639,33 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* HP-1552: poll the stage height until it settles — two equal reads in a
|
||||
* row, or a hard cap of ~600 ms (HA's panels normally land well inside it).
|
||||
* Only then is the plan revealed, already refit to the final geometry.
|
||||
* HP-1552: hold the boot veil until the stage height settles. AUD-1552-02:
|
||||
* the old "two equal reads at a 200 ms cadence" revealed the plan at
|
||||
* ~400 ms, so an HA panel landing right after (450+ ms is realistic on a
|
||||
* slow device) still jumped on a VISIBLE plan. The veil now holds for the
|
||||
* full protective window (BOOT_MIN_MS); the height is sampled every 100 ms
|
||||
* and any change restarts a trailing-quiescence requirement
|
||||
* (BOOT_QUIET_MS), so chrome still settling near the cap extends the wait.
|
||||
* BOOT_MAX_MS lifts the veil unconditionally — it can never get stuck.
|
||||
*/
|
||||
private _bootWatch(): void {
|
||||
clearTimeout(this._bootTimer); // never two concurrent watchers (connect + updated)
|
||||
this._bootStart = Date.now();
|
||||
this._bootLastH = -1; // the first read only arms the comparison
|
||||
this._bootLastChange = this._bootStart;
|
||||
const tick = () => {
|
||||
if (!this._booting) return;
|
||||
const now = Date.now();
|
||||
const h = this._stageEl ? this._stageEl.clientHeight : 0;
|
||||
if ((h > 0 && h === this._bootLastH) || Date.now() - this._bootStart >= 600) {
|
||||
if (h !== this._bootLastH) { this._bootLastH = h; this._bootLastChange = now; }
|
||||
const elapsed = now - this._bootStart;
|
||||
if (elapsed >= BOOT_MAX_MS || (elapsed >= BOOT_MIN_MS && h > 0 && now - this._bootLastChange >= BOOT_QUIET_MS)) {
|
||||
this._bootSettled();
|
||||
return;
|
||||
}
|
||||
this._bootLastH = h;
|
||||
this._bootTimer = window.setTimeout(tick, 200);
|
||||
this._bootTimer = window.setTimeout(tick, 100);
|
||||
};
|
||||
this._bootTimer = window.setTimeout(tick, 200);
|
||||
this._bootTimer = window.setTimeout(tick, 100);
|
||||
}
|
||||
|
||||
private _bootSettled(): void {
|
||||
@@ -1627,6 +1674,21 @@ class HouseplanCard extends LitElement {
|
||||
this._booting = false;
|
||||
this._bootFading = true; // one soft opacity-out, then out of the DOM
|
||||
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
|
||||
// AUD-1552-02: chrome that lands after the cap (device slower than
|
||||
// BOOT_MAX_MS) must not snap — for a short grace the stage height
|
||||
// transitions and the viewport ResizeObserver refits the plan each frame.
|
||||
this._bootSoft = true;
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._bootSoftTimer = window.setTimeout(() => { this._bootSoft = false; }, BOOT_SOFT_MS);
|
||||
}
|
||||
|
||||
/** The soft grace only covers PASSIVE late chrome. A user action that
|
||||
* changes the stage height (entering/leaving an editor) must apply
|
||||
* instantly — the plan may not drift under the pointer mid-drag. */
|
||||
private _bootSoftCancel(): void {
|
||||
if (!this._bootSoft) return;
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
this._bootSoft = false;
|
||||
}
|
||||
|
||||
/** Recompute the view for a new scene size, preserving zoom and center. */
|
||||
@@ -2027,6 +2089,7 @@ class HouseplanCard extends LitElement {
|
||||
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
|
||||
if (this._kiosk && mode !== 'view') return; // wall devices never edit
|
||||
if (this._mode === mode) return;
|
||||
this._bootSoftCancel(); // editor bars change the stage height DELIBERATELY — snap, no glide
|
||||
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
|
||||
this._showToast(this._t('toast.markup_needs_server'));
|
||||
return;
|
||||
@@ -4737,7 +4800,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
|
||||
</div>
|
||||
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._booting ? ' hpboot' : ''}"
|
||||
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
|
||||
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
|
||||
@click=${(e: MouseEvent) => this._markupClick(e)}
|
||||
@wheel=${(e: WheelEvent) => this._onWheel(e)}
|
||||
|
||||
@@ -156,6 +156,12 @@ export const cardStyles = css`
|
||||
.stage.hpboot .zoombadge {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* AUD-1552-02: post-veil grace — HA chrome landing after the cap moves
|
||||
the stage height smoothly; the viewport ResizeObserver refits the plan
|
||||
along the transition, so a late panel glides instead of jumping. */
|
||||
.stage.hpsettle {
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
.bootveil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -188,6 +194,9 @@ export const cardStyles = css`
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.stage.hpsettle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.zoomctl {
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user