fix(plan): key room shapes by space and id so a floor switch never repaints a stranger (#742)

The flat room list was a bare map(), so Lit reused room nodes by position.
On a floor switch the previous floor's room node became the new floor's room
and `.room { transition: 0.12s }` drew its fill and stroke in from the old
computed values: one paper-white frame, then two or three frames darker than
the final fill (alpha rises while fill-opacity falls), then the fill. Between
two filled floors the fill bled in from the other floor's colour.

The list is now keyed(space.id, repeat(rooms, (r, i) => r.id || i, ...)), the
shape #534 settled on for openings and markers. The outer key handles the
floor switch, including room ids repeated on two floors (ids are unique only
within a space); the inner key keeps a node bound to its room inside a space,
where inserts, merges, splits and the editor filter shift positions. An
id-less room keys by its numeric index, which never equals a string id. The
transition itself stays: it smooths hover and a real fill change on the same
floor.

Witness: a new section of smoke_space_switch_transitions, before physicalize
(after it the first room changes template branch and the node is recreated
anyway). Red on dev: five transitions on g1, node r1 reused for g1, computed
fill rgba(0, 0, 0, 0) / 1; room nodes swapped by an insert; same-id case
reuses r1. A real custom_fill change still runs a fill transition (catches
`transition: none`). Two mutants: inner key removed, outer key removed.

Issue: #742
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 10:13:50 +00:00
committed by claude[bot]
parent 88985d2e4c
commit a0f72280ee
7 changed files with 185 additions and 9 deletions
+123 -4
View File
@@ -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);
+5
View File
@@ -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
+5
View File
@@ -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
@@ -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`
+29
View File
@@ -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',
+5 -2
View File
@@ -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}`}></path>`
: nothing;
return svg`${shape}${outline}`;
});
}));
})()}
${this._renderRoomHoverFill(roomHover)}
${this._renderOpeningTunnelFills(space, roomFills)}
+14 -1
View File
@@ -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;
}