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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 12:15:40 +00:00
committed by claude[bot]
parent 707cb18f7e
commit 6cd29f9b2b
7 changed files with 187 additions and 7 deletions
+151 -2
View File
@@ -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)');
+3
View File
@@ -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
+3
View File
@@ -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
@@ -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`
+13
View File
@@ -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',
+8 -4
View File
@@ -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`<rect class="${cls}" style="${style}" data-hp="room" data-id=${hpId} data-area=${hpArea}
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"></rect>`;
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.
+8 -1
View File
@@ -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;
}