From b40e8ff58d663437b8e8f27edb134280f87e5ca7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 2 Oct 2026 20:26:16 +0300 Subject: [PATCH] fix(led): badge at the anchor, room_id wins, chain stays in its space, hidden loads nothing (#780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 of the code review: - M1: a strip keeps its marker's value badge, passive, at the half-length anchor on the card and on the static card (no icon core, pulse or slot). - M2: one room resolver for the strip's Glow — an explicit valid room_id of the marker wins over the anchor room; a stale one falls back (stripRoom). - M3: the chain remembers the space it is drawn in; a space switch finishes it there, never in the space shown next, and opens no picker over it. - M4: visibility is decided before import(): a hidden marker or an HA-disabled device loads no LED chunk (ledVisible, also checks the stored marker so a just-hidden one does not slip through a stale device list). - M6: the static card is a full light_pools × live_states browser matrix. Unit tests for M2/M3, smokes for M1/M3/M4/M6, five registered mutants. Issue: #780 User-Visible: yes --- demo/smoke_led_strip_draw.mjs | 32 +++++ demo/smoke_led_strip_glow.mjs | 132 +++++++++++++++--- docs/ARCHITECTURE.md | 5 +- docs/CHANGELOG.md | 3 +- docs/CHANGELOG.ru.md | 3 +- docs/DEVICE-PRESENTATION.md | 4 +- docs/LIGHT.md | 6 +- docs/USER-GUIDE.md | 4 +- docs/USER-GUIDE.ru.md | 4 +- docs/UX-MODES.md | 4 +- docs/testing-notes/mutation-browser-guards.md | 9 +- scripts/mutation-registry.mjs | 56 +++++++- src/houseplan-card.ts | 18 ++- src/led-strip-editor.ts | 25 ++-- src/led-strip-gate.ts | 26 +++- src/led-strip-runtime.ts | 16 ++- src/space-render.ts | 28 ++-- src/styles/devices.styles.ts | 2 + test/led-strip-editor.test.mjs | 23 +++ test/led-strip-runtime.test.mjs | 24 +++- 20 files changed, 362 insertions(+), 62 deletions(-) diff --git a/demo/smoke_led_strip_draw.mjs b/demo/smoke_led_strip_draw.mjs index 958ac139..7c22180d 100644 --- a/demo/smoke_led_strip_draw.mjs +++ b/demo/smoke_led_strip_draw.mjs @@ -6,6 +6,7 @@ * the tool finishes, never loses the chain. A new segment stops at the first * face of a thick wall, a fast vertex drag cannot jump it. The selection lives * in the session only, its tray closes on Esc and on a clean background click. + * A space switch finishes an unfinished chain in its own space (r1 M3). * Plan/Background have no LED tool, handles or targets. */ import { launch, check, finish } from './serve.mjs'; @@ -135,6 +136,37 @@ await clickAt(700, 550); check('a clean background click drops the selection', await evaluate(() => window.__card._ledEditor.sel), null); check('dropping the selection writes nothing', await evaluate(() => window.__ledSaves.length), savesBefore); +// r1 M3 (ТЗ §4): a space switch with an unfinished chain finishes it in the +// space it was drawn in — never in the space shown now — and opens no picker there. +await evaluate(() => window.__hpTest.setServerConfig((cfg) => { + if (!cfg.spaces.some((space) => space.id === 'led2')) { + cfg.spaces.push({ id: 'led2', title: 'LED 2', cell_cm: 5, view_box: [0, 0, 1, 0.7], rooms: [], wall_segments: [], partitions: [] }); + } +})); +await page.waitForTimeout(300); +const beforeSwitch = (await strips()).length; +await page.click('[data-tool="led-strip"]'); +await page.waitForTimeout(200); +await clickAt(200, 500); +await clickAt(350, 500); +check('an unfinished chain before the switch', await chain(), 2); +await evaluate(() => window.__hpTest.switchSpace('led2')); +await page.waitForTimeout(600); +const switched = await evaluate(() => { + const c = window.__card; + const of = (id) => c._serverCfg.spaces.find((space) => space.id === id)?.led_strips || []; + return { led: of('led').length, led2: of('led2').length, last: of('led').at(-1)?.points, + sel: c._ledEditor.sel, tool: c._ledEditor.tool, + picker: !!c.shadowRoot.querySelector('hp-dialog[data-kind="led-picker"]') }; +}); +check('the chain is stored in the space it was drawn in', switched.led, beforeSwitch + 1); +check('nothing is written into the space shown now', switched.led2, 0); +check('the stored chain keeps its points', JSON.stringify(switched.last), JSON.stringify([[0.2, 0.5], [0.35, 0.5]])); +check('no tool, selection or picker carried into the other space', + JSON.stringify([switched.tool, switched.sel, switched.picker]), JSON.stringify([false, null, false])); +await evaluate(() => window.__hpTest.switchSpace('led')); +await page.waitForTimeout(400); + // «Optimize plans» reports a strip that passes through the partition and changes nothing in it. const optimizeNote = await evaluate(async () => { const c = window.__card; diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs index 0251c236..6bc46f4d 100644 --- a/demo/smoke_led_strip_glow.mjs +++ b/demo/smoke_led_strip_glow.mjs @@ -5,8 +5,10 @@ * strip replaces the icon, paints the two-stroke stripe and — with Glow — the * linear field; off/unavailable have no field, unavailable is dashed; without * Glow the core takes the source colour. The whole length is one target: a - * click toggles once, a touch pan calls nothing. The static card is passive - * and computes no field with `light_pools: false`. + * click toggles once, a touch pan calls nothing. A hidden marker or an + * HA-disabled device loads no chunk (r1 M4); the value badge stays at the + * anchor, passive (r1 M1). The static card is passive in all four + * light_pools × live_states combinations (r1 M6). */ import { launch, check, finish } from './serve.mjs'; import { installHpTestOnPage } from './helpers/hp-test.mjs'; @@ -16,17 +18,23 @@ const ledRequests = []; page.on('request', (request) => { if (/led-strip-(runtime|field|editor)-/.test(request.url())) ledRequests.push(request.url().replace(/.*\//, '')); }); const evaluate = (fn, arg) => page.evaluate(fn, arg); const settle = () => page.waitForTimeout(500); -const setStrips = (strips, settings) => evaluate(async ([strips, settings]) => { +const setStrips = (strips, settings, extra = {}) => evaluate(async ([strips, settings, extra]) => { await window.__hpTest.setServerConfig((cfg) => { cfg.spaces[0].led_strips = strips; cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), ...settings }; cfg.markers = [ ...(cfg.markers || []).filter((m) => !['d_light1', 'd_lamp'].includes(m.id)), - { id: 'd_light1', binding: 'device:d_light1', space: 'f1' }, - { id: 'd_lamp', binding: 'device:d_lamp', space: 'f1' }, + { id: 'd_light1', binding: 'device:d_light1', space: 'f1', ...(extra.d_light1 || {}) }, + { id: 'd_lamp', binding: 'device:d_lamp', space: 'f1', ...(extra.d_lamp || {}) }, ]; }); -}, [strips, settings]); +}, [strips, settings, extra]); +const registryDisabled = (by) => evaluate(async (by) => { + window.__setRegistryDisabled('device', 'd_light1', by); + window.__card.hass = window.__mkHass(); + await new Promise((resolve) => setTimeout(resolve, 250)); + await window.__card.updateComplete; +}, by); const setState = (entity, state, attributes = {}) => evaluate(async ([entity, state, attributes]) => { const c = window.__card; const st = c.hass.states[entity]; c.hass = { ...c.hass, states: { ...c.hass.states, [entity]: { ...st, state, attributes: { ...st.attributes, ...attributes } } } }; @@ -46,6 +54,21 @@ await setStrips([{ id: 'hidden', points: [[0.12, 0.40], [0.40, 0.40]], marker: ' await settle(); check('hidden shape and unbound strip: still no LED chunk', ledRequests.length, 0); check('a hidden shape leaves the ordinary icon', await evaluate(() => !!window.__card.shadowRoot.querySelector('.dev[data-id="d_lamp"]'))); +// r1 M4 (ТЗ §13.1): visibility is decided before import() — an active strip of +// a hidden marker or of an HA-disabled device loads no LED chunk at all. +const ceilingStrip = [{ id: 'ceiling', points: [[0.12, 0.30], [0.40, 0.30], [0.40, 0.45]], marker: 'd_light1' }]; +await setStrips(ceilingStrip, { glow_enabled: true }, { d_light1: { hidden: true } }); +await settle(); +check('a hidden marker: its active strip loads no LED chunk', ledRequests.length, 0); +check('a hidden marker: no stripe, no badge, no icon', await evaluate(() => { + const root = window.__card.shadowRoot; + return !root.querySelector('[data-led-strip], [data-led-badge], .dev[data-id="d_light1"]'); +})); +await registryDisabled('user'); +await setStrips(ceilingStrip, { glow_enabled: true }); +await settle(); +check('an HA-disabled device: its active strip loads no LED chunk', ledRequests.length, 0); +await registryDisabled(null); await setStrips([{ id: 'ceiling', points: [[0.12, 0.30], [0.40, 0.30], [0.40, 0.45]], marker: 'd_light1' }, { id: 'loose', points: [[0.12, 0.45], [0.30, 0.45]], marker: null }], { glow_enabled: true }); @@ -60,6 +83,40 @@ check('no auto-slot reserved for the strip’s marker', await evaluate(() => !(' check('on with Glow: white core and a field', JSON.stringify([await stripe('d_light1'), await field() > 0]), JSON.stringify([{ state: 'on', core: '#FFFFFF', dash: null }, true])); +// r1 M1 (ТЗ §5): the strip keeps the device's value badge, passive, at the +// half-length anchor — the icon core, pulse and slot stay suppressed. +const badgeAt = (root) => evaluate((root) => { + const host = root === 'card' ? window.__card : document.querySelector('houseplan-space-card'); + const sr = host.shadowRoot; + const badge = sr.querySelector('[data-led-badge="d_light1"]'); + if (!badge) return null; + const core = badge.querySelector('.device-core').getBoundingClientRect(); + const value = badge.querySelector('.value-badge'); + const style = getComputedStyle(badge); + return { text: value?.textContent?.trim() || '', x: core.x + core.width / 2, y: core.y + core.height / 2, + passive: style.pointerEvents === 'none' && badge.getAttribute('aria-hidden') === 'true', + coreHidden: getComputedStyle(badge.querySelector('.device-core')).visibility === 'hidden', + icon: !!badge.querySelector('ha-icon, .device-pulse, .activity-dot') }; +}, root); +const midpoint = () => evaluate(() => { + const el = window.__card.shadowRoot.querySelector('.led-hit'); + const p = el.getPointAtLength(el.getTotalLength() / 2), m = el.getScreenCTM(); + return [p.x * m.a + m.e, p.y * m.d + m.f]; +}); +check('no configured badge: no badge element', await badgeAt('card'), null); +await setStrips(ceilingStrip, { glow_enabled: true }, { d_light1: { display: 'badge', value_badge: { + enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position: 'right' } } }); +await settle(); +{ + const badge = await badgeAt('card'); + const [mx, my] = await midpoint(); + check('the value badge stays with the strip', !!badge?.text); + check('the badge sits at the half-length anchor', !!badge && Math.hypot(badge.x - mx, badge.y - my) < 3, + true); + check('the badge is passive, without the icon core or pulse', + JSON.stringify(badge && [badge.passive, badge.coreHidden, badge.icon]), JSON.stringify([true, true, false])); +} + // One target over the whole length: a click toggles exactly once. const calls = await evaluate(async () => { const c = window.__card; const log = []; @@ -114,24 +171,59 @@ const colored = await stripe('d_light1'); check('on without Glow: coloured core, no field', JSON.stringify([colored?.state, colored?.core !== '#FFFFFF', await field()]), JSON.stringify(['on', true, 0])); -// The static card: passive; the field only with light_pools. -await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: true }); -const staticCards = await evaluate(async () => { +// The static card (ТЗ §8, AC14, r1 M6): the full light_pools × live_states +// matrix. Passive in all four: no hit path, no focus, a click calls nothing. +// live_states:false — a neutral stripe, no field; light_pools:false — no +// field, the core carries the source colour when the state is live. +await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: true }, + { d_light1: { display: 'badge', value_badge: { + enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position: 'right' } } }); +const matrix = await evaluate(async () => { await customElements.whenDefined('houseplan-space-card'); const host = document.createElement('div'); host.style.width = '700px'; document.body.appendChild(host); - const make = (pools) => { const el = document.createElement('houseplan-space-card'); el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: pools }); el.hass = window.__card.hass; host.appendChild(el); return el; }; - const plain = make(false), pools = make(true); - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - for (let i = 0; i < 40; i++) { - await wait(50); - if (plain.shadowRoot?.querySelector('[data-led-strip]') && pools.shadowRoot?.querySelector('[data-led-field]')) break; + const cards = {}; + for (const pools of [false, true]) for (const live of [false, true]) { + const el = document.createElement('houseplan-space-card'); + el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: pools, live_states: live }); + el.hass = window.__card.hass; host.appendChild(el); + cards[`pools=${pools},live=${live}`] = el; } - const read = (el) => ({ stripe: !!el.shadowRoot?.querySelector('[data-led-strip]'), - field: !!el.shadowRoot?.querySelector('[data-led-field]'), hit: !!el.shadowRoot?.querySelector('.led-hit') }); - return { plain: read(plain), pools: read(pools) }; + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + for (let i = 0; i < 60; i++) { + await wait(50); + if (Object.values(cards).every((el) => el.shadowRoot?.querySelector('[data-led-strip]')) + && cards['pools=true,live=true'].shadowRoot.querySelector('[data-led-field]')) break; + } + await wait(300); + const out = {}; + for (const [key, el] of Object.entries(cards)) { + const sr = el.shadowRoot; + const g = sr.querySelector('[data-led-strip]'); + const core = g?.querySelector('.led-core'); + const box = core?.getBoundingClientRect(); + if (box) { + const target = sr.elementFromPoint?.(box.x + box.width / 2, box.y + box.height / 2); + target?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + } + out[key] = { + state: g?.dataset.state, white: core?.getAttribute('stroke') === '#FFFFFF', + field: !!sr.querySelector('[data-led-field]'), + passive: !sr.querySelector('.led-hit, [data-led-strip] [role], [data-led-strip] [tabindex]'), + badge: !!sr.querySelector('[data-led-badge="d_light1"] .value-badge'), + }; + } + host.remove(); + return out; }); -check('static card without light_pools: passive stripe, no field', JSON.stringify(staticCards.plain), JSON.stringify({ stripe: true, field: false, hit: false })); -check('static card with light_pools: the field', JSON.stringify(staticCards.pools), JSON.stringify({ stripe: true, field: true, hit: false })); +check('static live_states:false + light_pools:false — neutral, no field, passive', JSON.stringify(matrix['pools=false,live=false']), + JSON.stringify({ state: 'off', white: true, field: false, passive: true, badge: true })); +check('static live_states:true + light_pools:false — source colour in the core, no field, passive', JSON.stringify(matrix['pools=false,live=true']), + JSON.stringify({ state: 'on', white: false, field: false, passive: true, badge: true })); +check('static live_states:false + light_pools:true — neutral, no field, passive', JSON.stringify(matrix['pools=true,live=false']), + JSON.stringify({ state: 'off', white: true, field: false, passive: true, badge: true })); +check('static live_states:true + light_pools:true — white core and the field, passive', JSON.stringify(matrix['pools=true,live=true']), + JSON.stringify({ state: 'on', white: true, field: true, passive: true, badge: true })); +check('a click on any static stripe toggles nothing', await evaluate(() => window.__card.hass.states['light.ceiling'].state), 'on'); // The device dialog offers «Show as LED strip» before the tool is loaded: // a static section (no dialog shift), the press loads the tool and starts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36c866b9..a337d4ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -704,7 +704,10 @@ and the device model stays the only owner of state and services. Four modules: | `led-strip-editor.ts` (+ `i18n/led`) | lazy `led-editor` | the Devices tool, tray, picker, representation switch, LED history commands | A View without a displayed active strip, and the Devices editor without the -tool or an editable strip, load none of them. Each chunk checks the entry +tool or an editable strip, load none of them. «Displayed» is decided before +`import()` (`ledVisible`): an active strip bound to a live device of the +space that is neither hidden nor HA-disabled — the stored marker is checked as +well as the built device list (r1 M4). Each chunk checks the entry build fingerprint; a failed load is fail-dark for the strips only and retried on the next explicit entry. Budgets: `LAZY_LED_GZIP_CEILING` and `LAZY_LED_EDITOR_GZIP_CEILING` in `scripts/bundle-budget.mjs`. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3cf4b8f0..3a6b383c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,7 +6,8 @@ draws a strip on the plan point by point — it snaps to the grid and to wall faces and stops at walls, windows and columns, passing doors and passages. Bind it to a light (or any device) and the strip replaces the device's icon: - a two-stroke stripe that, when the light is on with Glow, casts a soft + a two-stroke stripe with the device's value badge at its middle that, when + the light is on with Glow, casts a soft continuous band of its colour (50 cm by default) that does not pass through walls or closed doors; without Glow the stripe itself takes the light's colour. In the View the whole strip is one tap target, also in 2.5D and on diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 02ba405f..c453aa14 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -12,7 +12,8 @@ «LED-лента» — лента рисуется на плане по точкам, притягивается к сетке и к граням стен и упирается в стены, окна и колонны, проходя через двери и проёмы. Привяжите её к светильнику (или любому устройству) — и лента заменит - значок устройства: полоса из двух штрихов, а при включённом свете с Glow — + значок устройства: полоса из двух штрихов с бейджем значения устройства + посередине, а при включённом свете с Glow — мягкая непрерывная полоса света его цвета (по умолчанию 50 см), которая не проходит сквозь стены и закрытые двери; без Glow цвет света принимает сама полоса. В просмотре вся лента — одна цель нажатия, в том числе в 2.5D и в diff --git a/docs/DEVICE-PRESENTATION.md b/docs/DEVICE-PRESENTATION.md index f1c49b39..ed035c06 100644 --- a/docs/DEVICE-PRESENTATION.md +++ b/docs/DEVICE-PRESENTATION.md @@ -120,7 +120,9 @@ the value badge keep working; the individual `display`, `size`, `angle`, icon». While shown as a strip the icon is not drawn, its saved position and auto-grid slot are not used; the anchor — the point at half the polyline length — replaces the position for the room, tooltip, label and badge (an -explicit valid `room_id` wins). A hidden shape (`active: false`) changes +explicit valid `room_id` wins — also for the strip's Glow room). The value +badge is drawn at the anchor as a passive satellite (`data-led-badge`) — no +icon core, pulse or auto-slot (r1 M1). A hidden shape (`active: false`) changes nothing: the icon works normally. No pulses and no alarm red on the stripe. `hidden` / HA-disabled suppress the stripe, its target and its light like an icon; temporarily unavailable keeps it as a dashed grey stripe. In the device diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 178107b2..b737f769 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -443,7 +443,11 @@ only the geometry differs: Devices editor shows active strips (unbound ones as grey dashes); Plan/Background show a passive translucent stripe. `houseplan-space-card` draws the passive stripe always and the field only with `light_pools: true` - — with the option off no barrier, visibility or timer is created. + — with the option off no barrier, visibility or timer is created; with + `live_states: false` the stripe is neutral and has no field (all four + combinations are one browser matrix in `smoke_led_strip_glow`). +- **Room.** The Glow room of a strip is the explicit valid `room_id` of its + marker, otherwise the room of the half-length anchor (`stripRoom`). - **Laziness.** The stripe/hit/2.5D code (`led-strip-runtime`) and the field (`led-strip-field`) are separate lazy chunks; the initial graph holds only the presence check and the loader (`led-strip-gate`). Caches are bounded per diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 2ba44397..12846dd8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1051,14 +1051,14 @@ editor** — there is no need to switch to the Plan editor. |---|---| | **LED strip** (next to «Add») | Starts drawing. A clean click adds a point; the grid and wall faces attract it, a zero-thickness wall its axis; `Shift` gives 45° steps. A new segment stops at the first face of a thick wall, partition, column or window («The strip stopped at a wall»); doors, gates and passages let it through | | `Ctrl/Cmd+Z` while drawing | Removes the chain's last point, not another history step | -| `Esc`, a double click on the last point, pressing **LED strip** again, leaving the editor | Finish the strip; a chain of fewer than two distinct points is not saved | +| `Esc`, a double click on the last point, pressing **LED strip** again, leaving the editor, switching to another space | Finish the strip in the space it was drawn in; a chain of fewer than two distinct points is not saved | | A click on the first point (at least three vertices) | Closes the strip | | After finishing | The device picker opens: lights first, devices bound to another strip are disabled with an explanation, **New device…** creates one and binds it in the same write, **Later** keeps an unbound strip (grey dashes, visible in the Devices editor only) | | A click on a strip | Selects it and shows its vertices; they can be dragged, and walls stop them even on a fast drag. The tray offers **Device settings**, **Bind / Change device**, **Unbind**, **Show as icon**, **Delete strip**. `Esc` or a clean click on free space drops the selection | | In the device dialog | **Show as LED strip** draws a shape for the same marker (or brings a saved one back at once); **Show as icon** returns the ordinary icon, the shape is kept hidden and comes back without redrawing. The usual unsaved-changes question comes first | Binding creates no new Home Assistant device: the same marker, light -settings, tap actions and badge. The strip glows when the device does: with +settings, tap actions and badge — drawn at the middle of the strip's length. The strip glows when the device does: with Glow on, a white core and a soft band of colour 50 cm wide by default (or the device's own radius); without Glow the core takes the light's colour. Light does not pass through walls or closed doors. In View the whole strip is one diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 571dd166..74dc8df9 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1147,14 +1147,14 @@ binding tombstone не вырезает её из живого устройст |---|---| | **LED-лента** (рядом с «Добавить») | Включает рисование. Чистый клик ставит точку; сетка и грани стен притягивают точку, у стены нулевой толщины — её ось; `Shift` даёт шаг 45°. Новый отрезок упирается в первую грань толстой стены, перегородки, колонны или окна («Лента упёрлась в стену»); двери, ворота и проходы пропускают | | `Ctrl/Cmd+Z` во время рисования | Убирает последнюю точку цепочки, а не чужое действие истории | -| `Esc`, двойной клик по последней точке, повторное нажатие **LED-лента**, выход из редактора | Завершают ленту; цепочка меньше двух различных точек не сохраняется | +| `Esc`, двойной клик по последней точке, повторное нажатие **LED-лента**, выход из редактора, переход на другое пространство | Завершают ленту в том пространстве, где её рисовали; цепочка меньше двух различных точек не сохраняется | | Клик по первой точке (не меньше трёх вершин) | Замыкает ленту | | После завершения | Открывается выбор устройства: сначала светильники, занятые другой лентой недоступны с пояснением, **Новое устройство…** создаёт его и привязывает в той же записи, **Позже** оставляет непривязанную ленту (серый пунктир, видна только в редакторе устройств) | | Клик по ленте | Выделяет её и показывает вершины; их можно перетаскивать, упор в стены действует и при быстром перетаскивании. Внизу — лоток: **Настройки устройства**, **Привязать / Сменить устройство**, **Отвязать**, **Показывать значком**, **Удалить ленту**. `Esc` или чистый клик по свободному месту снимают выделение | | В диалоге устройства | **Показывать LED-лентой** — нарисовать форму для этого же маркера (или сразу вернуть сохранённую); **Показывать значком** — вернуть обычный значок, форма сохраняется скрыто и восстанавливается без перерисовки. Перед этим действует обычный вопрос о несохранённых изменениях | Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же -настройки света, действия по нажатию и бейдж. Лента светится, когда светится +настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится устройство: при включённом Glow — белое ядро и мягкая цветная полоса света радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index c91b1287..b688ebed 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -256,7 +256,9 @@ layer you cannot see is a layer you cannot edit. grid and to physical wall faces / zero-wall axes; a new segment stops at the first face of masonry, a partition, a column or a window. `Ctrl/Cmd+Z` removes the chain's own point first, `Esc` finishes. A finished strip opens - the device picker. A clicked strip is selected (session state only) and + the device picker. Switching the space finishes an unfinished chain in the + space it was drawn in (never in the one shown next) and opens no picker + there. A clicked strip is selected (session state only) and shows vertex handles and the LED branch of the secondary tray. An ordinary icon clicked while a strip is selected still opens its own dialog. The device dialog switches the representation («Show as LED strip» / «Show as diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 82c89fc4..3a3ee40f 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -14,11 +14,11 @@ to prove that the Node witness actually kills it. | --- | ---: | --- | | Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. | | Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. | -| Paint, cascade and layer composition | 30 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | +| Paint, cascade and layer composition | 32 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | | Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | | Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | -| Custom-element and HA browser lifecycle | 100 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **223 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | +| Custom-element and HA browser lifecycle | 101 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | +| **Total** | **226 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | ## Measured effect @@ -91,9 +91,11 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p - `iso-sun-card-drops-occluders` - `iso-sun-flat-wedges-remain` - `iso-theme-dark-wall-rule-returns` +- `led-badge-dropped` - `led-core-coloured-under-glow` - `led-icon-not-suppressed` - `led-source-stays-round-at-anchor` +- `led-static-live-ignored` - `led-unbound-in-view` - `stage3-w4-device-target-loses-44px-floor` - `stage3-w5-runtime-nudge-writes-storage` @@ -251,6 +253,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `junction-limit-candidate-fail-open` - `junction-limit-write-gate-removed` - `led-auto-slot-reserved` +- `led-hidden-marker-loads-chunk` - `locale-failure-toast-dropped` - `marker-reject-keeps-optimistic-candidate` - `marker-rollback-keeps-enqueue-time-revision` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index e300c7b1..295ebec3 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [ find: ' ${passageGlowTunnels}\n' + ' ${decorImages}\n' + ' ${stairShapes}\n' - + ' ${glowPools}\n ${leds.size', + + ' ${glowPools}\n ${ledVisible(', replace: ' ${passageGlowTunnels}\n' + ' ${!space.bg && !disp.showNames ? svg`${space.rooms.map((room) => {\n' + ' const center = roomCenter(room);\n' @@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [ + ' ${decorImages}\n' + ' ${stairShapes}\n' + ' ${glowPools}\n' - + ' ${leds.size', + + ' ${ledVisible(', }], }, { @@ -14374,6 +14374,58 @@ const MUTANT_DEFINITIONS = [ replace: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: old_id for old_id, new_id in marker_map.items()', }], }, + { + id: 'led-badge-dropped', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: "#780 r1 M1: a strip keeps the device value badge, passive, at the half-length anchor — measured against the rendered stripe in Chromium", + patches: [{ + file: 'src/houseplan-card.ts', + find: " ${ledDevs.map((d) => this._ledBadge(d, view, isoOverlays?.devices.get(d.id)))}\n", + replace: "", + }], + }, + { + id: 'led-room-id-ignored', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-runtime.test.mjs', + because: '#780 r1 M2: an explicit valid room_id wins over the anchor room for the strip Glow', + patches: [{ + file: 'src/led-strip-runtime.ts', + find: " return (roomId ? rooms.find((r) => r.id === roomId) : undefined)\n ?? (anchor", + replace: " return (anchor", + }], + }, + { + id: 'led-chain-written-to-shown-space', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-editor.test.mjs', + because: '#780 r1 M3: a space switch finishes an unfinished chain in the space it was drawn in, never the one shown now', + patches: [{ + file: 'src/led-strip-editor.ts', + find: " chain.space, id, () => strip);", + replace: " this.host._spaceModel()?.id ?? chain.space, id, () => strip);", + }], + }, + { + id: 'led-hidden-marker-loads-chunk', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: '#780 r1 M4: a hidden marker or an HA-disabled device decides before import() — no LED chunk request in the browser network log', + patches: [{ + file: 'src/led-strip-gate.ts', + find: " && (!d.hidden || showHidden) && !hidden(d.id));", + replace: ");", + }], + }, + { + id: 'led-static-live-ignored', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: '#780 r1 M6: the static card with live_states:false draws a neutral stripe without a field in both light_pools modes — the rendered SVG state of four real cards', + patches: [{ + file: 'src/led-strip-runtime.ts', + find: " const views = input.live ? frame.views", + replace: " const views = true ? frame.views", + }], + }, { id: 'led-old-writer-drops-strips', guard: 'node scripts/backend-test-guard.mjs old_writer tests_backend/test_led_strips.py', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 759cf82a..159185ae 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -6,7 +6,7 @@ * 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas. * The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage. */ -import { ledAnchor, ledRuntime, ledStripsByMarker } from './led-strip-gate'; +import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate'; import { ledButton, ledEditorFor, ledHistory, ledImportNote, ledSection, ledWallsNote } from './led-strip-card'; import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit'; import { cache as litCache } from 'lit/directives/cache.js'; @@ -10509,7 +10509,8 @@ export class HouseplanCard extends LitElement { /** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */ private _ledRt(space: SpaceModel) { - return ledStripsByMarker(space).size ? ledRuntime(space.id, () => this.requestUpdate()) : null; + return ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers) + ? ledRuntime(space.id, () => this.requestUpdate()) : null; } /** #780: the Devices-editor LED tool, its own lazy chunk (src/led-strip-card.ts). */ @@ -10725,6 +10726,8 @@ export class HouseplanCard extends LitElement { const showGhosts = this._mode === 'devices' && this._showAll; const leds = ledStripsByMarker(space); const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts) && !leds.has(d.id)); + // #780 r1 M1: a strip keeps its value badge at the anchor; no icon, pulse or slot. + const ledDevs = leds.size ? this._renderDevices.filter((d) => d.space === space.id && !d.hidden && leds.has(d.id)) : []; const deviceSnapshot = this._renderDeviceSnapshot; const disp = this._spaceDisplayForRender(); const roomFills = this._resolvedRoomFills(space, disp); @@ -11208,6 +11211,7 @@ export class HouseplanCard extends LitElement { ${keyed(space.id, repeat(devs, (d) => d.id, (d) => this._renderDevice( d, view, showLqi, isoOverlays?.devices.get(d.id), )))} + ${ledDevs.map((d) => this._ledBadge(d, view, isoOverlays?.devices.get(d.id)))} ${this._renderVacuums(this._renderVacuumDevices, view, space.id)} ${this._renderVacFit(view)} ${this._renderOpeningLocks(view, isoOverlays?.locks)} @@ -11801,6 +11805,16 @@ export class HouseplanCard extends LitElement { ${pucks}`; } + /** #780 r1 M1: the value badge of a strip, passive, at its anchor (`_pos`). */ + private _ledBadge(d: DevItem, view: { x: number; y: number; w: number; h: number }, iso?: IsoOverlayPlacement) { + const presentation = this._devicePresentation(d, false); + const face = presentation.haDisabled ? nothing : renderDeviceShadowFace(presentation); + if (face === nothing) return nothing; + const pos = this._pos(d), point = iso?.visualScene ?? [pos.x, pos.y]; + return html``; + } + private _renderDevice( d: DevItem, view: { x: number; y: number; w: number; h: number }, diff --git a/src/led-strip-editor.ts b/src/led-strip-editor.ts index 73d9b90b..da46ff1b 100755 --- a/src/led-strip-editor.ts +++ b/src/led-strip-editor.ts @@ -161,7 +161,8 @@ function nearestOnSegment(p: Pt, a: Pt, b: Pt): Pt { export class LedStripEditor { /** The tool is armed: clean clicks add points. */ tool = false; - chain: { points: Pt[]; convert: string | null } | null = null; + /** The unfinished chain and the space it is drawn in (r1 M3: it is written there, never elsewhere). */ + chain: { points: Pt[]; convert: string | null; space: string } | null = null; hover: Pt | null = null; stopped = false; sel: string | null = null; @@ -258,7 +259,7 @@ export class LedStripEditor { this.tool = true; this.sel = null; this.picker = null; - this.chain = { points: [], convert }; + this.chain = { points: [], convert, space: space.id }; this.hover = null; this.stopped = false; this.host.requestUpdate(); @@ -364,7 +365,7 @@ export class LedStripEditor { private addPoint(e: PointerEvent): void { if (this.busy) return; const { bodies } = this.placement(); - const chain = this.chain || (this.chain = { points: [], convert: null }); + const chain = this.chain || (this.chain = { points: [], convert: null, space: this.host._spaceModel()?.id ?? '' }); const last = chain.points[chain.points.length - 1] || null; let p = this.snapPoint(this.rawPoint(e), e.shiftKey, last); if (!last) { @@ -404,15 +405,17 @@ export class LedStripEditor { /** Esc, a double click, a click on the first point, a tool or editor change. */ async finish(): Promise { const chain = this.chain; - const space = this.host._spaceModel(); this.chain = null; this.hover = null; if (this.tool && chain?.convert) this.tool = false; - if (!chain || !space) { this.host.requestUpdate(); return; } + // r1 M3: the chain belongs to the space it was drawn in — a space switch + // finishes it THERE (by id), never into the space shown now. + if (!chain?.space) { this.host.requestUpdate(); return; } const stored = compactPoints(chain.points).map(scaleOut); if (!validStripPoints(stored)) { // Fewer than two distinct points: nothing is written, nothing converted. - if (this.tool) this.chain = { points: [], convert: null }; + const here = this.host._spaceModel()?.id; + if (this.tool && here) this.chain = { points: [], convert: null, space: here }; this.host.requestUpdate(); return; } @@ -421,11 +424,15 @@ export class LedStripEditor { ? { id, points: stored, marker: chain.convert, active: true } : { id, points: stored, marker: null }; const ok = await this.write(this.t(chain.convert ? 'led.history_view' : 'led.history_draw'), - space.id, id, () => strip); + chain.space, id, () => strip); if (ok) { this.tool = false; - this.sel = id; - if (!chain.convert) this.picker = id; // bind now, or "Later" + // Selection and the device picker are session state of the space shown; + // after a switch the strip waits unbound in its own space (ТЗ §4 п.5, п.8). + if (this.host._spaceModel()?.id === chain.space) { + this.sel = id; + if (!chain.convert) this.picker = id; // bind now, or "Later" + } } this.host.requestUpdate(); } diff --git a/src/led-strip-gate.ts b/src/led-strip-gate.ts index b6e9dbe7..847548eb 100644 --- a/src/led-strip-gate.ts +++ b/src/led-strip-gate.ts @@ -4,9 +4,10 @@ * position, and one page-wide load of the lazy `led-strip-runtime` chunk. * Geometry, light and hit-testing live in the chunk. * - * A strip is represented when it is active and bound; the card additionally - * checks the marker is a live device of that space. `active: false` shapes, - * unbound strips and an empty array never ask for the chunk. A failed load is + * A strip is represented when it is active and bound; the chunk loads only + * when such a strip is drawn — a live, visible device of that space + * (`ledVisible`). `active: false` shapes, unbound strips, hidden or + * HA-disabled markers and an empty array never ask for the chunk. A failed load is * fail-dark for the strips only and is retried on the next explicit entry * (another space or the LED tool) — never in a loop; a chunk of another build * is never installed. @@ -48,6 +49,25 @@ export function ledStripsByMarker(space: SpaceModel | null | undefined): Map