mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
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:
+151
-2
@@ -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)');
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user