diff --git a/demo/smoke_space_switch_transitions.mjs b/demo/smoke_space_switch_transitions.mjs index 9ba70ed0..622d52e0 100644 --- a/demo/smoke_space_switch_transitions.mjs +++ b/demo/smoke_space_switch_transitions.mjs @@ -5,7 +5,8 @@ // переход в 0.6 с, позиционное переиспользование превращает смену // пространства в анимацию: створка нового этажа доезжает из положения двери, // которая занимала этот слот раньше. То же у оболочки маркера -// (`.device-shell-frame`, `box-shadow`). +// (`.device-shell-frame`, `box-shadow`) и у фигуры комнаты (`.room`, заливка +// и обводка за 0,12 с, #742). // // Две ловушки, обе стоили бы свидетелю правдивости: // 1. `document.getAnimations()` здесь пуст ДАЖЕ НА СЛОМАННОМ КОДЕ — карточка @@ -15,6 +16,8 @@ // пространств), поэтому дверь в двух пространствах готовит сам смок. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch(); +/** DEFAULT_CUSTOM_FILL (`src/logic.ts`) в вычисленном стиле: цвет / `fill-opacity`. */ +const FINAL_FILL = 'rgb(96, 125, 139) / 0.18'; const res = await page.evaluate(async () => { const out = {}; @@ -49,10 +52,121 @@ const res = await page.evaluate(async () => { // доигрывает чужие данные. const EXPECTED = (entry) => /(^|\s)tab(\s|$)|zoomwrap/.test(entry.cls) || /^summary-/.test(entry.property); - - // ---- фикстура: дверь на одном месте в двух пространствах --------------- const ids = c._serverCfg.spaces.map((space) => space.id).slice(0, 2); out.twoSpacesExist = ids.length === 2; + + // ---- 0) фигуры комнат: смена пространства их не перекрашивает (#742) --- + // `.room` переводит все свойства за 0,12 с. Пока список фигур был голым + // `map()`, узел комнаты прежнего этажа доставался комнате нового, и заливка + // ехала от чужого значения: кадр белой бумаги, потом темнее итога. + // Раздел стоит ДО physicalize: после толстых стен первая комната рисуется + // другой веткой шаблона, Lit создаёт её узел заново, и свидетель молчит + // даже на сломанном коде. Здесь первые комнаты обоих этажей — `polygon` + // по `r.poly`: на f1 подложка и `room overlay` без заливки, второму этажу + // смок включает `fill_mode: 'custom'`. Конфиг меняется пушем с сервера + // (`__hpTest.setServerConfig`) — тем же путём, что правка с другого клиента. + const T = window.__hpTest; + const initialSpace = c._space; + const initialCfg = structuredClone(c._serverCfg); + const spaceOf = (cfg, id) => cfg.spaces.find((space) => space.id === id); + const roomNodes = () => [...root().querySelectorAll('[data-hp="room"]')]; + const roomTransitions = () => roomNodes().flatMap((node) => node.getAnimations() + .filter((animation) => animation instanceof CSSTransition) + .map((animation) => `${node.dataset.id}:${animation.transitionProperty}`)); + /** + * Узлы комнат по `data-id` и переходы, стартовавшие на них до `stop()`. + * Событие не зависит от того, успел ли кадр дойти до проверки. + */ + const watchRooms = () => { + const nodes = new Map(roomNodes().map((node) => [node.dataset.id, node])); + const ran = []; + const listening = new AbortController(); + for (const [id, node] of nodes) { + node.addEventListener('transitionrun', (event) => ran.push(`${id}:${event.propertyName}`), + { signal: listening.signal }); + } + return { nodes, stop: () => { listening.abort(); return [...ran]; } }; + }; + const pickSpace = async (id) => { + c._pickSpace(id); + await c.updateComplete; + await settle(); + }; + /** Сменить пространство и снять первый кадр после смены. */ + const switchRooms = async (to) => { + const before = roomNodes().map((node) => ({ node, id: node.dataset.id, cls: node.getAttribute('class') })); + const tagBefore = before[0]?.node.tagName.toLowerCase(); + c._pickSpace(to); + await c.updateComplete; + await frame(); + const head = roomNodes()[0]; + const style = head ? getComputedStyle(head) : null; + return { + fixture: !!head && tagBefore === 'polygon' && head.tagName.toLowerCase() === 'polygon' + && !/\bstyled\b/.test(before[0].cls) && /\bfilled\b/.test(head.getAttribute('class')), + headId: head?.dataset.id, + transitions: roomTransitions(), + // Любой подключённый узел прежнего пространства сейчас рисует комнату + // нового — под чужим `data-id` или под тем же, если `id` совпали. + survivors: before.filter((entry) => entry.node.isConnected) + .map((entry) => `${entry.id} → ${entry.node.dataset.id}`), + fill: style ? `${style.fill} / ${style.fillOpacity}` : null, + }; + }; + await T.setServerConfig((cfg) => { + const second = spaceOf(cfg, ids[1]); + second.settings = { ...(second.settings || {}), fill_mode: 'custom', show_borders: true }; + }); + await pickSpace(ids[0]); + // AC1: подложка без заливки → этаж с заливкой + const paperToFill = await switchRooms(ids[1]); + out.roomWitnessFixtureHolds = paperToFill.fixture; + out.noRoomTransitionOnSwitch = paperToFill.transitions; + out.noRoomNodeOutlivesTheSwitch = paperToFill.survivors; + out.newFloorRoomsBornInTheirFill = paperToFill.fill; + // AC2: состав списка меняется внутри пространства — новая комната первой + await pickSpace(ids[0]); + const kept = watchRooms(); + const probeId = 'hp-742-probe'; + await T.setServerConfig((cfg) => { + const space = spaceOf(cfg, ids[0]); + space.rooms = [ + { id: probeId, name: 'probe', poly: [[0.04, 0.88], [0.2, 0.88], [0.2, 0.97], [0.04, 0.97]] }, + ...space.rooms, + ]; + }); + const afterInsert = new Map(roomNodes().map((node) => [node.dataset.id, node])); + out.roomListGrewInsideTheSpace = afterInsert.size > kept.nodes.size && roomNodes()[0]?.dataset.id === probeId; + out.noRoomNodeSwappedInsideTheSpace = [...kept.nodes] + .filter(([id, node]) => afterInsert.get(id) !== node).map(([id]) => id); + out.quietRoomsAfterTheInsert = kept.stop(); + // AC1, второй случай: `id` уникален только внутри пространства + const sharedId = spaceOf(c._serverCfg, ids[0]).rooms.find((room) => room.id !== probeId)?.id; + await T.setServerConfig((cfg) => { + spaceOf(cfg, ids[0]).rooms = spaceOf(cfg, ids[0]).rooms.filter((room) => room.id !== probeId); + spaceOf(cfg, ids[1]).rooms[0].id = sharedId; + }); + const sameId = await switchRooms(ids[1]); + out.sameRoomIdOnBothFloors = sameId.fixture && !!sharedId && sameId.headId === sharedId; + out.noRoomTransitionOnSwitchWithSameId = sameId.transitions; + out.noRoomNodeOutlivesTheSwitchWithSameId = sameId.survivors; + out.sameIdRoomBornInItsFill = sameId.fill; + // AC3: настоящая смена заливки той же комнаты по-прежнему анимируется — + // ловит ложный фикс `transition: none` + await settle(); + const filled = watchRooms(); + await T.setServerConfig((cfg) => { + const second = spaceOf(cfg, ids[1]); + second.settings = { ...second.settings, custom_fill: { c: '#c62828', a: 0.5 } }; + }); + out.realFillChangeKeepsTheRoomNode = !!filled.nodes.get(sharedId) + && roomNodes().find((node) => node.dataset.id === sharedId) === filled.nodes.get(sharedId); + out.aRealFillChangeStillAnimates = filled.stop().includes(`${sharedId}:fill`); + // фикстуру разделов ниже не трогаем + await T.setServerConfig(initialCfg); + await pickSpace(initialSpace); + + // ---- фикстура: дверь на одном месте в двух пространствах --------------- const physicalize = (space) => { for (const room of [...(space.rooms || [])]) { const rendered = c._spaceModelById(space.id)?.rooms?.find((item) => item.id === room.id); @@ -205,5 +319,10 @@ const res = await page.evaluate(async () => { return out; }); checkAll(res, { unexpectedAnimations: [], reusedMarkerNodes: [], - swappedMarkerNodes: [], swappedOpeningNodes: [] }); + swappedMarkerNodes: [], swappedOpeningNodes: [], + noRoomTransitionOnSwitch: [], noRoomNodeOutlivesTheSwitch: [], + newFloorRoomsBornInTheirFill: FINAL_FILL, + noRoomNodeSwappedInsideTheSpace: [], quietRoomsAfterTheInsert: [], + noRoomTransitionOnSwitchWithSameId: [], noRoomNodeOutlivesTheSwitchWithSameId: [], + sameIdRoomBornInItsFill: FINAL_FILL }); await finish(browser, res); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 92f9e690..2bece24a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,11 @@ - General settings restored after the card reloads — for example on returning to the tab — now show the moon's "Now: …" line too, worked out afresh at that moment ([#731](https://github.com/Matysh/houseplan-card/issues/731)). +- Switching floors no longer flashes rooms white and then darker than their + fill: the new floor appears in its own colours at once, with the usual short + slide. A real colour change on the same floor — a light turning on, a new + temperature, hover — still fades smoothly + ([#742](https://github.com/Matysh/houseplan-card/issues/742)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 35c604bb..75fba41b 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -22,6 +22,11 @@ возврате на вкладку, — тоже показывают строку луны «Сейчас: …», рассчитанную заново на этот момент ([#731](https://github.com/Matysh/houseplan-card/issues/731)). +- При переключении этажа комнаты больше не мигают белым и затем темнее своего + цвета: новый этаж сразу появляется в своих цветах, с обычным коротким + сдвигом плана. Настоящая смена цвета на том же этаже — включился свет, + сменилась температура, наведение — по-прежнему идёт плавно + ([#742](https://github.com/Matysh/houseplan-card/issues/742)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 0ad82b44..ee28e86c 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -16,8 +16,8 @@ to prove that the Node witness actually kills it. | Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | | Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | | Responsive DOM layout | 36 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | -| Custom-element and HA browser lifecycle | 86 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **201 / 200** | Growth above the cap fails `mutation-gate --check`. | +| Custom-element and HA browser lifecycle | 88 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | +| **Total** | **203 / 200** | Growth above the cap fails `mutation-gate --check`. | ## Measured effect @@ -261,6 +261,8 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `room-gear-second-touch-keeps-drag` - `room-settings-click-does-not-open` - `room-tooltip-off-skips-pointer-modality` +- `rooms-rendered-without-keys` +- `rooms-rendered-without-space-key` - `same-space-room-change-recenters` - `space-create-hidden-display-override` - `stairs-view-pan-opens-target-floor` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index f1ff6ea8..f08e6119 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9902,6 +9902,35 @@ const MUTANT_DEFINITIONS = [ replace: ' ${keyed(space.id, devs.map((d) => this._renderDevice(', }], }, + { + id: 'rooms-rendered-without-keys', + guard: 'node demo/smoke_space_switch_transitions.mjs', + because: 'inside one space the room list still shifts (a room pushed first, merge, split, ' + + 'the editor filter): a positional map() hands each room node to its neighbour, so the ' + + 'node no longer belongs to its data-id and any fill difference replays the 0.12s .room ' + + 'transition from the neighbour\'s value (#742 AC2)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' return keyed(space.id, repeat(shownRooms, (r, index) => r.id || index, (r) => {', + replace: ' return keyed(space.id, shownRooms.map((r) => {', + }], + }, + { + id: 'rooms-rendered-without-space-key', + guard: 'node demo/smoke_space_switch_transitions.mjs', + because: 'room ids are unique only inside a space: without the outer space key repeat matches ' + + 'r1 of the previous floor to r1 of the next one, keeps the node, and its fill draws in ' + + 'from the previous floor\'s computed value — the white frame of #742 (AC1, same-id case)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' return keyed(space.id, repeat(shownRooms, (r, index) => r.id || index, (r) => {', + replace: ' return repeat(shownRooms, (r, index) => r.id || index, (r) => {', + }, { + file: 'src/houseplan-card.ts', + find: ' return svg`${shape}${outline}`;\n }));\n })()}', + replace: ' return svg`${shape}${outline}`;\n });\n })()}', + }], + }, { id: 'live-editor-devices-drops-align-guides', guard: 'node demo/smoke_align_guides.mjs', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 7347982e..3cf10877 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -10946,7 +10946,10 @@ export class HouseplanCard extends LitElement { }; const otherPolys = (rr: any) => space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][]; - return space.rooms.filter((r) => r.area || this._mode === 'view' || this._markup || disp.showBorders).map((r) => { + const shownRooms = space.rooms.filter((r) => r.area || this._mode === 'view' || this._markup || disp.showBorders); + // #742: keyed like openings and markers (plan.styles.ts, #525/#534/#742): a bare map() handed the + // previous floor's room node to the new floor, and `.room`'s transition drew its fill in from white. + return keyed(space.id, repeat(shownRooms, (r, index) => r.id || index, (r) => { let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : ''); if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId)) cls += ' picked'; @@ -11060,7 +11063,7 @@ export class HouseplanCard extends LitElement { style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}>` : nothing; return svg`${shape}${outline}`; - }); + })); })()} ${this._renderRoomHoverFill(roomHover)} ${this._renderOpeningTunnelFills(space, roomFills)} diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index 66c5cfd3..1ce29fba 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -490,7 +490,20 @@ export const planStyles = css` with an unresolved host is listed ONLY in plan mode (_openingsR), so entering and leaving the editor shifts the positions of the rest. Removing either half brings the phantom animation back through a - different door. Witnesses: demo/smoke_space_switch_transitions.mjs. */ + different door. + + #742: the room shapes share the rule. .room above transitions every + property for 0.12 s, and the flat room list was a bare map(): on a floor + switch the previous floor's room node became the new floor's room, and + its fill drew in from the stranger's value — one paper-white frame, then + darker than the fill. The list is now keyed(space.id, repeat(rooms, + (r, i) => r.id || i, ...)) for the same two reasons: room ids are unique + only inside a space, and inside a space insert, merge, split and the + editor's filter shift positions. An id-less room (the backend accepts + an empty id) keys by its index: a number never equals a string id in + repeat's key map. The transition itself stays — it is hover and a real + fill change on the same floor. + Witnesses: demo/smoke_space_switch_transitions.mjs. */ .op-leaf { transition: transform 0.6s ease; }