fix(led): polish glow and interactions (#784)

Issue: #784
User-Visible: yes
This commit is contained in:
Matysh
2026-10-03 10:47:28 +03:00
parent 4962de1d5c
commit 706eb02d70
21 changed files with 243 additions and 72 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
* (50 strips × 50 points); `--size=none` is the same build and plan without
* strips. No icon is added: the converted devices keep their ids, only their
* entity becomes a light. Light radius 50 cm (the strip default), Glow on,
* entity becomes a light. Light radius 30 cm (the strip default), Glow on,
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
* browser with an empty cache mounts a new card; the warm metrics run in the
+1 -2
View File
@@ -432,7 +432,7 @@ measures the LED strips on the `large-house-v1` fixture: on every floor 10 or 50
existing devices become lights shown as strips that are on — `10x5` (10 strips
× 5 points) or `50x50` — without adding a device or an icon
(`performance/led-strips-fixture.mjs`); `none` is the same build and plan
without strips. Glow on, strip radius 50 cm, viewport 1440×1000, DPR 1,
without strips. Glow on, strip radius 30 cm, viewport 1440×1000, DPR 1,
reduced motion; every sample is cold (a new browser and card), seven samples
after one warm-up. The runner fails on its own against
`budgets-led-strips.json` — the ТЗ table, median and p95: `firstStableRenderMs`
@@ -456,4 +456,3 @@ strips, so there is no relative comparison; the zero-LED View remains judged
by the relative `large-house-interaction-v1` profile. Exact-SHA Linux output
of the full performance workflow (`led-strips` matrix entry) is the gate
evidence; a local report is diagnostic.
+35
View File
@@ -84,7 +84,42 @@ const first = await strips();
check('Esc finished one unbound strip', first.length === 1 && first[0].marker === null
&& JSON.stringify(first[0].points) === JSON.stringify([[0.2, 0.2], [0.4, 0.2]]));
check('the device picker opened', await evaluate(() => !!window.__card.shadowRoot.querySelector('hp-dialog[data-kind="led-picker"]')));
await page.setViewportSize({ width: 1000, height: 240 });
await page.waitForTimeout(100);
// Keep the demo registry deterministic for every smoke. A long picker is a
// presentation condition, so clone the product-rendered row in this isolated
// page instead of mutating the authoritative HA registry snapshot.
await evaluate(() => {
const list = window.__card.shadowRoot.querySelector('.led-picker');
const template = list.querySelector('.led-pick');
for (let i = 0; i < 18; i += 1) {
const row = template?.cloneNode(true) || document.createElement('button');
row.className = 'btn ghost led-pick';
row.removeAttribute('data-led-pick');
row.disabled = true;
row.textContent = `Picker light ${i + 1}`;
list.append(row);
}
});
const pickerBefore = await evaluate(() => {
const list = window.__card.shadowRoot.querySelector('.led-picker');
const rect = list.getBoundingClientRect();
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height,
scrollTop: list.scrollTop, scrollHeight: list.scrollHeight, clientHeight: list.clientHeight,
zoom: window.__card._zoom };
});
check('the device picker owns a bounded scrolling list', pickerBefore.scrollHeight > pickerBefore.clientHeight, true);
await page.mouse.move(pickerBefore.x + pickerBefore.w / 2, pickerBefore.y + pickerBefore.h / 2);
await page.mouse.wheel(0, 260);
await page.waitForTimeout(250);
const pickerAfter = await evaluate(() => ({
scrollTop: window.__card.shadowRoot.querySelector('.led-picker').scrollTop,
zoom: window.__card._zoom,
}));
check('wheel scrolls the device picker', pickerAfter.scrollTop > pickerBefore.scrollTop, true);
check('wheel over the picker does not zoom the plan', pickerAfter.zoom, pickerBefore.zoom);
await page.click('[data-led-action="later"]');
await page.setViewportSize({ width: 1000, height: 820 });
await page.waitForTimeout(200);
check('Later keeps the unbound strip, selected with its tray', await evaluate(() => {
const c = window.__card;
+36 -2
View File
@@ -129,8 +129,17 @@ const calls = await evaluate(async () => {
const hit = await evaluate(() => { const r = window.__card.shadowRoot.querySelector('.led-hit').getBoundingClientRect(); return [r.x + r.width * 0.25, r.y + 2]; });
const box = await evaluate(() => { const p = window.__card.shadowRoot.querySelector('.led-hit'); const len = p.getTotalLength(); const pt = p.getPointAtLength(len * 0.3); const m = p.getScreenCTM(); return [pt.x * m.a + m.e, pt.y * m.d + m.f]; });
await page.mouse.click(box[0], box[1]);
await settle();
await page.waitForFunction(() => window.__card.hass.states['light.ceiling'].state === 'off');
check('a click on the stripe toggles once', await evaluate(() => window.__ledToggle.length), calls + 1);
check('turning off keeps the field for the fade', await evaluate(() =>
window.__card.shadowRoot.querySelector('[data-led-field]')?.getAttribute('data-led-phase')), 'leaving');
check('a pointer click leaves no selection outline', await evaluate(() => {
const root = window.__card.shadowRoot;
const target = root.querySelector('.led-hit');
return !target.matches(':focus-visible')
&& getComputedStyle(root.querySelector('.led-focus')).stroke === 'rgba(0, 0, 0, 0)';
}), true);
await page.waitForTimeout(600);
void hit;
check('off: no field, white core', JSON.stringify([await stripe('d_light1'), await field()]),
JSON.stringify([{ state: 'off', core: '#FFFFFF', dash: null }, 0]));
@@ -152,7 +161,15 @@ check('a pan along the stripe calls no service', await ceiling(), 'off');
const [x, y] = await along(0.6);
await page.mouse.click(x, y);
}
await settle();
await page.waitForFunction(() => window.__card.hass.states['light.ceiling'].state === 'on');
const fadeInOpacity = await evaluate(() => Number(getComputedStyle(
window.__card.shadowRoot.querySelector('[data-led-field]'),
).opacity));
check('turning on starts below full opacity', fadeInOpacity < 0.95, true);
await page.waitForTimeout(600);
check('turning on reaches full opacity smoothly', await evaluate(() => Number(getComputedStyle(
window.__card.shadowRoot.querySelector('[data-led-field]'),
).opacity) > 0.99), true);
check('the next clean click works at once', await ceiling(), 'on');
{
const [x, y] = await along(0.6);
@@ -225,6 +242,23 @@ check('static live_states:true + light_pools:true — white core and the field,
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');
await evaluate(() => window.__hpTest.setMode('devices'));
await page.locator('.led-select-hit[data-led-select="ceiling"]').dispatchEvent('click');
await page.waitForTimeout(100);
await page.click('[data-led-action="settings"]');
await page.waitForTimeout(100);
const radiusFocus = await evaluate(() => {
const input = window.__card.shadowRoot.querySelector('#marker-glow-radius');
const unit = input?.closest('.hpf-unit');
input?.focus();
return input && unit ? {
input: getComputedStyle(input).outlineStyle,
wrapper: getComputedStyle(unit).outlineStyle,
} : null;
});
check('the Glow radius has one wrapper focus outline', JSON.stringify(radiusFocus),
JSON.stringify({ input: 'none', wrapper: 'solid' }));
// 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
// drawing for this same marker after the dialog's own close path.
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- LED strips now cast a smooth, continuous Glow field without gradient bands
or cuts at corners and fade in and out instead of switching abruptly. Their
default Glow radius is now 30 cm. The device picker scrolls without zooming
the plan, the radius field has one focus outline, and clicking a strip no
longer leaves a selection outline while keyboard focus remains visible
([#784](https://github.com/Matysh/houseplan-card/issues/784)).
## v1.79.0-beta.3 — 2026-10-03
- LED strips: in the Devices editor, the new «LED strip» tool next to «Add»
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- LED-ленты теперь дают мягкое непрерывное свечение без полос градиента и
срезов на углах и плавно загораются и гаснут. Радиус свечения по умолчанию
изменён на 30 см. Список выбора устройства прокручивается, не меняя масштаб
плана; у поля радиуса осталась одна обводка фокуса, а клик по ленте больше не
оставляет обводку выделения — видимый клавиатурный фокус сохранён
([#784](https://github.com/Matysh/houseplan-card/issues/784)).
## v1.79.0-beta.3 — 2026-10-03
- LED-ленты: в редакторе устройств рядом с «Добавить» появился инструмент
+1 -1
View File
@@ -418,7 +418,7 @@ its anchor. Colour, brightness, role and availability come from the same
`resolveGlowCandidates` / `resolveGlowAppearance` path as every other source;
only the geometry differs:
- **Radius.** 50 cm by default, independent of `settings.glow_radius_cm`; the
- **Radius.** 30 cm by default, independent of `settings.glow_radius_cm`; the
marker's personal `glow_radius_cm` wins.
- **Field.** A continuous band along every segment with round free ends: grey
luminance bands of the shared `GLOW_FALLOFF`, one piece per stretch, blended
+1 -1
View File
@@ -1059,7 +1059,7 @@ editor** — there is no need to switch to the Plan editor.
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 50 cm wide by default (or the
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
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
+1 -1
View File
@@ -1156,7 +1156,7 @@ binding tombstone не вырезает её из живого устройст
Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же
настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится
устройство: при включённом Glow — белое ядро и мягкая цветная полоса света
радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета
радиусом 30 см (или персональный радиус устройства), без Glow — ядро цвета
источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента
— одна цель нажатия по всей длине, как значок. Каждое законченное изменение
формы и смена представления — один шаг **Отменить/Повторить**.
+2 -2
View File
@@ -43,7 +43,7 @@ behaviours, not of positions.
| On with Glow: white core + coloured field; without Glow: core in the source colour, no field | `ledStripView` + `resolveGlowAppearance` | smoke `smoke_led_strip_glow.mjs` |
| Glow is the space/room switch, independent of `fill_mode` | `glowFor(room)` | `lighting-led-strip-glow-dark` uses `fill_mode: none` |
| Per-piece offset: t/2 on a thick face into free floor, 0 on free floor and zero walls; continuous transition | `visibleStripPath` | unit `test/led-strip-geometry.test.mjs` (AC8) |
| Field 50 cm by default, own `glow_radius_cm` wins; round free ends; no seams or doubled brightness at corners/closure | `ledFrame`, `led-strip-field` (`lighten` in one group) | unit `test/led-strip-runtime.test.mjs`; reference pair |
| Field 30 cm by default, own `glow_radius_cm` wins; round free ends; no seams, bands or doubled brightness at corners/closure | `ledFrame`, one continuous path through the unioned visibility clip in `led-strip-field` | unit `test/led-strip-runtime.test.mjs`; reference pair |
| Shared `glowAlpha` / `GLOW_FALLOFF` / `GLOW_FADE_MS` | field bands from `falloffAt` | unit `test/led-strip-runtime.test.mjs` |
| Field under icons, badges and labels; icons not tinted | glow layer below the device layer | reference pair (designer tinting deliberately not reproduced) |
@@ -69,4 +69,4 @@ behaviours, not of positions.
the default `icon_size` and the band edge is the shared Glow edge.
- Icons and labels are not tinted by the field.
- The mockup's "half the shared radius" and "always coloured core" are replaced
by 50 cm and the white core under Glow (owner's decision in #780).
by 30 cm (#784) and the white core under Glow (owner's decision in #780).
+1 -1
View File
@@ -11,7 +11,7 @@ with the frames Led On (626:35) and Led Off (626:53).
**The contract is the ТЗ in the body of
[#780](https://github.com/Matysh/houseplan-card/issues/780)**, not the archived
`source/TZ-issue-662-LED-strips.md`. Where they differ the issue wins — notably
the 50 cm default radius, the white core under Glow, editing in the Devices
the 30 cm default radius (#784), the white core under Glow, editing in the Devices
editor and the product thicknesses 0.08/0.12 D instead of the mockup's pixel
sizes and blur. These files are documentation only; nothing here reaches the
runtime bundle.
+2 -2
View File
@@ -14268,10 +14268,10 @@ const MUTANT_DEFINITIONS = [
id: 'led-default-radius-shared',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/led-strip-runtime.test.mjs',
because: "#780 ТЗ §3: the field radius is 50 cm by default, independent of the shared radius of ordinary sources",
because: "#784: the field radius is 30 cm by default, independent of the shared radius of ordinary sources",
patches: [{
file: 'src/led-strip-geometry.ts',
find: "export const LED_DEFAULT_RADIUS_CM = 50;",
find: "export const LED_DEFAULT_RADIUS_CM = 30;",
replace: "export const LED_DEFAULT_RADIUS_CM = 360;",
}],
},
+4 -2
View File
@@ -783,8 +783,10 @@ export class LedStripEditor {
.sort((a, b) => Number(isLight(b)) - Number(isLight(a)) || a.name.localeCompare(b.name));
const close = () => { this.picker = null; this.host.requestUpdate(); };
return html`<hp-dialog .hass=${this.host.hass} data-kind="led-picker" .title=${this.t('led.pick_title')}
icon="mdi:led-strip-variant" @hp-close=${close}>
<div class="body led-picker" style="display:flex;flex-direction:column;gap:4px">
icon="mdi:led-strip-variant" flex-content @wheel=${(e: WheelEvent) => e.stopPropagation()}
@hp-close=${close}>
<div class="body led-picker"
style="display:flex;flex-direction:column;gap:4px;min-height:0;max-height:min(60dvh,520px);overflow-y:auto;overscroll-behavior:contain">
${devices.length ? devices.map((d) => {
const owner = ownerOf(this.host._serverCfg, d.id);
const taken = !!owner && owner.strip.id !== stripId;
+97 -35
View File
@@ -5,19 +5,19 @@
* card has a light scene — the static card with `light_pools: false` never
* loads it (ТЗ §13.1).
*
* The field is the exact distance field of the strip with the shared falloff:
* every piece of the strip paints opaque grey bands of a luminance mask (round
* caps and joins, so one piece never doubles itself), pieces meet through
* `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece — and each
* piece is clipped to the floor its own emitters can see. A hidden part never
* lights through another part's visibility, a closed strip has no seam and a
* corner no double brightness.
* The field is the distance field of one continuous strip path with the shared
* falloff. Visibility remains sampled in bounded pieces, but their visible
* regions are unioned into one clip before the continuous path is painted.
* That separation keeps walls opaque without exposing piece boundaries in the
* gradient at straight cuts or corners.
*/
import { noChange, nothing, svg, type TemplateResult } from 'lit';
import { Directive, directive, type PartInfo } from 'lit/directive.js';
import { repeat } from 'lit/directives/repeat.js';
import {
GLOW_FALLOFF, type LightBarrierScene, type LightRoomPolygon,
createGlowRuntimeState, disposeGlowRuntime, forgetGlowSpace, GLOW_FALLOFF,
pruneGlowSources, transitionGlowSource, type GlowRuntimeHost,
type GlowRuntimeState, type LightBarrierScene, type LightRoomPolygon,
} from './glow-scene';
import { visibilityPolygon } from './light-visibility';
import {
@@ -45,11 +45,11 @@ const memo = directive(Memo);
export const LED_FIELD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
/**
* Bands of the luminance field. Twelve keep the midpoint error at the r/2
* visual acceptance point below 10%, without multiplying every visibility
* piece into sixteen SVG paint nodes.
* Bands of the luminance field. The field is painted once per strip rather
* than once per visibility piece, so 48 steps remain bounded while keeping
* neighbouring alpha levels below the threshold that showed as rings.
*/
export const LED_FIELD_BANDS = 12;
export const LED_FIELD_BANDS = 48;
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
@@ -60,6 +60,8 @@ interface FieldPiece {
}
interface FieldGeometry {
/** One continuous path; never split at visibility/cache boundaries. */
d: string;
pieces: FieldPiece[];
box: { x: number; y: number; w: number; h: number };
}
@@ -184,6 +186,9 @@ export function buildFieldGeometry(input: {
const path = compactPoints(pts(input.points));
if (path.length < 2 || !(input.radius > 0)) return null;
const r = input.radius;
const closed = isClosedStrip(path);
const visiblePath = closed ? path.slice(0, -1) : path;
const d = `${visiblePath.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')}${closed ? ' Z' : ''}`;
// Consecutive pieces of at most r along the polyline.
const runs: Pt[][] = [];
let run: Pt[] = [path[0]];
@@ -218,14 +223,14 @@ export function buildFieldGeometry(input: {
let clip: string[] | null;
try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
if (clip && !clip.length) continue;
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' '), clip });
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' '), clip });
for (const p of piece) {
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
}
}
if (!pieces.length) return null;
return { pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
return { d, pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
}
/** The shared falloff at a relative distance 0…1 (GLOW_FALLOFF, linear between stops). */
@@ -252,13 +257,61 @@ export interface LedFieldInput {
spaceId: string;
/** The card that owns the bounded cache (one per card, gone with it). */
owner: object;
requestUpdate: () => void;
isConnected: () => boolean;
reducedMotion?: () => boolean;
}
const fieldCaches = new WeakMap<object, LedFieldCache>();
interface FieldLifecycle {
state: GlowRuntimeState;
host: GlowRuntimeHost;
callbacks: Pick<LedFieldInput, 'requestUpdate' | 'isConnected' | 'reducedMotion'>;
spaceId: string;
}
const fieldLifecycles = new WeakMap<object, FieldLifecycle>();
function fieldLifecycle(input: LedFieldInput): FieldLifecycle {
let lifecycle = fieldLifecycles.get(input.owner);
if (!lifecycle) {
const callbacks = {
requestUpdate: input.requestUpdate,
isConnected: input.isConnected,
reducedMotion: input.reducedMotion,
};
const host: GlowRuntimeHost = {
window: () => window,
isConnected: () => callbacks.isConnected(),
requestUpdate: () => callbacks.requestUpdate(),
reducedMotion: () => callbacks.reducedMotion?.()
?? window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
?? false,
};
lifecycle = { state: createGlowRuntimeState(), host, callbacks, spaceId: '' };
fieldLifecycles.set(input.owner, lifecycle);
} else {
lifecycle.callbacks.requestUpdate = input.requestUpdate;
lifecycle.callbacks.isConnected = input.isConnected;
lifecycle.callbacks.reducedMotion = input.reducedMotion;
}
if (lifecycle.spaceId && lifecycle.spaceId !== input.spaceId) {
forgetGlowSpace(lifecycle.state, lifecycle.host, lifecycle.spaceId);
}
lifecycle.spaceId = input.spaceId;
return lifecycle;
}
/** Disconnect (ТЗ §13.2): no retained entry of this owner. */
export function releaseLedField(owner: object): void {
fieldCaches.get(owner)?.clear();
fieldCaches.delete(owner);
const lifecycle = fieldLifecycles.get(owner);
if (lifecycle) disposeGlowRuntime(lifecycle.state, lifecycle.host);
fieldLifecycles.delete(owner);
}
/** Keep rendering while an off transition still owns a DOM node. */
export function hasLedField(owner: object): boolean {
return !!fieldLifecycles.get(owner)?.state.renderedSources.size;
}
/** The performance witness: what this owner retains right now. */
export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
@@ -284,9 +337,18 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
if (!input.scene) return svg`` as unknown as TemplateResult;
const cache = ledFieldCache(input.owner);
cache.forSpace(input.spaceId);
const lifecycle = fieldLifecycle(input);
const scene = input.scene;
const seen = new Set<string>();
const fields = input.views.flatMap((view) => {
if (!view.glow || view.state === 'unavailable') return [];
const lifecycleKey = `${input.spaceId}|${view.strip.id}`;
seen.add(lifecycleKey);
const active = view.glow && view.state === 'on' && !!view.appearance;
const transition = transitionGlowSource(lifecycle.state, lifecycle.host, lifecycleKey, active);
if (!transition) return [];
if (active && view.appearance) lifecycle.state.lastAppearance.set(lifecycleKey, view.appearance);
const appearance = active ? view.appearance : lifecycle.state.lastAppearance.get(lifecycleKey) ?? null;
if (!appearance) return [];
const key = `${view.strip.id}|${pointsKey(view.strip.points)}|${view.radius.toFixed(5)}|${scene.fingerprint}`;
const geometry = cache.read(key, () => buildFieldGeometry({
points: view.strip.points,
@@ -296,8 +358,9 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
faces: input.faces,
spaceId: input.spaceId,
}));
return geometry ? [{ view, geometry }] : [];
return geometry ? [{ view: { ...view, appearance }, geometry, transition }] : [];
});
pruneGlowSources(lifecycle.state, lifecycle.host, input.spaceId, seen);
if (!fields.length) return svg`` as unknown as TemplateResult;
const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => {
const outer = 1 - k / LED_FIELD_BANDS;
@@ -309,34 +372,33 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
data-led-cache="${cache.size}" data-led-recomputes="${cache.recomputes}">
<defs><clipPath id="hp-led-floor">${fieldFloor(scene).map((d) => svg`<path d="${d}"></path>`)}</clipPath></defs>
<g clip-path="url(#hp-led-floor)">
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry }) => {
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry, transition }) => {
const id = `${cache.id(view.strip.id)}`;
const r = view.radius;
const on = view.state === 'on' && !!view.appearance;
const box = geometry.box;
const closed = isClosedStrip(pts(view.strip.points));
const clipped = geometry.pieces.flatMap((piece, k): Array<FieldPiece & { clip: string[]; clipId: number }> => piece.clip
? [{ ...piece, clip: piece.clip, clipId: k }] : []);
const free = geometry.pieces.filter((piece) => !piece.clip).map((piece) => piece.d).join(' ');
const paint = [...clipped, ...(free ? [{ d: free, clip: null, clipId: -1 }] : [])];
return memo([geometry, on, view.appearance?.c, view.appearance?.alpha, r, id], () => svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
data-pieces="${geometry.pieces.length}" data-closed="${closed ? 'true' : 'false'}">
const clipped = geometry.pieces.filter((piece): piece is FieldPiece & { clip: string[] } => !!piece.clip);
const free = geometry.pieces.filter((piece) => !piece.clip);
const visibilityClip = clipped.length > 0;
return memo([geometry, transition.entering, transition.leaving, view.appearance.c,
view.appearance.alpha, r, id], () => svg`<g
class="glow-spot led-field ${transition.entering ? 'is-entering' : ''} ${transition.leaving ? 'is-leaving' : ''}"
data-led-field="${view.strip.id}" data-led-phase="${transition.entering ? 'entering' : transition.leaving ? 'leaving' : 'visible'}"
data-pieces="${geometry.pieces.length}" data-bands="${LED_FIELD_BANDS}"
data-closed="${closed ? 'true' : 'false'}">
<defs>
${clipped.map((piece) => svg`<clipPath id="hp-led-clip-${id}-${piece.clipId}">
${''/* Subpaths of one path have the same union semantics in a clipPath,
without one DOM node per emitter. */}
<path d="${piece.clip.join(' ')}"></path>
</clipPath>`)}
${visibilityClip ? svg`<clipPath id="hp-led-visible-${id}">
${free.map((piece) => svg`<path d="${piece.d}" fill="none" stroke="white"
stroke-width="${2 * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
${clipped.map((piece) => svg`<path d="${piece.clip.join(' ')}"></path>`)}
</clipPath>` : nothing}
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
color-interpolation="sRGB" style="mask-type:luminance">
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
<g style="isolation:isolate">
${paint.map((piece) => svg`<g style="mix-blend-mode:lighten"
clip-path=${piece.clip ? `url(#hp-led-clip-${id}-${piece.clipId})` : nothing}>
${bands.map((band) => svg`<path d="${piece.d}" fill="none" stroke="${grey(band.value)}"
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
</g>`)}
<g clip-path=${visibilityClip ? `url(#hp-led-visible-${id})` : nothing}>
${bands.map((band) => svg`<path d="${geometry.d}" fill="none" stroke="${grey(band.value)}"
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
</g>
</mask>
</defs>
+2 -2
View File
@@ -25,8 +25,8 @@ export const LED_MAX_STRIPS = 50;
export const LED_MAX_POINTS = 50;
/** Geometric tolerance for "lies on a face": 0.001 cm, never a screen magnet. */
export const LED_EPSILON_CM = 0.001;
/** Default linear field radius (ТЗ §3): 50 cm, independent of the shared one. */
export const LED_DEFAULT_RADIUS_CM = 50;
/** Default linear field radius (#784): 30 cm, independent of the shared one. */
export const LED_DEFAULT_RADIUS_CM = 30;
/** Total stripe thickness in base device diameters (ТЗ §3). */
export const LED_THICKNESS_OFF_D = 0.08;
export const LED_THICKNESS_ON_D = 0.12;
+11 -11
View File
@@ -6,13 +6,9 @@
* and the hit path; the card keeps the device model, the actions and the
* light state, and passes them in. Nothing here writes configuration.
*
* The linear field is the exact distance field of the strip with the shared
* falloff: every piece of the strip paints opaque grey bands of a luminance
* mask (round caps and joins, so one piece never doubles itself), pieces meet
* through `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece —
* and each piece is clipped to the floor its own emitters can see. A hidden
* part therefore never lights through another part's visibility (ТЗ §6), a
* closed strip has no seam and a corner no double brightness (ТЗ §3).
* The linear field paints one continuous path through a union of bounded
* visibility regions. Cache boundaries therefore never become visible seams,
* while the same wall/door scene still clips the light.
*/
import { nothing, svg, type TemplateResult } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
@@ -81,7 +77,7 @@ export interface LedStripView {
glow: boolean;
/** Resolved light colour and per-stop alpha when on; null otherwise. */
appearance: { c: string; alpha: number } | null;
/** Field radius, plan units: the marker's own radius or 50 cm. */
/** Field radius, plan units: the marker's own radius or 30 cm. */
radius: number;
}
@@ -261,7 +257,7 @@ export function renderLedStripes(input: LedStripeInput): TemplateResult {
<feGaussianBlur stdDeviation="${iso.shadow.sigma}"></feGaussianBlur></filter></defs>` : nothing}
<style>
.led-strip .led-focus { fill: none; stroke: transparent; }
.led-strip:focus-within .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
</style>
${repeat(ordered, (view) => view.strip.id, (view) => {
@@ -541,7 +537,8 @@ export function renderLedLayerFor(
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
if (host.isConnected === false) return svg`` as unknown as TemplateResult;
const frame = ledFrameFor(host, space, spaceGlow);
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
const module = frame.scene && (fieldWanted(frame.views) || field?.hasLedField(host))
? field || ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
if (!module) return svg`` as unknown as TemplateResult;
return module.renderLedField({
views: frame.views,
@@ -550,6 +547,8 @@ export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlo
faces: frame.faces,
spaceId: space.id,
owner: host,
requestUpdate: () => host.requestUpdate(),
isConnected: () => host.isConnected !== false,
});
}
@@ -611,9 +610,10 @@ export function renderStaticLed(input: StaticLedInput): TemplateResult {
occluders: [], floor: [], fingerprint: '',
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
const module = input.scene && input.live && fieldWanted(views)
const module = input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
? ledField(input.space.id, input.ready) : null;
return svg`${module ? module.renderLedField({
views, scene: input.scene as LightBarrierScene, polygons, faces, spaceId: input.space.id, owner: input.owner,
requestUpdate: input.ready, isConnected: () => true,
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
}
+1 -1
View File
@@ -209,7 +209,7 @@ function extrasCss(options: FormKitCssOptions): string {
${form} [aria-invalid="true"] { border-color: var(${tokens.danger}) !important; }
.${p}-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 7px; background: var(${tokens.surface}); }
.${p}-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
.${p}-unit > input:focus { outline: 0; }
${form} .${p}-unit > input:focus { outline: 0; }
.${p}-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(${tokens.muted}); font-size: .875rem; }
.${p}-unit:focus-within { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
.${p}-unit.${p}-unit-wide, .${p}-unit.${p}-unit-wide > input { width: 100%; min-width: 0; }
+1 -1
View File
@@ -82,7 +82,7 @@
hp-dialog .hpf-form [aria-invalid="true"] { border-color: var(--hpf-danger) !important; }
.hpf-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(--hpf-line); border-radius: 7px; background: var(--hpf-surface); }
.hpf-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
.hpf-unit > input:focus { outline: 0; }
hp-dialog .hpf-form .hpf-unit > input:focus { outline: 0; }
.hpf-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(--hpf-muted); font-size: .875rem; }
.hpf-unit:focus-within { outline: 2px solid var(--hpf-accent); outline-offset: 2px; }
.hpf-unit.hpf-unit-wide, .hpf-unit.hpf-unit-wide > input { width: 100%; min-width: 0; }
+1 -1
View File
@@ -68,7 +68,7 @@
hp-dialog .hpf-form [aria-invalid="true"] { border-color: var(--hpf-danger) !important; }
.hpf-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(--hpf-line); border-radius: 7px; background: var(--hpf-surface); }
.hpf-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
.hpf-unit > input:focus { outline: 0; }
hp-dialog .hpf-form .hpf-unit > input:focus { outline: 0; }
.hpf-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(--hpf-muted); font-size: .875rem; }
.hpf-unit:focus-within { outline: 2px solid var(--hpf-accent); outline-offset: 2px; }
.hpf-unit.hpf-unit-wide, .hpf-unit.hpf-unit-wide > input { width: 100%; min-width: 0; }
+8
View File
@@ -146,6 +146,14 @@ test('#594 имена параметризованы, а не зашиты', ()
'с включённым флагом ряд-переключатель обязан появляться');
});
test('#784 unit input leaves one focus ring on its wrapper', () => {
const css = formKitCss(CARD_DIALOG_FORM_KIT, { withSwitch: false });
assert.match(css, /hp-dialog \.hpf-form \.hpf-unit > input:focus \{ outline: 0; \}/,
'the inner input must override the more specific generic focus-visible rule');
assert.match(css, /\.hpf-unit:focus-within \{ outline: 2px solid var\(--hpf-accent\);/,
'the wrapper keeps the single visible focus ring');
});
test('#594 сегментированный переключатель остаётся радиогруппой', () => {
const kit = readFileSync(new URL('../src/editors/form-kit.ts', import.meta.url), 'utf8');
// Только тело `segmented`: дальше в файле есть другие радиогруппы (radioRow,
+23 -6
View File
@@ -53,7 +53,8 @@ test('ТЗ §3: the linear falloff is the shared GLOW_FALLOFF', () => {
}
});
test('AC10/AC17: the compact field bands stay within the r/2 visual tolerance', () => {
test('#784: the continuous field has enough bands to avoid visible gradient steps', () => {
assert.ok(LED_FIELD_BANDS >= 32, `${LED_FIELD_BANDS} bands are visibly discrete on wide fields`);
const fraction = 0.5;
const band = Math.floor((1 - fraction) * LED_FIELD_BANDS);
const midpoint = 1 - (band + 0.5) / LED_FIELD_BANDS;
@@ -65,13 +66,13 @@ test('AC10/AC17: the compact field bands stay within the r/2 visual tolerance',
const device = (extra = {}) => ({ id: 'm1', name: 'Kitchen LED', primary: 'light.led', space: 's', ...extra });
const strip = { id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' };
test('AC7: states — off white, on, unavailable without a field; radius 50 cm or the own one', () => {
const base = { strip, defaultRadius: 10, cellCm: 5, gridPitch: 1, glow: true };
test('#784/AC7: states — off white, on, unavailable without a field; radius 30 cm or the own one', () => {
const base = { strip, defaultRadius: 6, cellCm: 5, gridPitch: 1, glow: true };
const on = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'on' } } },
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: { c: '#ff0000', alpha: 0.5 } } });
assert.equal(on.state, 'on');
assert.deepEqual(on.appearance, { c: '#ff0000', alpha: 0.5 });
assert.equal(on.radius, 10, 'the shared radius of ordinary sources does not apply: 50 cm default');
assert.equal(on.radius, 6, 'the shared radius of ordinary sources does not apply: 30 cm default');
const off = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'off' } } },
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: null } });
assert.equal(off.state, 'off');
@@ -100,6 +101,8 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
const geometry = buildFieldGeometry({ points: [[1, 1], [9, 1]], radius: 2, scene, polygons, faces, spaceId: 's' });
assert.ok(geometry, 'a free strip has a field');
assert.equal(geometry.pieces.length, 4, 'an 8-unit segment with r = 2 makes four pieces');
assert.equal((geometry.d.match(/M/g) || []).length, 1,
'visibility/cache pieces do not split the painted path');
for (const piece of geometry.pieces) assert.equal(piece.clip, null, 'nothing within r: the bands are the bound');
// Passing 0.5 below the body: the pieces near it are clipped to their own fans.
const near = buildFieldGeometry({ points: [[0.5, 3.5], [9.5, 3.5]], radius: 1, scene, polygons, faces, spaceId: 's' });
@@ -118,6 +121,20 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
assert.equal(buried, null, 'entirely inside the body: no field');
});
test('#784: a corner and a closed strip remain one painted path', () => {
const faces = faceContext(scene, 1e-6);
const corner = buildFieldGeometry({ points: [[1, 1], [9, 1], [9, 9]], radius: 2,
scene, polygons, faces, spaceId: 's' });
assert.ok(corner && corner.pieces.length > 1);
assert.equal((corner.d.match(/M/g) || []).length, 1);
assert.match(corner.d, /L9 1 L9 9$/);
const closed = buildFieldGeometry({ points: [[1, 1], [3, 1], [3, 3], [1, 3], [1, 1]], radius: 1,
scene, polygons, faces, spaceId: 's' });
assert.ok(closed);
assert.equal((closed.d.match(/M/g) || []).length, 1);
assert.match(closed.d, / Z$/);
});
test('AC17: the field cache is bounded, per space, and counts geometry rebuilds', () => {
const cache = new LedFieldCache(3);
cache.forSpace('a');
@@ -130,7 +147,7 @@ test('AC17: the field cache is bounded, per space, and counts geometry rebuilds'
assert.equal(cache.size, 0, 'another space frees the previous one');
});
test('AC9: the frame gives every strip the 50 cm default, not the shared radius; unbound strips have no view', () => {
test('#784/AC9: the frame gives every strip the 30 cm default, not the shared radius; unbound strips have no view', () => {
const lamp = { id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's', marker: { id: 'm1', binding: 'device:m1' } };
const frame = ledFrame({
space: { id: 's', vb: [0, 0, 1000, 1000], rooms: [], led_strips: [
@@ -143,7 +160,7 @@ test('AC9: the frame gives every strip the 50 cm default, not the shared radius;
scene: null, polygons: [], glowFor: () => true, inRoom: () => false, showHidden: false,
});
assert.equal(frame.views.length, 1, 'the unbound strip is not a View strip');
assert.ok(Math.abs(frame.views[0].radius - (50 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
assert.ok(Math.abs(frame.views[0].radius - (30 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
});
test('AC2/r1 M2: an explicit valid room_id wins over the anchor room; a stale one falls back', () => {