From 706eb02d70b6fecaafda0da67e4564e8909606ff Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 3 Oct 2026 10:47:28 +0300 Subject: [PATCH] fix(led): polish glow and interactions (#784) Issue: #784 User-Visible: yes --- demo/benchmark_led_strips.mjs | 2 +- demo/performance/README.md | 3 +- demo/smoke_led_strip_draw.mjs | 35 +++++ demo/smoke_led_strip_glow.mjs | 38 ++++- docs/CHANGELOG.md | 7 + docs/CHANGELOG.ru.md | 7 + docs/LIGHT.md | 2 +- docs/USER-GUIDE.md | 2 +- docs/USER-GUIDE.ru.md | 2 +- docs/design/led-strips/ACCEPTANCE.md | 4 +- docs/design/led-strips/README.md | 2 +- scripts/mutation-registry.mjs | 4 +- src/led-strip-editor.ts | 6 +- src/led-strip-field.ts | 132 +++++++++++++----- src/led-strip-geometry.ts | 4 +- src/led-strip-runtime.ts | 22 +-- src/styles/form-kit.styles.ts | 2 +- .../form-kit-card-dialog-with-switch.css | 2 +- test/fixtures/form-kit-card-dialog.css | 2 +- test/form-kit.test.mjs | 8 ++ test/led-strip-runtime.test.mjs | 29 +++- 21 files changed, 243 insertions(+), 72 deletions(-) diff --git a/demo/benchmark_led_strips.mjs b/demo/benchmark_led_strips.mjs index 8e2a8446..1e5572b7 100644 --- a/demo/benchmark_led_strips.mjs +++ b/demo/benchmark_led_strips.mjs @@ -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 diff --git a/demo/performance/README.md b/demo/performance/README.md index 5cde5e30..4bcb9b4e 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -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. - diff --git a/demo/smoke_led_strip_draw.mjs b/demo/smoke_led_strip_draw.mjs index 7c22180d..9fa59dd0 100644 --- a/demo/smoke_led_strip_draw.mjs +++ b/demo/smoke_led_strip_draw.mjs @@ -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; diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs index 6bc46f4d..ec1b1335 100644 --- a/demo/smoke_led_strip_glow.mjs +++ b/demo/smoke_led_strip_glow.mjs @@ -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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 59abde3f..5d063b21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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» diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 2e3d0d3d..137e69bb 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- LED-ленты теперь дают мягкое непрерывное свечение без полос градиента и + срезов на углах и плавно загораются и гаснут. Радиус свечения по умолчанию + изменён на 30 см. Список выбора устройства прокручивается, не меняя масштаб + плана; у поля радиуса осталась одна обводка фокуса, а клик по ленте больше не + оставляет обводку выделения — видимый клавиатурный фокус сохранён + ([#784](https://github.com/Matysh/houseplan-card/issues/784)). + ## v1.79.0-beta.3 — 2026-10-03 - LED-ленты: в редакторе устройств рядом с «Добавить» появился инструмент diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 8b5e8fae..0a632c63 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -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 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 12846dd8..f837e883 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 74dc8df9..c8732c45 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1156,7 +1156,7 @@ binding tombstone не вырезает её из живого устройст Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится устройство: при включённом Glow — белое ядро и мягкая цветная полоса света -радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета +радиусом 30 см (или персональный радиус устройства), без Glow — ядро цвета источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента — одна цель нажатия по всей длине, как значок. Каждое законченное изменение формы и смена представления — один шаг **Отменить/Повторить**. diff --git a/docs/design/led-strips/ACCEPTANCE.md b/docs/design/led-strips/ACCEPTANCE.md index 0f1538e7..d7fc3548 100644 --- a/docs/design/led-strips/ACCEPTANCE.md +++ b/docs/design/led-strips/ACCEPTANCE.md @@ -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). diff --git a/docs/design/led-strips/README.md b/docs/design/led-strips/README.md index 249f1894..8b66b35c 100644 --- a/docs/design/led-strips/README.md +++ b/docs/design/led-strips/README.md @@ -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. diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 295ebec3..c5d2eb97 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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;", }], }, diff --git a/src/led-strip-editor.ts b/src/led-strip-editor.ts index da46ff1b..7778f4ac 100755 --- a/src/led-strip-editor.ts +++ b/src/led-strip-editor.ts @@ -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` -
+ icon="mdi:led-strip-variant" flex-content @wheel=${(e: WheelEvent) => e.stopPropagation()} + @hp-close=${close}> +
${devices.length ? devices.map((d) => { const owner = ownerOf(this.host._serverCfg, d.id); const taken = !!owner && owner.strip.id !== stripId; diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts index 4a2cea7b..91068bbd 100644 --- a/src/led-strip-field.ts +++ b/src/led-strip-field.ts @@ -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(); +interface FieldLifecycle { + state: GlowRuntimeState; + host: GlowRuntimeHost; + callbacks: Pick; + spaceId: string; +} +const fieldLifecycles = new WeakMap(); + +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(); 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}"> ${fieldFloor(scene).map((d) => svg``)} - ${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 => 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` + 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` - ${clipped.map((piece) => svg` - ${''/* Subpaths of one path have the same union semantics in a clipPath, - without one DOM node per emitter. */} - - `)} + ${visibilityClip ? svg` + ${free.map((piece) => svg``)} + ${clipped.map((piece) => svg``)} + ` : nothing} - - ${paint.map((piece) => svg` - ${bands.map((band) => svg``)} - `)} + + ${bands.map((band) => svg``)} diff --git a/src/led-strip-geometry.ts b/src/led-strip-geometry.ts index e7209f29..ca3cc486 100755 --- a/src/led-strip-geometry.ts +++ b/src/led-strip-geometry.ts @@ -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; diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index c7520774..61a6a818 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -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 { ` : nothing} ${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; } diff --git a/src/styles/form-kit.styles.ts b/src/styles/form-kit.styles.ts index 2b5bef97..929b281c 100644 --- a/src/styles/form-kit.styles.ts +++ b/src/styles/form-kit.styles.ts @@ -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; } diff --git a/test/fixtures/form-kit-card-dialog-with-switch.css b/test/fixtures/form-kit-card-dialog-with-switch.css index 58279d4f..b3bb0a36 100644 --- a/test/fixtures/form-kit-card-dialog-with-switch.css +++ b/test/fixtures/form-kit-card-dialog-with-switch.css @@ -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; } diff --git a/test/fixtures/form-kit-card-dialog.css b/test/fixtures/form-kit-card-dialog.css index d6e40644..c8a95462 100644 --- a/test/fixtures/form-kit-card-dialog.css +++ b/test/fixtures/form-kit-card-dialog.css @@ -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; } diff --git a/test/form-kit.test.mjs b/test/form-kit.test.mjs index 23559ad4..025db468 100644 --- a/test/form-kit.test.mjs +++ b/test/form-kit.test.mjs @@ -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, diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index 8d94c46a..bc92b4b8 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -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', () => {