From 6cd29f9b2b8cbc7add59bb7e89e4f6116de9534b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:38:50 +0300 Subject: [PATCH] fix(space-card): key room shapes by space and id so a config change never repaints a stranger (#745) The space card drew its rooms with a bare map(), so Lit reused room nodes by position, and `.room { transition: 0.12s }` (planStyles is part of this card's styles too) drew a node's fill and stroke in from whichever room held it before. Two paths change the room set in the same DOM: a new `space` in setConfig of the same element (the card editor's preview), and a config event from any device that inserts, removes, reorders or re-zones a room of the shown space. Filled rooms faded out and back in for ~0.12 s, unfilled ones briefly darkened in a filled room's place. The list is now keyed(space.id, repeat(rooms, (r, i) => r.id || i, ...)), the same shape as the full card after #742: the outer key handles the space change, the inner one keeps a node bound to its room inside a space. An id-less room keys by its numeric index, which never equals a string id. The transition itself stays: it smooths a real fill change on the same room. The #742 note in plan.styles.ts now names the space card as well. Witness: a new section of smoke_space_card. The config is delivered by a server push (__hpTest.setServerConfig), the event the card subscribes to. Red on dev: node r1 reused for g1 with fill/fill-opacity transitions and a first-frame fill of rgba(0, 0, 0, 0) / 0; a room inserted first shifts all four nodes and replays fill transitions. A real custom_fill change still runs a fill transition on the same node (catches `transition: none`). One mutant: inner key replaced by map(), guarded by AC2 (checked by hand: red). Issue: #745 User-Visible: yes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/smoke_space_card.mjs | 153 +++++++++++++++++- docs/CHANGELOG.md | 3 + docs/CHANGELOG.ru.md | 3 + docs/testing-notes/mutation-browser-guards.md | 1 + scripts/mutation-registry.mjs | 13 ++ src/space-render.ts | 12 +- src/styles/plan.styles.ts | 9 +- 7 files changed, 187 insertions(+), 7 deletions(-) diff --git a/demo/smoke_space_card.mjs b/demo/smoke_space_card.mjs index 5365bfc7..4cc44979 100644 --- a/demo/smoke_space_card.mjs +++ b/demo/smoke_space_card.mjs @@ -1,5 +1,7 @@ // Smoke: houseplan-space-card renders a live, non-interactive schematic + deep-link button. import { launch, reportPageErrors } from './serve.mjs'; +/** DEFAULT_CUSTOM_FILL (`src/logic.ts`) в вычисленном стиле: цвет / `fill-opacity`. */ +const FINAL_FILL = 'rgb(96, 125, 139) / 0.18'; const { page, browser } = await launch({ width: 900, height: 900 }, 1); const res = await page.evaluate(async () => { await customElements.whenDefined('houseplan-space-card'); @@ -251,6 +253,151 @@ const res = await page.evaluate(async () => { errorText: errCard?.textContent?.trim() || null, }; }); +// ---- #745: фигуры комнат с ключами (пространство, id комнаты) ------------- +// `.room` переводит все свойства за 0,12 с (planStyles входит в стили этой +// карточки). Пока список фигур был голым `map()`, Lit отдавал узел комнаты +// соседке по позиции, и заливка ехала от чужого значения. Путь A — новый +// `space` в `setConfig` того же элемента (превью редактора карточки). Путь B — +// событие конфигурации: комнату вставили, удалили, переставили с другого +// устройства; конфиг доставляется пушем с сервера (`__hpTest.setServerConfig`), +// тем же событием, на которое подписана карточка. +const keyedRooms = await page.evaluate(async () => { + const out = {}; + const T = window.__hpTest; + const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + const settle = async (frames = 10, ms = 400) => { + for (let i = 0; i < frames; i++) await frame(); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const spaceOf = (cfg, id) => cfg.spaces.find((space) => space.id === id); + const host = document.createElement('div'); + host.style.width = '600px'; + document.body.appendChild(host); + const el = document.createElement('houseplan-space-card'); + const config = { type: 'custom:houseplan-space-card', show_button: false }; + const roomNodes = () => [...(el.renderRoot?.querySelectorAll('[data-hp="room"]') || [])]; + const roomNode = (id) => roomNodes().find((node) => node.dataset.id === id) || null; + const roomTransitions = () => roomNodes().flatMap((node) => node.getAnimations() + .filter((animation) => animation instanceof CSSTransition) + .map((animation) => `${node.dataset.id}:${animation.transitionProperty}`)); + const fillOf = (node) => { + const style = node ? getComputedStyle(node) : null; + return style ? `${style.fill} / ${style.fillOpacity}` : null; + }; + const waitUntil = async (predicate, ms = 6000) => { + const deadline = performance.now() + ms; + while (!predicate() && performance.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 16)); + return !!predicate(); + }; + /** + * Узлы комнат по `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]; } }; + }; + // Фикстура: f1 — подложка, комнаты без заливки, но с границами (`styled`); + // у garden своя заливка. + await T.setServerConfig((cfg) => { + const f1 = spaceOf(cfg, 'f1'); + f1.settings = { ...(f1.settings || {}), show_borders: true, fill_mode: 'none' }; + const garden = spaceOf(cfg, 'garden'); + garden.settings = { ...(garden.settings || {}), show_borders: true, fill_mode: 'custom' }; + }); + el.setConfig({ ...config, space: 'f1' }); + el.hass = window.__card.hass; + host.appendChild(el); + await waitUntil(() => roomNodes().length >= 2 + && roomNodes().every((node) => /\bstyled\b/.test(node.getAttribute('class')))); + await el.updateComplete; + await settle(); + + // AC1, путь A: тот же элемент получает другое пространство + const before = roomNodes().map((node) => ({ + node, id: node.dataset.id, tag: node.tagName.toLowerCase(), cls: node.getAttribute('class'), + })); + const watchA = watchRooms(); + el.setConfig({ ...config, space: 'garden' }); + await el.updateComplete; + await frame(); + const g1 = roomNode('g1'); + out.pathAFixtureHolds = before[0]?.id === 'r1' && before[0].tag === 'polygon' + && !/\bfilled\b/.test(before[0].cls) + && g1?.tagName.toLowerCase() === 'polygon' && /\bfilled\b/.test(g1.getAttribute('class')); + out.pathANoRoomTransition = [...new Set([...roomTransitions(), ...watchA.stop()])]; + out.pathANoRoomNodeOutlivesTheSwitch = before.filter((entry) => entry.node.isConnected) + .map((entry) => `${entry.id} → ${entry.node.dataset.id}`); + out.pathANewRoomBornInItsFill = fillOf(g1); + + // AC2, путь B: событие конфигурации меняет состав комнат того же пространства + el.setConfig({ ...config, space: 'f1' }); + await T.setServerConfig((cfg) => { + const f1 = spaceOf(cfg, 'f1'); + f1.settings = { ...f1.settings, fill_mode: 'custom' }; + }); + await waitUntil(() => roomNodes().length >= 2 + && roomNodes().every((node) => /\bfilled\b/.test(node.getAttribute('class')))); + await el.updateComplete; + await settle(); + const probeId = 'hp-745-probe'; + const kept = watchRooms(); + await T.setServerConfig((cfg) => { + const f1 = spaceOf(cfg, 'f1'); + f1.rooms = [{ + id: probeId, name: 'Porch', poly: [[0.04, 0.88], [0.2, 0.88], [0.2, 0.97], [0.04, 0.97]], + settings: { fill_mode: 'none' }, + }, ...f1.rooms]; + }); + out.pathBRoomListGrew = await waitUntil(() => roomNodes()[0]?.dataset.id === probeId) + && roomNodes().length === kept.nodes.size + 1 + && !/\bfilled\b/.test(roomNodes()[0].getAttribute('class')); + await el.updateComplete; + await frame(); + const afterInsert = new Map(roomNodes().map((node) => [node.dataset.id, node])); + out.pathBNoRoomNodeSwapped = [...kept.nodes] + .filter(([id, node]) => afterInsert.get(id) !== node).map(([id]) => id); + out.pathBQuietRooms = [...new Set([...roomTransitions(), ...kept.stop()])]; + + // AC2: настоящая смена цвета той же комнаты по-прежнему анимируется — + // ловит ложный фикс `transition: none` + await settle(); + const filled = watchRooms(); + const witnessId = 'r1'; + await T.setServerConfig((cfg) => { + const f1 = spaceOf(cfg, 'f1'); + f1.settings = { ...f1.settings, custom_fill: { c: '#c62828', a: 0.5 } }; + }); + await waitUntil(() => /#c62828/i.test(roomNode(witnessId)?.getAttribute('style') || '')); + await el.updateComplete; + await frame(); + out.pathBRealFillChangeKeepsTheRoomNode = !!filled.nodes.get(witnessId) + && roomNode(witnessId) === filled.nodes.get(witnessId); + out.pathBRealFillChangeAnimates = [...roomTransitions(), ...filled.stop()] + .includes(`${witnessId}:fill`); + host.remove(); + return out; +}); +const keyedRoomsExpected = { + pathAFixtureHolds: true, + pathANoRoomTransition: [], + pathANoRoomNodeOutlivesTheSwitch: [], + pathANewRoomBornInItsFill: FINAL_FILL, + pathBRoomListGrew: true, + pathBNoRoomNodeSwapped: [], + pathBQuietRooms: [], + pathBRealFillChangeKeepsTheRoomNode: true, + pathBRealFillChangeAnimates: true, +}; +const keyedRoomsFailures = Object.entries(keyedRoomsExpected) + .filter(([key, value]) => JSON.stringify(keyedRooms[key]) !== JSON.stringify(value)) + .map(([key, value]) => `${key}: expected ${JSON.stringify(value)}, got ${JSON.stringify(keyedRooms[key])}`); await browser.close(); const ok = res.stagePointerEvents === 'none' && @@ -296,8 +443,10 @@ const ok = typeof res.deepLink === 'string' && res.deepLink.includes('#space=') && res.errorShown; console.log(JSON.stringify(res)); +console.log(JSON.stringify({ keyedRooms })); +for (const failure of keyedRoomsFailures) console.error(`FAIL #745 ${failure}`); // #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан // именно остановить: иначе строка успеха печатается после «FAILED». if (await reportPageErrors()) process.exit(1); -if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); } -console.log('OK space-card: live shared marker face, pointer-events:none, nothing hit-testable in the schematic (#664), deep-link button, error card'); +if (!ok || keyedRoomsFailures.length) { console.error('FAIL space-card smoke'); process.exit(1); } +console.log('OK space-card: live shared marker face, pointer-events:none, nothing hit-testable in the schematic (#664), deep-link button, error card, room shapes keyed by space and id (#745)'); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 54e0e67b..5cccace7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,9 @@ - When the editor fails to load (e.g. offline), the card no longer retries on its own and repeats the notice every few seconds; the next press retries ([#757](https://github.com/Matysh/houseplan-card/issues/757)). +- The space card no longer flashes its rooms when the plan is edited elsewhere + or its space is changed + ([#745](https://github.com/Matysh/houseplan-card/issues/745)). ## v1.79.0-beta.1 — 2026-09-30 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index b55dd7c1..ff4bd8d8 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -31,6 +31,9 @@ снова сама и не повторяет уведомление каждые несколько секунд: повтор — по следующему нажатию ([#757](https://github.com/Matysh/houseplan-card/issues/757)). +- Карточка пространства больше не мигает комнатами, когда план правят с другого + устройства или меняют пространство в её настройках + ([#745](https://github.com/Matysh/houseplan-card/issues/745)). ## 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 9599f738..20979b4c 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -265,6 +265,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `rooms-rendered-without-keys` - `rooms-rendered-without-space-key` - `same-space-room-change-recenters` +- `space-card-rooms-rendered-without-keys` - `space-create-hidden-display-override` - `stairs-view-pan-opens-target-floor` - `support-invalid-response-leaks-issued-token` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 468b5beb..98e0e17a 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9931,6 +9931,19 @@ const MUTANT_DEFINITIONS = [ replace: ' return svg`${shape}${outline}`;\n });\n })()}', }], }, + { + id: 'space-card-rooms-rendered-without-keys', + guard: 'node demo/smoke_space_card.mjs', + because: 'a config event that inserts, removes or re-zones a room shifts the space card\'s ' + + 'room list inside one space: a positional map() hands each room node to its neighbour, ' + + 'so the node no longer belongs to its data-id and the 0.12s .room transition draws the ' + + 'fill in from the neighbour\'s value (#745 AC2)', + patches: [{ + file: 'src/space-render.ts', + find: ' const roomShapes = keyed(space.id, repeat(shownRooms, (r, index) => r.id || index, (r) => {', + replace: ' const roomShapes = keyed(space.id, shownRooms.map((r) => {', + }], + }, { id: 'live-editor-devices-drops-align-guides', guard: 'node demo/smoke_align_guides.mjs', diff --git a/src/space-render.ts b/src/space-render.ts index 907ec708..1275d3ad 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -7,6 +7,8 @@ * Geometry/model math lives in space-geometry.ts (pure, unit-tested). */ import { html, svg, nothing, type TemplateResult } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; +import { repeat } from 'lit/directives/repeat.js'; import { buildDevices, areaLqi, effectiveExcludedIntegrations, roomClimateKey, roomClimateMap, sourceValue, resolvedLightSources, resolvedLightState, @@ -531,9 +533,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { for (const room of space.rooms) if (room.id) { roomFillsById.set(room.id, resolvedRoomFills.get(room) || null); } - const roomShapes = space.rooms - .filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) !== 'none') - .map((r) => { + const shownRooms = space.rooms + .filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) !== 'none'); + // #745: keyed like the full card's rooms (plan.styles.ts, #525/#534/#742/#745): a bare map() handed a + // room node to a stranger on a new `space` or a config event, and `.room`'s transition drew its fill in. + const roomShapes = keyed(space.id, repeat(shownRooms, (r, index) => r.id || index, (r) => { let cls = 'room ' + (space.bg ? 'overlay' : 'yard'); let style = ''; // tier 3 wins over the space, exactly as on the full card (HP-1454-07) @@ -563,7 +567,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { : svg``; return shape; - }); + })); // Base projection is independent of radial pools: opt-in pools are painted // above it through the same room-level Glow gates as the full plan. diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index 1ce29fba..6110fe05 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -503,7 +503,14 @@ export const planStyles = css` 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. */ + + #745: so does the space card (space-render.ts). It takes these styles + too and draws its rooms the same way: there the room set changes in + the same DOM on a new "space" in setConfig of the same element (the + outer key) and on a config event that inserts, removes or re-zones a + room (the inner key). + Witnesses: demo/smoke_space_switch_transitions.mjs, + demo/smoke_space_card.mjs. */ .op-leaf { transition: transform 0.6s ease; }