mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
fix: first-open boot veil — no residual jump under HA panels (HP-1552)
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 — late panels nudged the height and the freshly painted plan visibly jumped (kiosk is a flat 100dvh, hence 'perfect in kiosk'). Fix, per the owner's sketch: - until the stage height reads the same twice in a row (200 ms cadence) or a hard ~600 ms cap, the plan stays hidden (.hpboot) and is revealed only after a refit — not a single frame paints at a stale height; - that window is covered by a quiet product-style preloader: dark veil, small pulsing outline house (the card's own mdi:home-city outline, inline SVG), no text, 0.15 s opacity fade-out; - prefers-reduced-motion gets a static house; kiosk never shows the veil; first open of the card instance only — floor/mode switches are untouched. smoke_preloader simulates an HA panel landing at t=300 ms and asserts the veil, zero plan-visible frames at a non-final height, the kiosk opt-out and the reduced-motion variant; serve.mjs starts every smoke after the reveal, where the user starts.
This commit is contained in:
+52
-1
@@ -29,6 +29,7 @@ import {
|
||||
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
|
||||
} from './resize';
|
||||
import { ContentSigner } from './signing';
|
||||
import { mdiHomeCityOutline } from '@mdi/js';
|
||||
import {
|
||||
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
|
||||
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
|
||||
@@ -289,6 +290,17 @@ class HouseplanCard extends LitElement {
|
||||
private _roHdr?: ResizeObserver;
|
||||
private _onWinResize?: () => void;
|
||||
private _hdrH = 118; // measured px above the stage (see the observer in updated())
|
||||
/** 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. */
|
||||
private _booting = true;
|
||||
private _bootFading = false; // veil kept one beat for the opacity-out
|
||||
private _bootTimer?: number;
|
||||
private _bootLastH = -1;
|
||||
private _bootStart = 0;
|
||||
/** 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
|
||||
@@ -440,6 +452,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
static properties = {
|
||||
_hdrH: { state: true },
|
||||
_booting: { state: true },
|
||||
_bootFading: { state: true },
|
||||
_tapConfirm: { state: true },
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
@@ -515,6 +529,7 @@ class HouseplanCard extends LitElement {
|
||||
this._signer.dispose();
|
||||
clearTimeout(this._toastTimer);
|
||||
clearTimeout(this._slideTimer);
|
||||
clearTimeout(this._bootTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
@@ -650,6 +665,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
public setConfig(config: CardConfig): void {
|
||||
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
|
||||
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
|
||||
if (config.default_floor) this._space = config.default_floor;
|
||||
try {
|
||||
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
|
||||
@@ -891,6 +907,7 @@ class HouseplanCard extends LitElement {
|
||||
this._roViewport = new ResizeObserver(() => this._refitView());
|
||||
this._roViewport.observe(stage);
|
||||
}
|
||||
if (stage && this._booting && !this._bootTimer) this._bootWatch();
|
||||
// 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
|
||||
@@ -1583,6 +1600,35 @@ class HouseplanCard extends LitElement {
|
||||
this._view = this._clampView({ x: ccx - w / 2, y: ccy - h / 2, w, h }, fit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private _bootWatch(): void {
|
||||
this._bootStart = Date.now();
|
||||
this._bootLastH = -1; // the first read only arms the comparison
|
||||
const tick = () => {
|
||||
if (!this._booting) return;
|
||||
const h = this._stageEl ? this._stageEl.clientHeight : 0;
|
||||
if ((h > 0 && h === this._bootLastH) || Date.now() - this._bootStart >= 600) {
|
||||
this._bootSettled();
|
||||
return;
|
||||
}
|
||||
this._bootLastH = h;
|
||||
this._bootTimer = window.setTimeout(tick, 200);
|
||||
};
|
||||
this._bootTimer = window.setTimeout(tick, 200);
|
||||
}
|
||||
|
||||
private _bootSettled(): void {
|
||||
if (!this._booting) return;
|
||||
this._refitView(); // reveal in the final geometry, never mid-jump
|
||||
this._booting = false;
|
||||
this._bootFading = true; // one soft opacity-out, then out of the DOM
|
||||
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
|
||||
}
|
||||
|
||||
/** Recompute the view for a new scene size, preserving zoom and center. */
|
||||
private _refitView(): void {
|
||||
if (!this._stageEl) return;
|
||||
@@ -4678,7 +4724,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}"
|
||||
<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' : ''}"
|
||||
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)}
|
||||
@@ -4815,6 +4861,11 @@ class HouseplanCard extends LitElement {
|
||||
${this._zoom > 1
|
||||
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
|
||||
: nothing}
|
||||
${this._booting || this._bootFading
|
||||
? html`<div class="bootveil ${this._booting ? '' : 'off'}" aria-hidden="true">
|
||||
<svg class="boothouse" viewBox="0 0 24 24"><path d="${mdiHomeCityOutline}"></path></svg>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${this._roomDialog ? this._renderRoomDialog() : nothing}
|
||||
|
||||
@@ -151,6 +151,44 @@ export const cardStyles = css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
|
||||
.stage.hpboot .zoomwrap,
|
||||
.stage.hpboot .zoombadge {
|
||||
visibility: hidden;
|
||||
}
|
||||
.bootveil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--hp-bg, #16212e);
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bootveil.off {
|
||||
opacity: 0;
|
||||
}
|
||||
.bootveil .boothouse {
|
||||
position: static; /* .stage svg pins itself to inset:0 — not this one */
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
fill: var(--hp-accent);
|
||||
opacity: 0.85;
|
||||
animation: hp-boot-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hp-boot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.94); }
|
||||
50% { opacity: 0.9; transform: scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bootveil .boothouse {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.zoomctl {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
|
||||
Reference in New Issue
Block a user