diff --git a/demo/smoke_feedback_v2.mjs b/demo/smoke_feedback_v2.mjs index f19b4c78..80a64924 100644 --- a/demo/smoke_feedback_v2.mjs +++ b/demo/smoke_feedback_v2.mjs @@ -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; diff --git a/demo/smoke_plan_device_landmarks.mjs b/demo/smoke_plan_device_landmarks.mjs new file mode 100644 index 00000000..6c53e753 --- /dev/null +++ b/demo/smoke_plan_device_landmarks.mjs @@ -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); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fd54e0b6..166edac1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index f762841b..fcf8f2e7 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 diff --git a/docs/DECOR-EDITOR.md b/docs/DECOR-EDITOR.md index 44c11463..070f975a 100644 --- a/docs/DECOR-EDITOR.md +++ b/docs/DECOR-EDITOR.md @@ -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`. | diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index f5c7d5e9..5eaa85e6 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -258,7 +258,7 @@ Escape сначала отменяет активный диалог, меню, | Режим | Основное назначение | Устройства | Комнаты/стены | Декор и проёмы | |---|---|---|---|---| | Просмотр | Контроль дома | Интерактивны; видны состояния, значения и подсказки | Комната подсвечивается при наведении; подсказка показывает название, чистую площадь, температуру, влажность и LQI | Видны, если не скрыты настройками пространства; проёмы не реагируют на клик, кроме значка замка | -| Редактор плана | Геометрия комнат, стен и проёмов | Скрыты | Интерактивны; видны кнопки настроек комнат, размеры и сетка | Проёмы всегда видны и редактируются; стены нулевой толщины рисуются полностью по оси | +| Редактор плана | Геометрия комнат, стен и проёмов | Не редактируются и полупрозрачны, как в редакторе подложки; клики и наведение проходят к инструментам плана | Интерактивны; видны кнопки настроек комнат, размеры и сетка | Проёмы всегда видны и редактируются; стены нулевой толщины рисуются полностью по оси | | Редактор устройств | Позиции и параметры маркеров | Перетаскиваются; клик открывает настройки; доступны скрытые маркеры | Не редактируются | Служат фоном | | Редактор подложки | Изображение, линии, фигуры, текст, мебель | Не редактируются и полупрозрачны | Комнаты и все стены полупрозрачны | Декор интерактивен и непрозрачен; скрытый в просмотре декор всё равно виден здесь | | Киоск | Настенный экран | Интерактивны как в просмотре | Только отображение | Только отображение; редакторы отсутствуют | diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index 67eb1624..2d462690 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -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. diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 5f059645..d4dde20f 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index b1b2a20b..790168b4 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -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 {