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