feat(plan): device markers are landmarks in the Plan editor

The Plan editor hid every device marker (display: none), so walls,
openings and stairs were placed against devices from memory. It now
shows them exactly as the Background editor does (#362): the same
markers as View, at the Background editor's effective opacity, fully
pointer-inert. The fade is filter: opacity(0.35) on each marker, not
on .devlayer, because room labels and room settings buttons share that
layer and stay opaque and interactive; filter multiplies the marker's
own opacity (.unavail -> 0.35 x 0.35), as the Background layer does.
Marker handlers already fail closed outside View/Devices.

demo/smoke_plan_device_landmarks.mjs proves AC1/AC2: same markers as
View, per-marker alpha equal to Background, core and capsule fall
through, a real click on a marker reaches the Walls tool and a room
settings button below, cursor and target unchanged, handlers inert, no
tabindex. Three mutants guard hiding, opacity override and the pointer
boundary. UX-MODES, DECOR-EDITOR, USER-GUIDE.ru mode table, changelogs.

Issue: #687
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-09-28 09:38:26 +03:00
parent cab4b4153a
commit 560849a1a7
9 changed files with 288 additions and 4 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ const res = await page.evaluate(async () => {
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
const box = btn?.getBoundingClientRect();
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
// (маркеры устройств в редакторе плана — неинтерактивные ориентиры #687,
// кнопку с ними не сравниваем)
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
+211
View File
@@ -0,0 +1,211 @@
// #687: in the Plan editor device markers are landmarks exactly as in the
// Background editor (#362) — the same markers as View, at the Background
// editor's effective opacity, and fully pointer-inert so every Plan tool
// receives the point below them. Room labels (same .devlayer) stay opaque.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const probe = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// Entering a mode settles the camera after the stage resizes; wait until
// marker positions hold still so measured points are the clicked points.
const layoutKey = () => [...sr().querySelectorAll('.devlayer [data-hp="device"]')]
.map((node) => { const r = node.getBoundingClientRect(); return `${r.left.toFixed(1)},${r.top.toFixed(1)}`; })
.join('|');
const enter = async (mode) => {
c._setMode(mode, false);
await settleMode();
const started = performance.now();
let key = layoutKey(), still = 0;
while (still < 10 && performance.now() - started < 3000) {
await new Promise((resolve) => requestAnimationFrame(resolve));
const next = layoutKey();
still = next === key ? still + 1 : 0;
key = next;
}
await c.updateComplete;
};
const stage = () => sr().querySelector('.stage');
const markers = () => [...sr().querySelectorAll('.devlayer [data-hp="device"]')];
const visible = (node) => {
const r = node.getBoundingClientRect();
return getComputedStyle(node).display !== 'none' && r.width > 0 && r.height > 0;
};
const visibleIds = () => markers().filter(visible).map((node) => node.dataset.id).sort();
// Effective alpha of one marker: its own and every ancestor's opacity and
// filter: opacity(), up to and including the stage.
const effectiveOpacity = (node) => {
let alpha = 1;
for (let el = node; el; el = el.parentElement) {
const cs = getComputedStyle(el);
alpha *= Number(cs.opacity);
for (const m of cs.filter.matchAll(/opacity\(([^)]+)\)/g)) alpha *= Number(m[1]);
if (el === stage()) break;
}
return alpha;
};
const opacityById = () => Object.fromEntries(markers().filter(visible)
.map((node) => [node.dataset.id, effectiveOpacity(node)]));
// One unavailable marker exercises contract item 2: the fade multiplies
// the marker's own opacity (.unavail) exactly as the Background layer does.
const target = markers().find((node) => node.dataset.entity && c.hass.states[node.dataset.entity]);
if (target) {
const entity = target.dataset.entity;
c.hass = { ...c.hass, states: { ...c.hass.states,
[entity]: { ...c.hass.states[entity], state: 'unavailable' } } };
await c.updateComplete;
}
await enter('view');
out.unavailableMarkerInView = !!sr().querySelector('.devlayer [data-hp="device"].unavail');
const viewIds = visibleIds();
out.viewHasMarkers = viewIds.length > 0;
await enter('decor');
const decorOpacity = opacityById();
await enter('plan');
const planIds = visibleIds();
const planOpacity = opacityById();
// AC1 — the same markers as View, each at the Background editor's alpha.
out.planShowsViewMarkers = JSON.stringify(planIds) === JSON.stringify(viewIds);
out.planOpacityMatchesBackground = planIds.length > 0 && planIds.every((id) =>
id in decorOpacity && Math.abs(planOpacity[id] - decorOpacity[id]) < 1e-3);
const plain = markers().find((node) => visible(node) && !node.classList.contains('unavail'));
out.planPlainMarkerAt35 = !!plain && Math.abs(planOpacity[plain.dataset.id] - 0.35) < 1e-3;
const unavail = markers().find((node) => visible(node) && node.classList.contains('unavail'));
out.planUnavailableMarkerAt35x35 = !!unavail
&& Math.abs(planOpacity[unavail.dataset.id] - 0.35 * 0.35) < 1e-3;
// Room labels share .devlayer: no layer fade reaches them (their own
// opacity is the label design, not this contract).
const label = sr().querySelector('.roomlabel');
out.roomLabelNotFaded = !!label
&& Math.abs(effectiveOpacity(label) - Number(getComputedStyle(label).opacity)) < 1e-3
&& !/opacity\(/.test(getComputedStyle(label).filter);
out.planMarkersOutOfTabOrder = markers().every((node) => !node.hasAttribute('tabindex')
&& !node.hasAttribute('role'));
// AC2 — pick a marker wholly inside the stage, preferring a value capsule
// that extends outside the marker's nominal box.
const s = stage().getBoundingClientRect();
const inside = markers().filter((node) => {
const r = node.getBoundingClientRect();
return visible(node) && r.left > s.left + 4 && r.right < s.right - 4
&& r.top > s.top + 4 && r.bottom < s.bottom - 4;
});
const at = (p) => p ? sr().elementFromPoint(p.x, p.y) : null;
const centre = (node) => {
const b = node.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
};
// Room chrome lying under a marker keeps the hit (the demo plan puts a
// marker over a room's settings button).
const roomChrome = '.roomlabel, [data-hp="room-settings"]';
const overLabel = inside.find((node) => at(centre(node))?.closest?.(roomChrome));
out.roomChromeUnderMarkerGetsHit = !!overLabel;
const gearPoint = overLabel && at(centre(overLabel))?.closest?.('[data-hp="room-settings"]')
? centre(overLabel) : null;
// The tool probe lies over bare plan, so the Walls tool owns the point;
// prefer a value capsule that extends outside the marker's nominal box.
const overPlan = inside.filter((node) => !at(centre(node))?.closest?.('.devlayer'));
const dev = overPlan.find((node) => node.classList.contains('valonly')
|| node.querySelector('.device-shell.with-values')) || overPlan[0];
const frame = dev?.querySelector('.device-shell-frame');
const r = dev?.getBoundingClientRect();
const fr = frame?.getBoundingClientRect();
const core = dev ? centre(dev) : null;
const capsule = fr && fr.right > r.right + 2
? { x: fr.right - 1, y: fr.top + fr.height / 2 } : core;
out.probeFound = !!dev && !!core;
out.probeHasOutsideCapsule = !!fr && fr.right > r.right + 2;
const cursorAt = (p) => { const t = at(p); return t ? getComputedStyle(t).cursor : null; };
const hit = { core: at(core), capsule: at(capsule) };
const cursor = { core: cursorAt(core), capsule: cursorAt(capsule) };
out.coreFallsThrough = !!hit.core && !hit.core.closest('.dev');
out.capsuleFallsThrough = !!hit.capsule && !hit.capsule.closest('.dev');
out.markerSubtreeIsPointerInert = !!dev && !!frame
&& getComputedStyle(dev).pointerEvents === 'none'
&& getComputedStyle(dev, '::before').pointerEvents === 'none'
&& getComputedStyle(frame).pointerEvents === 'none'
&& [...dev.querySelectorAll('*')].every((el) => getComputedStyle(el).pointerEvents === 'none');
// "The cursor over a marker is the cursor of what lies below": the same
// point with the marker taken out of layout gives the same target/cursor.
if (dev) {
dev.style.display = 'none';
out.targetSameWithoutMarker = at(core) === hit.core && at(capsule) === hit.capsule;
out.cursorSameWithoutMarker = cursorAt(core) === cursor.core && cursorAt(capsule) === cursor.capsule;
dev.style.display = '';
}
c._tool = 'draw'; c._path = []; c._cursorPt = null;
await c.updateComplete;
return { out, core, id: dev?.dataset.id, gearPoint };
});
// A real mouse click on the marker core reaches the Walls tool. It runs
// before the synthetic probes below, which leave pointer state behind.
if (probe.core) await page.mouse.click(probe.core.x, probe.core.y);
// A real click on a marker lying over a room's settings button opens the
// room dialog: the button below owns the point.
if (probe.gearPoint) await page.mouse.click(probe.gearPoint.x, probe.gearPoint.y);
const gear = await page.evaluate(async () => {
const c = window.__card;
await c.updateComplete;
const opened = c._roomDialog === true && !!c._roomEditId;
if (opened) { c._roomDialogCancel(); await c.updateComplete; }
return { roomSettingsOpenThroughMarker: opened };
});
const inert = await page.evaluate(async ({ core, id }) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
await c.updateComplete;
out.wallsToolReceivesClickThroughMarker = c._path.length === 1;
c._path = []; c._cursorPt = null;
await c.updateComplete;
const dev = [...sr().querySelectorAll('.devlayer [data-hp="device"]')].find((node) => node.dataset.id === id);
const r = dev?.getBoundingClientRect();
// The click above must have landed on the probed marker.
out.probeDidNotMove = !!r && !!core
&& Math.abs(r.left + r.width / 2 - core.x) < 1 && Math.abs(r.top + r.height / 2 - core.y) < 1;
// Handlers fail closed even when events are dispatched on the marker itself.
let serviceCalls = 0, wsCalls = 0;
const oldCallService = c.hass.callService;
const oldCallWS = c.hass.callWS;
c.hass.callService = () => { serviceCalls += 1; };
c.hass.callWS = async () => { wsCalls += 1; return { ok: true }; };
const before = { tip: c._tip, info: c._infoCard, drag: c._deviceDrag, sel: c._selId };
const opts = { bubbles: true, composed: true, cancelable: true, clientX: core?.x || 0, clientY: core?.y || 0 };
dev?.dispatchEvent(new PointerEvent('pointerover', { ...opts, pointerId: 687, pointerType: 'mouse', isPrimary: true }));
dev?.dispatchEvent(new PointerEvent('pointermove', { ...opts, pointerId: 687, pointerType: 'mouse', isPrimary: true }));
dev?.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 688, pointerType: 'mouse', button: 0, isPrimary: true }));
dev?.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 688, pointerType: 'mouse', button: 0, isPrimary: true }));
dev?.dispatchEvent(new MouseEvent('click', { ...opts, button: 0 }));
const ctx = new MouseEvent('contextmenu', { ...opts, button: 2 });
dev?.dispatchEvent(ctx);
await c.updateComplete;
out.handlersFailClosed = serviceCalls === 0 && wsCalls === 0
&& c._tip === before.tip && c._infoCard === before.info
&& c._deviceDrag === before.drag && c._selId === before.sel;
out.noDeviceHoverAttribute = !sr().querySelector('.dev[data-hp-device-hover]');
out.contextMenuNotClaimed = !ctx.defaultPrevented;
c.hass.callService = oldCallService;
c.hass.callWS = oldCallWS;
c._pointers?.clear?.(); c._panStart = null; c._panLock = null;
c._path = []; c._cursorPt = null;
await c.updateComplete;
return out;
}, { core: probe.core, id: probe.id });
const res = { ...probe.out, ...gear, ...inert };
checkAll(res);
await finish(browser, res);
+5
View File
@@ -6,6 +6,11 @@
focus frame when it receives focus; it can still be reached with Tab and
followed with Enter or Space
([#686](https://github.com/Matysh/houseplan-card/issues/686)).
- The Plan editor now shows device markers as translucent landmarks, exactly as
the Background editor does: the same markers as View at 35% opacity. They
never react to clicks, hover or the cursor, so every Plan tool, room label and
room settings button works through them
([#687](https://github.com/Matysh/houseplan-card/issues/687)).
## v1.78.0-beta.7 — 2026-09-28
+5
View File
@@ -12,6 +12,11 @@
фокуса браузера; до неё по-прежнему можно дойти клавишей Tab и перейти по
Enter или Пробелу
([#686](https://github.com/Matysh/houseplan-card/issues/686)).
- Редактор плана теперь показывает значки устройств полупрозрачными ориентирами —
так же, как редактор подложки: те же значки, что в просмотре, с
непрозрачностью 35 %. Они не реагируют на клики, наведение и курсор, поэтому
все инструменты плана, подписи комнат и кнопки настроек комнат работают сквозь
них ([#687](https://github.com/Matysh/houseplan-card/issues/687)).
## v1.78.0-beta.7 — 2026-09-28
+1 -1
View File
@@ -22,7 +22,7 @@ light routing, device state or Home Assistant actions.
| Scale | Corner drag preserves aspect ratio. Hold `Shift` for independent axes. Furniture and custom images also have four one-axis middle handles and may cross the fixed edge to mirror. |
| Rotation | Ordinary decor uses 5° steps by default and `Shift` for free rotation. Furniture and custom images are free by default and `Shift` snaps to 45°. Lines use endpoint handles instead of a rotation handle. |
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
| Context emphasis | Decor and its editing chrome stay fully opaque. Rooms, labels, devices, openings, positive-thickness walls and solid/dashed zero-thickness walls are contextual only and render at 35% opacity. The whole device presentation (core, ring, capsule, values and badges) is pointer-inert: it never hovers, opens, acts or drags, and the active Background tool receives a press through it. |
| Context emphasis | Decor and its editing chrome stay fully opaque. Rooms, labels, devices, openings, positive-thickness walls and solid/dashed zero-thickness walls are contextual only and render at 35% opacity. The whole device presentation (core, ring, capsule, values and badges) is pointer-inert: it never hovers, opens, acts or drags, and the active Background tool receives a press through it. The Plan editor shows devices the same way, as a per-marker 35% fade because room labels share the layer there (#687, `UX-MODES.md` › Plan). |
| View composition | All decor kinds form one layer above room/data fills, room hover fill, opening-tunnel fills and Glow base. Live Glow, sun, physical walls, opening symbols, devices and room labels remain above decor. The plan image remains below it (§3.3). |
| Compatibility | Legacy `width`, text `size/scale` and `plan_scale` remain readable. New writes use `width_cm`, `size_cm` and `plan_scale_x/y`. |
+1 -1
View File
@@ -258,7 +258,7 @@ Escape сначала отменяет активный диалог, меню,
| Режим | Основное назначение | Устройства | Комнаты/стены | Декор и проёмы |
|---|---|---|---|---|
| Просмотр | Контроль дома | Интерактивны; видны состояния, значения и подсказки | Комната подсвечивается при наведении; подсказка показывает название, чистую площадь, температуру, влажность и LQI | Видны, если не скрыты настройками пространства; проёмы не реагируют на клик, кроме значка замка |
| Редактор плана | Геометрия комнат, стен и проёмов | Скрыты | Интерактивны; видны кнопки настроек комнат, размеры и сетка | Проёмы всегда видны и редактируются; стены нулевой толщины рисуются полностью по оси |
| Редактор плана | Геометрия комнат, стен и проёмов | Не редактируются и полупрозрачны, как в редакторе подложки; клики и наведение проходят к инструментам плана | Интерактивны; видны кнопки настроек комнат, размеры и сетка | Проёмы всегда видны и редактируются; стены нулевой толщины рисуются полностью по оси |
| Редактор устройств | Позиции и параметры маркеров | Перетаскиваются; клик открывает настройки; доступны скрытые маркеры | Не редактируются | Служат фоном |
| Редактор подложки | Изображение, линии, фигуры, текст, мебель | Не редактируются и полупрозрачны | Комнаты и все стены полупрозрачны | Декор интерактивен и непрозрачен; скрытый в просмотре декор всё равно виден здесь |
| Киоск | Настенный экран | Интерактивны как в просмотре | Только отображение | Только отображение; редакторы отсутствуют |
+5
View File
@@ -172,6 +172,11 @@ tabs, the editor-close reserve, zoom and one gear whose menu holds the rest.
editor with the draw tool armed (an empty floor has nothing useful in View).
- ⚙ General settings (fill palette) lives here — it is about the plan's appearance.
- Devices are landmarks here exactly as in Background (#687, #362): the same
markers as View at 35% (each marker's own opacity multiplied, as the
Background layer does) and fully pointer-inert — every press, hover and
cursor belongs to the Plan tool, room label or room settings button below.
Room labels share the device layer and stay opaque and interactive.
- Independent partitions and columns are masonry for hit testing as well as
area/light: room hover stops at their physical bodies just as it stops at a
thick room wall. This does not split the room or change its HA area.
+45
View File
@@ -3955,6 +3955,51 @@ const MUTANT_DEFINITIONS = [
replace: ' && !inEditorSecondary) {',
}],
},
{
id: 'plan-device-landmarks-hidden',
guard: 'node demo/smoke_plan_device_landmarks.mjs',
because: '#687 AC1: the Plan editor shows the same markers as View as landmarks; hiding '
+ 'them again removes the reference the owner asked for',
patches: [{
file: 'src/styles/plan.styles.ts',
find: ' .stage.markup .devlayer .dev {\n'
+ ' filter: opacity(0.35);\n'
+ ' }\n',
replace: ' .stage.markup .devlayer .dev {\n'
+ ' display: none;\n'
+ ' }\n',
}],
},
{
id: 'plan-device-landmarks-override-own-opacity',
guard: 'node demo/smoke_plan_device_landmarks.mjs',
because: '#687 contract item 2: the fade multiplies the marker\'s own opacity as the '
+ 'Background layer does; an opacity override shows an unavailable marker at 35% '
+ 'instead of 35% x 35%',
patches: [{
file: 'src/styles/plan.styles.ts',
find: ' .stage.markup .devlayer .dev {\n'
+ ' filter: opacity(0.35);\n',
replace: ' .stage.markup .devlayer .dev {\n'
+ ' opacity: 0.35;\n',
}],
},
{
id: 'plan-device-landmarks-hit-target',
guard: 'node demo/smoke_plan_device_landmarks.mjs',
because: '#687 AC2: a visible marker must never own a Plan-editor point; without the '
+ 'pointer boundary its 44 px hit area and capsule swallow the Walls tool click and the '
+ 'room settings button below',
patches: [{
file: 'src/styles/plan.styles.ts',
find: ' .stage.markup .devlayer .dev,\n'
+ ' .stage.markup .devlayer .dev *,\n'
+ ' .stage.markup .devlayer .dev::before {\n'
+ ' pointer-events: none;\n'
+ ' }\n',
replace: '',
}],
},
{
id: 'opening-light-quantum-identity',
guard: 'node --test --test-name-pattern="#366" test/logic.test.mjs',
+13 -1
View File
@@ -1176,8 +1176,20 @@ export const planStyles = css`
.stage.markup .room {
pointer-events: none;
}
/* #687: devices are landmarks in the Plan editor too, exactly as in
Background (#362): the same markers as View, at the same 35% and fully
pointer-inert, so every Plan tool receives the point below them. The
fade sits on each marker, not on .devlayer: room labels share that
layer and stay opaque and interactive here. filter: opacity()
multiplies the marker's own opacity (.unavail), as the Background
layer opacity does. */
.stage.markup .devlayer .dev {
display: none; /* in plan mode the icons do not get in the way; labels stay */
filter: opacity(0.35);
}
.stage.markup .devlayer .dev,
.stage.markup .devlayer .dev *,
.stage.markup .devlayer .dev::before {
pointer-events: none;
}
/* mode frames: the edit modes are visible at a glance */
.stage.mode-plan {