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
This commit is contained in:
Matysh
2026-10-03 23:54:21 +03:00
parent 24e48935c3
commit 181b61ae8e
11 changed files with 156 additions and 32 deletions
+122 -14
View File
@@ -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"]');
+4 -1
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- Ядро включённой LED-ленты внутри обводки теперь показывает цвет её свечения
и при включённом Glow; выключенная лента остаётся белой
([#790](https://github.com/Matysh/houseplan-card/issues/790)).
## v1.79.0 — 2026-10-03
- LED-ленты теперь могут заменять значки устройств: нарисуйте и отредактируйте
+5 -3
View File
@@ -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);
+1
View File
@@ -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`. |
+4 -3
View File
@@ -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.
+4 -3
View File
@@ -1155,9 +1155,10 @@ binding tombstone не вырезает её из живого устройст
Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же
настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится
устройство: при включённом Glow — белое ядро и мягкая цветная полоса света
радиусом 30 см (или персональный радиус устройства), без Glow — ядро цвета
источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента
устройство: ядро внутри обводки имеет выбранный цвет свечения независимо от
Glow. При включённом Glow добавляется мягкая полоса света радиусом 30 см
(или персональный радиус устройства). Выключенная лента остаётся белой.
Свет не проходит сквозь стены и закрытые двери. В просмотре лента
— одна цель нажатия по всей длине, как значок. Каждое законченное изменение
формы и смена представления — один шаг **Отменить/Повторить**.
@@ -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`
+4 -4
View File
@@ -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;",
}],
},
{
+3 -3
View File
@@ -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;