mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
|
||||
@@ -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
Vendored
+18
-3
File diff suppressed because one or more lines are too long
+30
-5
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user