From 181b61ae8e6adc967dde8c2a87799152ec777956 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 3 Oct 2026 23:54:21 +0300 Subject: [PATCH] fix(led): colour the active core from its resolved light Keep off and unavailable presentation unchanged. Extend the rendered colour and surface matrix and retain the independent white-source geometry oracle. Issue: #790 User-Visible: yes --- demo/smoke_led_strip_glow.mjs | 136 ++++++++++++++++-- demo/smoke_led_strip_tube.mjs | 5 +- docs/CHANGELOG.md | 4 + docs/CHANGELOG.ru.md | 4 + docs/LIGHT.md | 8 +- docs/STATUS.md | 1 + docs/USER-GUIDE.md | 7 +- docs/USER-GUIDE.ru.md | 7 +- docs/testing-notes/mutation-browser-guards.md | 2 +- scripts/mutation-registry.mjs | 8 +- src/led-strip-runtime.ts | 6 +- 11 files changed, 156 insertions(+), 32 deletions(-) diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs index 8ca13a39..94640573 100644 --- a/demo/smoke_led_strip_glow.mjs +++ b/demo/smoke_led_strip_glow.mjs @@ -3,8 +3,8 @@ * the View and on the static card. No LED chunk without a displayed active * strip (a hidden shape or an unbound strip loads nothing); an active bound * 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 + * linear field; off/unavailable have no field, unavailable is dashed. The on + * core takes the source colour with or without Glow. The whole length is one target: a * 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 @@ -12,6 +12,8 @@ */ import { launch, check, finish } from './serve.mjs'; import { installHpTestOnPage } from './helpers/hp-test.mjs'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; const { page, browser } = await launch({ width: 1000, height: 820 }, 1); const ledRequests = []; @@ -48,6 +50,18 @@ const stripe = (marker) => evaluate((marker) => { coreWidth: Number(g.querySelector('.led-core')?.getAttribute('stroke-width')) } : null; }, marker); const field = () => evaluate(() => window.__card.shadowRoot.querySelectorAll('[data-led-field]').length); +const painted = () => evaluate(() => { + const root = window.__card.shadowRoot; + const core = root.querySelector('[data-marker="d_light1"] .led-core'); + const pool = root.querySelector('[data-led-field="ceiling"] .led-pool'); + return { core: core?.getAttribute('stroke'), field: pool?.getAttribute('fill'), + coreOpacity: core ? getComputedStyle(core).strokeOpacity : null, + fieldOpacity: Number(pool?.getAttribute('fill-opacity')), + geometry: ['.led-outline', '.led-core', '.led-hit'].map(selector => { + const path = root.querySelector(`[data-marker="d_light1"] ${selector}`); + return [path?.getAttribute('d'), path?.getAttribute('stroke-width')]; + }) }; +}); await settle(); check('no strips: no LED chunk requested', ledRequests.length, 0); @@ -82,9 +96,68 @@ check('unbound strip is absent from the View', await evaluate(() => window.__car check('no round pool at the anchor: the strip is the source', await evaluate(() => !window.__card.shadowRoot.querySelector('[data-glow-source="light.ceiling"]'))); check('no auto-slot reserved for the strip’s marker', await evaluate(() => !('d_light1' in (window.__card._defPos || {})))); +await setState('light.ceiling', 'on', { rgb_color: [128, 213, 255], brightness: 64 }); +await settle(); const onStripe = await stripe('d_light1'); -check('on with Glow: white core and a field', JSON.stringify([onStripe?.state, onStripe?.core, onStripe?.dash, await field() > 0]), - JSON.stringify(['on', '#FFFFFF', null, true])); +check('on with Glow: source-coloured core and a field', [onStripe?.state, onStripe?.core, onStripe?.dash, await field() > 0], + ['on', '#80d5ff', null, true]); +const firstPaint = await painted(); +check('#790: rendered core and field share the resolved RGB colour', [firstPaint.core, firstPaint.field], ['#80d5ff', '#80d5ff']); +await setState('light.ceiling', 'on', { rgb_color: [225, 70, 35] }); +await settle(); +const rgbPaint = await painted(); +check('#790: HA RGB update changes the existing core and field', [rgbPaint.core, rgbPaint.field], ['#e14623', '#e14623']); +check('#790: a colour update changes no outline, core or hit geometry', rgbPaint.geometry, firstPaint.geometry); +await setState('light.ceiling', 'on', { rgb_color: null, color_temp_kelvin: 3000 }); +await settle(); +const warmPaint = await painted(); +check('#790: colour temperature uses the same resolved field colour', warmPaint.core === warmPaint.field + && warmPaint.core !== '#FFFFFF' && warmPaint.core !== rgbPaint.core); +await setStrips(ceilingStrip, { glow_enabled: true }, { d_light1: { glow_color: { c: '#d837a6', bri: 0.2 } } }); +await settle(); +const manualPaint = await painted(); +check('#790: saved manual colour updates core and field', [manualPaint.core, manualPaint.field], ['#d837a6', '#d837a6']); +check('#790: field brightness never becomes core opacity', manualPaint.coreOpacity === '1' && manualPaint.fieldOpacity < 1); +check('#790: manual colour changes no physical geometry', manualPaint.geometry, firstPaint.geometry); + +// AC3: the same public HA/config path in both themes, Flat/2.5D and on/off. +// Optional local review captures are not golden baselines or release files. +const captureDir = process.env.HP_LED_COLOR_SCREENSHOTS; +if (captureDir) await mkdir(captureDir, { recursive: true }); +await setStrips(ceilingStrip, { glow_enabled: true }); +for (const dark of [false, true]) for (const iso of [false, true]) { + await evaluate(async dark => { + const card = window.__card; + card.hass = { ...card.hass, themes: { ...(card.hass.themes || {}), darkMode: dark } }; + document.documentElement.style.colorScheme = dark ? 'dark' : 'light'; + await window.__hpTest.settled(); + }, dark); + await evaluate(iso => window.__hpTest.setVolumetricView(iso), iso); + let onGeometry; + for (const state of ['on', 'off']) { + await setState('light.ceiling', state, { rgb_color: [128, 213, 255] }); + await page.waitForTimeout(600); + const actual = await painted(); + const label = `#790: ${dark ? 'dark' : 'light'}, ${iso ? '2.5D' : 'Flat'}, ${state}`; + check(`${label}: core colour`, actual.core, state === 'on' ? '#80d5ff' : '#FFFFFF'); + if (state === 'on') onGeometry = actual.geometry; + else check(`${label}: geometry stays unchanged`, actual.geometry, onGeometry); + if (captureDir) { + const box = await page.locator('[data-led-strip="ceiling"] .led-outline').boundingBox(); + const x = Math.max(0, box.x - 30), y = Math.max(0, box.y - 30); + await page.screenshot({ path: join(captureDir, `${dark ? 'dark' : 'light'}-${iso ? 'iso' : 'flat'}-${state}.png`), + clip: { x, y, width: Math.min(1000 - x, box.width + 60), height: Math.min(820 - y, box.height + 60) } }); + } + } +} +await evaluate(async () => { + const card = window.__card; + card.hass = { ...card.hass, themes: { ...(card.hass.themes || {}), darkMode: false } }; + document.documentElement.style.colorScheme = 'light'; + await window.__hpTest.setVolumetricView(false); +}); +await setState('light.ceiling', 'on', { rgb_color: [128, 213, 255] }); +await settle(); // 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. @@ -186,8 +259,13 @@ await settle(); await setState('light.ceiling', 'unavailable'); await settle(); const unavailable = await stripe('d_light1'); -check('unavailable: dashed, no field', JSON.stringify([unavailable?.state, !!unavailable?.dash, await field()]), - JSON.stringify(['unavailable', true, 0])); +check('unavailable: dashed grey, no field', [unavailable?.state, unavailable?.core, !!unavailable?.dash, await field()], + ['unavailable', '#9e9e9e', true, 0]); +await setState('light.ceiling', 'unknown'); +await settle(); +const unknown = await stripe('d_light1'); +check('#790: unknown keeps the grey dashed unavailable presentation', + [unknown?.state, unknown?.core, !!unknown?.dash, await field()], ['unavailable', '#9e9e9e', true, 0]); await setState('light.ceiling', 'on', { rgb_color: [128, 213, 255] }); await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: false }); await settle(); @@ -197,8 +275,23 @@ await settle(); // finish before asserting the stable no-Glow contract. await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('[data-led-field]')); 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])); +check('on without Glow: source-coloured core, no field', [colored?.state, colored?.core, await field()], + ['on', '#80d5ff', 0]); +await evaluate(async () => { + await window.__hpTest.setServerConfig(cfg => { + cfg.spaces[0].settings.glow_enabled = true; + const room = cfg.spaces[0].rooms.find(room => room.id === 'r1'); + room.settings = { ...(room.settings || {}), glow: false }; + }); +}); +await settle(); +check('#790: room Glow override changes no live core colour', + [(await stripe('d_light1'))?.core, await field()], ['#80d5ff', 0]); +await evaluate(async () => { + await window.__hpTest.setServerConfig(cfg => { + cfg.spaces[0].rooms.find(room => room.id === 'r1').settings.glow = null; + }); +}); // 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. @@ -235,7 +328,7 @@ const matrix = await evaluate(async () => { target?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); } out[key] = { - state: g?.dataset.state, white: core?.getAttribute('stroke') === '#FFFFFF', + state: g?.dataset.state, core: core?.getAttribute('stroke'), 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'), @@ -245,16 +338,31 @@ const matrix = await evaluate(async () => { return out; }); 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 })); + JSON.stringify({ state: 'off', core: '#FFFFFF', 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 })); + JSON.stringify({ state: 'on', core: '#80d5ff', 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 })); + JSON.stringify({ state: 'off', core: '#FFFFFF', field: false, passive: true, badge: true })); +check('static live_states:true + light_pools:true — source colour and the field, passive', JSON.stringify(matrix['pools=true,live=true']), + JSON.stringify({ state: 'on', core: '#80d5ff', 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'); +for (const mode of ['plan', 'decor']) { + await evaluate(mode => window.__hpTest.setMode(mode), mode); + check(`#790: ${mode} keeps the coloured core passive and translucent`, await evaluate(() => { + const root = window.__card.shadowRoot; + return [root.querySelector('[data-marker="d_light1"] .led-core')?.getAttribute('stroke'), + root.querySelector('.led-passive')?.getAttribute('opacity'), !!root.querySelector('.led-hit')]; + }), ['#80d5ff', '0.45', false]); +} await evaluate(() => window.__hpTest.setMode('devices')); +check('#790: Devices retains the live core colour', (await stripe('d_light1'))?.core, '#80d5ff'); +await setStrips([...ceilingStrip, { id: 'loose', points: [[0.12, 0.45], [0.30, 0.45]], marker: null }], + { glow_enabled: true }); +check('#790: unbound Devices strip keeps its grey dashed presentation', await evaluate(() => { + const path = window.__card.shadowRoot.querySelector('[data-led-unbound="loose"]'); + return [path?.getAttribute('stroke'), !!path?.getAttribute('stroke-dasharray')]; +}), ['#8a8a8a', true]); await page.locator('.led-select-hit[data-led-select="ceiling"]').dispatchEvent('click'); await page.waitForTimeout(100); await page.click('[data-led-action="settings"]'); diff --git a/demo/smoke_led_strip_tube.mjs b/demo/smoke_led_strip_tube.mjs index fe0233f0..b35e37db 100644 --- a/demo/smoke_led_strip_tube.mjs +++ b/demo/smoke_led_strip_tube.mjs @@ -89,8 +89,11 @@ for (const scenario of scenarios) { // Public HA state input, not a write to the card's private resolver. await page.evaluate(async state => { const card = window.__card, previous = card.hass.states['light.ceiling']; + // This geometry-only oracle deliberately uses a white source in + // both states; source-colour behaviour is held by the Glow smoke. card.hass = { ...card.hass, states: { ...card.hass.states, - 'light.ceiling': { ...previous, state } } }; + 'light.ceiling': { ...previous, state, + attributes: { ...previous.attributes, rgb_color: [255, 255, 255] } } } }; await window.__hpTest.settled(); }, state); await page.waitForFunction(state => window.__card.shadowRoot diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a58a7e0..1123c0e0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- An on LED strip now shows its light colour inside the outline even when + Glow is enabled; off strips stay white + ([#790](https://github.com/Matysh/houseplan-card/issues/790)). + ## v1.79.0 — 2026-10-03 - LED strips can now replace device icons: draw and edit their shape in the diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 8858fc02..1bbb19e0 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,10 @@ ## Не выпущено +- Ядро включённой LED-ленты внутри обводки теперь показывает цвет её свечения + и при включённом Glow; выключенная лента остаётся белой + ([#790](https://github.com/Matysh/houseplan-card/issues/790)). + ## v1.79.0 — 2026-10-03 - LED-ленты теперь могут заменять значки устройств: нарисуйте и отредактируйте diff --git a/docs/LIGHT.md b/docs/LIGHT.md index ba82d9ec..6ab6d043 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -459,9 +459,11 @@ only the geometry differs: the saved points. Unsafe acute angles retain the short connector. A stored four-corner loop stays four-cornered without steps or protruding hooks; a genuinely tilted side stays tilted (#787, #788). -- **Core.** With effective Glow (space `glow_enabled` + room `glow`) the core - stays white and the colour is the field; without Glow the core takes the - source colour and there is no field. Off: white core, no field. +- **Core.** An on strip uses the same resolved source colour as its field, + whether effective Glow (space `glow_enabled` + room `glow`) is on or off. + RGB, colour temperature and manual colour changes update both through the + shared light resolver; field brightness does not change core opacity. + Without Glow there is no field. Off: white core, no field. Unavailable/unknown: dashed grey stripe, no field — the link is kept. - **Surfaces.** The full card renders field and stripe in the View; the Devices editor shows active strips (unbound ones as grey dashes); diff --git a/docs/STATUS.md b/docs/STATUS.md index 46ba207d..469f0609 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -30,6 +30,7 @@ Everything computable from the tree and git; regenerate, never edit by hand | Current local cycle | **Stable v1.79.0 candidate** — promotion of published beta.7, with no new feature source. The line adds LED strips and the moon on any background, stabilizes 2.5D layout, and improves room-fill transitions and warm remount. Exact-SHA full gates precede publication; `main` is promoted only to the tested candidate. | | Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. | | LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. | +| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. | | Warm remount | Delayed editor restoration preserves the original View camera, reserves pending chrome space, and yields synchronously to mode/space navigation (#762). Header/stage dimensions are published as one settled pair. Canonical: `docs/WARM-REMOUNT.md`. | | 2.5D View | Public since #649: the installation-wide General settings switch `settings.volumetric_view` (Display). Flat stays the default and byte-for-byte unchanged; editors and `houseplan-space-card` stay Flat. Canonical: `docs/ISOMETRIC.md`. | | Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch; the three editors are desktop-first, touch editing is best effort. Canonical: `docs/TOUCH-SUPPORT.md`. | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index f837e883..165622db 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1058,9 +1058,10 @@ editor** — there is no need to switch to the Plan editor. | 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 — 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 30 cm wide by default (or the -device's own radius); without Glow the core takes the light's colour. Light +settings, tap actions and badge — drawn at the middle of the strip's length. +When the device is on, the core inside its outline uses the selected light colour, with or without +Glow. Glow adds a soft band of colour with a 30 cm radius by default (or the +device's own radius). An off strip stays white. Light does not pass through walls or closed doors. In View the whole strip is one tap target, like an icon. Every finished shape change and every switch of the representation is one **Undo/Redo** step. diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index c8732c45..5810a214 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1155,9 +1155,10 @@ binding tombstone не вырезает её из живого устройст Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится -устройство: при включённом Glow — белое ядро и мягкая цветная полоса света -радиусом 30 см (или персональный радиус устройства), без Glow — ядро цвета -источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента +устройство: ядро внутри обводки имеет выбранный цвет свечения независимо от +Glow. При включённом Glow добавляется мягкая полоса света радиусом 30 см +(или персональный радиус устройства). Выключенная лента остаётся белой. +Свет не проходит сквозь стены и закрытые двери. В просмотре лента — одна цель нажатия по всей длине, как значок. Каждое законченное изменение формы и смена представления — один шаг **Отменить/Повторить**. diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 43a6cf83..21656911 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -92,7 +92,7 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p - `iso-sun-flat-wedges-remain` - `iso-theme-dark-wall-rule-returns` - `led-badge-dropped` -- `led-core-coloured-under-glow` +- `led-core-white-under-glow` - `led-field-endpoint-dropped` - `led-field-disc-cancels-fan` - `led-field-compound-clip-children` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 0c7980c5..af3b6fa2 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -14276,13 +14276,13 @@ const MUTANT_DEFINITIONS = [ }], }, { - id: 'led-core-coloured-under-glow', + id: 'led-core-white-under-glow', guard: 'node demo/smoke_led_strip_glow.mjs', - because: "#780 ТЗ §3: with Glow the core stays white and the colour is the field; only without Glow the core takes the source colour", + because: '#790: the actual SVG core must match the resolved field colour across live HA/config updates, with or without Glow; this needs the rendered DOM and its update lifecycle', patches: [{ file: 'src/led-strip-runtime.ts', - find: ": view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE;", - replace: ": view.state === 'on' && view.appearance ? view.appearance.c : CORE_IDLE;", + find: ": view.state === 'on' && view.appearance ? view.appearance.c : CORE_IDLE;", + replace: ": view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE;", }], }, { diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index b011451d..76895886 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -136,8 +136,8 @@ export function faceContext(scene: LightBarrierScene | null, epsilon: number): F /** * The state of one strip from the card's own light resolution (ТЗ §3, §5): * an unavailable source is a grey dashed stripe without a field — never an - * unbinding; on with Glow is a white core and a field; on without Glow is a - * core in the source colour; off is a white core. + * unbinding; on uses the source colour for the core, with a field when Glow + * is enabled; off is a white core. */ export function ledStripView(input: { strip: LedStripModel; @@ -293,7 +293,7 @@ export function renderLedStripes(input: LedStripeInput): TemplateResult { const { t, path, d } = ledStripePath(view.strip, input.faces, input.d); const unavailable = view.state === 'unavailable'; const core = unavailable ? UNAVAILABLE - : view.state === 'on' && !view.glow && view.appearance ? view.appearance.c : CORE_IDLE; + : view.state === 'on' && view.appearance ? view.appearance.c : CORE_IDLE; const hitWidth = (2 * stripHitRadiusPx(t * input.perUnit)) / (input.perUnit || 1); const dev = view.device; const own = (e: MouseEvent) => nearestOwner(e, view, input).device;