HP-1543: editor-exit zoom after cross-floor switch; motion flash restart on rapid retrip

HP-1543-01 (medium): exiting an editor AFTER switching floors inside it left
the editor working zoom on screen in view mode — the snapshot guard
(snap.space) dropped the restore and nothing else put the viewport back.
A space-mismatched exit now falls back to _restoreZoom() of the current
floor's saved view zoom (per-space store/LS_ZOOM were never polluted, the
view-only _saveZoom guard is untouched). Regression: crossFloor* asserts in
demo/smoke_zoom_out.mjs — red on v1.54.3, green now.

HP-1543-02 (low): a rapid off->on retrip before the current flash ended did
not restart the CSS animation: the senseflash class and animation-name never
changed, so the browser kept the old timeline and the second detection played
nothing once the first one-shot had finished (base opacity 0). Every
witnessed trip now bumps a generation counter; its parity alternates the
identical keyframes hp-sense / hp-sense-b via the .sf2 marker — a new
animation identity forces a fresh timeline per detection. flashTs and the
window timer re-arm as before; prefers-reduced-motion keeps the static ring
(retrip only extends the window). Regression: rapid* asserts in
demo/smoke_motion_sense.mjs — red on v1.54.3, green now.
This commit is contained in:
Matysh
2026-08-01 13:09:55 +03:00
parent adaed9be7c
commit fa32afa9f3
7 changed files with 178 additions and 15 deletions
File diff suppressed because one or more lines are too long
+31
View File
@@ -53,6 +53,37 @@ const res = await page.evaluate(async () => {
await setMotion('on', 'motion');
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
// не снимался и animation-name не менялся — браузер продолжал старый
// timeline, и после его конца (base opacity 0) второй trip был невидим.
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №1
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
await sleep(1200); // середина первой вспышки (< 3.3s)
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
await sleep(2300);
const el3 = sr().querySelector('.dev.senseflash');
const a3 = senseAnim(el3);
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
out.rapidRingVisibleAfterFirstWindow = !!el3
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
await sleep(1400); await c.updateComplete;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.sensehold');
+47
View File
@@ -144,6 +144,53 @@ out.ownerSecondSwitchOk = out.editorZoomNeverInSpaceStore.secondSwitch;
out.ownerLsClean = out.editorZoomNeverInSpaceStore.lsClean;
delete out.editorZoomNeverInSpaceStore;
// -- HP-1543-01: смена этажа ВНУТРИ редактора не тащит editor zoom в view --
// view-зум сохранён на обоих этажах → редактор на f1 → переключение ВКЛАДКОЙ
// на garden → editor zoom 5.0 → выход в view (остаёмся на garden): вид обязан
// вернуться к сохранённому view-зуму garden (1.4) с штатным центром
// _restoreZoom(), а не остаться на редакторских 500% (снапшот-guard по space
// отбрасывал восстановление, а текущий _zoom так и оставался редакторским).
out.crossFloorEditorExit = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const sr = c.shadowRoot || c.renderRoot;
c._setMode('view');
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // f1 view zoom
c._slideTo('garden', 'left'); await wait(350);
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // garden view zoom
c._slideTo('f1', 'right'); await wait(350);
c._setMode('devices'); await raf2();
// этаж меняется НАСТОЯЩЕЙ вкладкой — именно этот путь под подозрением
const idx = c._model.findIndex((s) => s.id === 'garden');
sr.querySelectorAll('.tabs .tab')[idx].click();
await c.updateComplete; await raf2();
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — рабочий инструмент
c._setMode('view'); await raf2(); await wait(60);
const stillGarden = c._space === 'garden';
const gotZoom = c._zoom;
const v = c._view; const vb = c._baseVb();
const centred = !!v && Math.abs(v.x + v.w / 2 - (vb[0] + vb[2] / 2)) < 1
&& Math.abs(v.y + v.h / 2 - (vb[1] + vb[3] / 2)) < 1;
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
const store = { ...c._zoomBySpace };
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // вернуть сцену следующим тестам
return {
stillGarden,
viewZoomRestoredCrossFloor: Math.abs(gotZoom - 1.4) < 0.01, // было 5.0 (HP-1543-01)
centerRestoredCrossFloor: centred, // центр как у _restoreZoom()
storeIntactCrossFloor: Math.abs((store.garden || 1) - 1.4) < 0.01, // _zoomBySpace не тронут
lsIntactCrossFloor: Math.abs((ls.garden || 1) - 1.4) < 0.01
&& Math.abs((ls.f1 || 1) - 1.6) < 0.01, // LS_ZOOM не тронут
};
});
out.crossFloorStillGarden = out.crossFloorEditorExit.stillGarden;
out.crossFloorViewZoomRestored = out.crossFloorEditorExit.viewZoomRestoredCrossFloor;
out.crossFloorCenterRestored = out.crossFloorEditorExit.centerRestoredCrossFloor;
out.crossFloorStoreIntact = out.crossFloorEditorExit.storeIntactCrossFloor;
out.crossFloorLsIntact = out.crossFloorEditorExit.lsIntactCrossFloor;
delete out.crossFloorEditorExit;
// -- devices outside rooms stretch the default frame ---------------------
out.devicesStretchFrame = await page.evaluate(() => {
const c = window.__card;
File diff suppressed because one or more lines are too long
+18 -3
View File
File diff suppressed because one or more lines are too long
+30 -5
View File
@@ -362,7 +362,7 @@ class HouseplanCard extends LitElement {
the flash is keyed to the TRANSITION, not to the 'on' state. `timer`
is the one setTimeout per entry that repaints the card when the flash
window closes (cleared in disconnectedCallback). */
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number }>();
private _senseRt = new Map<string, { last: string; flashTs: number; timer: number; gen: number }>();
/** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */
private _vacRt = new Map<string, { trail: VacPt[]; lastKey: string; lastTs: number;
moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null }>();
@@ -1331,7 +1331,12 @@ class HouseplanCard extends LitElement {
// the state has not changed. A new off→on trip re-arms the flash.
if (dc === 'motion') {
const rt = this._senseRt.get(d.id);
return rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS ? 'senseflash' : '';
if (!(rt && rt.flashTs && Date.now() - rt.flashTs < SENSE_FLASH_MS)) return '';
// HP-1543-02: alternate the animation identity per trip (see
// _senseTick) — odd generations ride the base hp-sense keyframes,
// even ones the identical hp-sense-b twin, so a rapid re-trip
// restarts the flash instead of silently inheriting the old timeline
return rt.gen % 2 === 0 ? 'senseflash sf2' : 'senseflash';
}
// OCCUPANCY/PRESENCE while 'on': a calm STATIC ring ('sensehold',
// no animation in styles.ts) — «комната обитаема» is a state, not
@@ -1923,8 +1928,7 @@ class HouseplanCard extends LitElement {
if (mode === 'view') {
const snap = this._viewModeSnap;
this._viewModeSnap = null;
// restore only for the space the snapshot was taken in — after a space
// switch inside the editor the saved per-space zoom already applies
// restore the snapshot only for the space it was taken in
if (snap && snap.space === this._space) {
this._zoom = snap.zoom;
this._view = null;
@@ -1934,6 +1938,17 @@ class HouseplanCard extends LitElement {
this._saveZoom(); // editor wheel zoom wrote itself to LS_ZOOM — put the view zoom back
this.requestUpdate();
});
} else if (snap) {
// HP-1543-01: the floor CHANGED inside the editor — the snapshot
// belongs to the floor the editor was entered from, while _zoom still
// carries the editor's working zoom for the floor we are exiting on.
// Dropping the snapshot alone left that editor zoom (say 500%) on
// screen in view mode. The per-space store was never polluted
// (_saveZoom is view-only), so the standard centred
// _restoreZoom() puts back this floor's saved VIEW viewport. Its rAF
// reads _zoomBySpace, not the snapshot, so the same-tick floor-tab
// race the view-only guard protects against stays closed.
this._restoreZoom();
}
}
this._path = [];
@@ -4462,9 +4477,19 @@ class HouseplanCard extends LitElement {
const p = this.hass.states[d.primary];
if (p?.attributes?.device_class !== 'motion') continue;
const rt = this._senseRt.get(d.id);
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0 }); continue; }
if (!rt) { this._senseRt.set(d.id, { last: p.state, flashTs: 0, timer: 0, gen: 0 }); continue; }
if (p.state === 'on' && rt.last === 'off') {
rt.flashTs = Date.now();
// HP-1543-02: a retrip BEFORE the previous flash finished kept the
// same 'senseflash' class and the same animation-name on the same
// pseudo-element — browsers never restart such a CSS animation, so
// the second detection played nothing once the first one-shot had
// ended (base opacity 0). Every trip bumps the generation;
// _stateClass maps its parity to alternating keyframe names
// (hp-sense / hp-sense-b in styles.ts), which is a NEW animation
// identity and forces a fresh timeline per detection. No class is
// ever removed mid-flash, so a lone first flash still plays whole.
rt.gen++;
clearTimeout(rt.timer);
rt.timer = window.setTimeout(() => this.requestUpdate(), SENSE_FLASH_MS + 60);
}
+16 -1
View File
@@ -907,6 +907,13 @@ export const cardStyles = css`
animation: hp-sense 1.1s ease-in-out 3;
pointer-events: none;
}
/* HP-1543-02: a rapid off→on retrip must RESTART the flash. The card
flips the .sf2 marker on every trip (_senseTick generation parity);
hp-sense-b duplicates hp-sense exactly — switching the animation-name
is what makes the browser abandon the old timeline and start a fresh
one on the same pseudo-element (a same-name animation never restarts
while the class stays on). */
.dev.senseflash.sf2::after { animation-name: hp-sense-b; }
/* OCCUPANCY/PRESENCE while 'on': a calm STATIC ring, no animation —
«комната обитаема» is a state, not an event, so it must not blink.
Brightness matches the reduced-motion variant of the flash. */
@@ -924,6 +931,13 @@ export const cardStyles = css`
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* identical twin of hp-sense — exists ONLY as the alternate animation
identity for the retrip restart (HP-1543-02), keep the two in sync */
@keyframes hp-sense-b {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* alarms pulse red over everything */
.dev.alarm::after {
content: '';
@@ -943,7 +957,8 @@ export const cardStyles = css`
/* the motion flash becomes a static ring for the same ~3.3s class
window — consistent with alarm's treatment; sensehold is static
by design already */
.dev.senseflash::after { animation: none; opacity: 0.4; }
.dev.senseflash::after,
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
}
.dev .newdot {
position: absolute;