mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
AUD-1552-01/02: the v1.55.2 boot-veil audit findings, fixed with regressions
The v1.55.2 recheck (verdict NEEDS FIX) found two lifecycle holes in the first-open boot veil (HP-1552); both are closed here and covered by demo/smoke_preloader_lifecycle.mjs, which fails on v1.55.2. AUD-1552-01 (high): disconnect/reconnect while booting hid the plan FOREVER. disconnectedCallback cleared the boot timer but kept its truthy id, so 'updated()' never restarted the watcher. Now the id is nulled on disconnect and connectedCallback restarts the whole veil lifecycle: a fresh watch (fresh clock, BOOT_MAX_MS hard cap) while booting, the tail timers when detached mid-fade or mid-grace. AUD-1552-02 (medium): two equal reads at 200/400 ms revealed the plan at ~400 ms, so HA chrome landing at 450+ ms jumped on a VISIBLE plan. The veil now holds a full protective window (BOOT_MIN_MS=700 — the old 600 ms plus a frame-latency margin: a shift applied at ~590 ms only materializes in the stage height a couple frames later) with 100 ms sampling and trailing quiescence (BOOT_QUIET_MS=250 restarts on every height change), capped by BOOT_MAX_MS=1200. After the reveal a short soft grace (BOOT_SOFT_MS=1500, .stage.hpsettle) turns later passive shifts into a 0.25 s height glide — the viewport ResizeObserver refits the plan along the transition; deliberate height changes (_setMode into an editor) cancel the grace so the plan never drifts under the pointer. Reduced motion disables the glide. Regressions (demo/smoke_preloader_lifecycle.mjs, all FAIL on v1.55.2): - A: detach before the first tick -> reattach -> veil lifts within the cap, plan visible, no hpboot class, no zombie veil after a mid-fade remount either; - B: parameterized layout shifts at 150/300/450/590 ms -> not a single frame shows the plan at a non-final stage height; - C: a shift after the reveal glides (>=3 intermediate frames), no snap. smoke_modes.mjs now strips the transient hpsettle class from its exact stage-class assertions. Docs: CHANGELOG en+ru, STATUS.
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