diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index aa279bd5..151f5424 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -48,6 +48,7 @@ jobs: - overlay - space-default - space-glow + - led-strips steps: - name: Check out candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -175,6 +176,14 @@ jobs: cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json fi ;; + led-strips) + # #780 led-strips-v1: base predates LED strips, so the profile is + # judged by its absolute limits inside the runner; the zero-LED + # View stays covered by the relative interaction profile above. + npm run benchmark:led-strips -- --size=10x5 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-10x5.json + npm run benchmark:led-strips -- --size=50x50 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-50x50.json + npm run benchmark:led-strips -- --size=none --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-none.json + ;; *) echo "::error::Unknown performance profile: $PROFILE" exit 1 @@ -217,6 +226,9 @@ jobs: space-glow) npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json ;; + led-strips) + echo "led-strips-v1: absolute limits and counters already enforced by the runner (budgets-led-strips.json)" + ;; esac - name: Upload full performance report diff --git a/demo/benchmark_led_strips.mjs b/demo/benchmark_led_strips.mjs new file mode 100644 index 00000000..a14d9a82 --- /dev/null +++ b/demo/benchmark_led_strips.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/** + * #780 ТЗ §13.2: the `led-strips-v1` performance profile. + * + * The `large-house-v1` fixture (200 devices, 3 floors, 60 rooms, 100 + * openings) with 10 or 50 of each floor's existing devices shown as LED + * 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, + * viewport 1440×1000, DPR 1, reduced motion. Every sample is cold: a new + * browser with an empty cache mounts a new card. + * + * Metrics (judged on median and p95 against budgets-led-strips.json): + * firstStableRenderMs (mount → stripes and fields of every visible strip), + * warmSpaceReadyMs (space switch with the module loaded → stable LED frame), + * stateUpdateMs (all strip sources switched → every stripe in its new state), + * panZoomMs and its longest Long Task, retained heap after 20 A→B→C→A cycles. + * Counters (must be exact): field geometry recomputes over 100 unrelated HA + * ticks, 100 pan/zoom steps and a colour-only change (0), cache entries ≤ 50 + * and equal after every cycle, LED chunk requests (none without strips). + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { launch } from './serve.mjs'; +import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs'; + +const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); +const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7)); +const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1)); +const size = valueArg('size') ?? '10x5'; +const output = valueArg('output') ? resolve(valueArg('output')) : null; +const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] }; +if (!SIZES[size]) throw new Error(`unknown size: ${size}`); +const [STRIPS, POINTS] = SIZES[size]; +const PROFILE = 'led-strips-v1'; +const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20)); + +const fixture = makeLedStripsFixture(STRIPS, POINTS); +const sourceSha = (() => { + try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { return null; } +})(); +const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8')); + +const rows = []; +let chromium = null; +for (let iteration = 0; iteration < warmups + samples; iteration++) { + const { page, browser } = await launch({ width: 1440, height: 1000 }, 1, + ['--enable-precise-memory-info', '--js-flags=--expose-gc']); + chromium ??= await browser.version(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + const requests = []; + page.on('request', (request) => { + const name = request.url().replace(/.*\//, '').replace(/\?.*$/, ''); + if (/^led-strip-(runtime|field|editor)-/.test(name)) requests.push(name.replace(/-[^-]+\.js$/, '')); + }); + try { + const row = await page.evaluate(async ({ fixture, strips, cycles }) => { + const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + const until = async (predicate, timeout = 20000) => { + const started = performance.now(); + while (!predicate()) { + if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out'); + await sleep(5); + } + }; + const longTasks = () => { + const entries = []; + const observer = new PerformanceObserver((list) => entries.push(...list.getEntries())); + observer.observe({ type: 'longtask', buffered: false }); + return async () => { + await sleep(0); + entries.push(...observer.takeRecords()); + observer.disconnect(); + return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2)); + }; + }; + const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } }; + window.__card?.remove?.(); + localStorage.clear(); + const card = document.createElement('houseplan-card'); + card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 }); + let states = fixture.states; + const hassBase = { + language: 'en', locale: { language: 'en' }, + user: { id: 'perf', name: 'Performance fixture', is_admin: true }, + devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, + callWS: async (message) => { + if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: true }; + if (message.type === 'houseplan/layout/get') return { layout: structuredClone(fixture.layout), rev: 1 }; + if (message.type === 'config/device_registry/list') return Object.values(fixture.devices); + if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities); + return { ok: true }; + }, + callService: async () => undefined, + connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined }, + localize: () => null, + formatEntityState: (state) => state.state, + config: { unit_system: { length: 'km' } }, + }; + const setStates = (next) => { states = next; card.hass = { ...hassBase, states }; }; + const root = () => card.renderRoot; + const count = (selector) => root()?.querySelectorAll(selector).length ?? 0; + const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips + && (state !== 'on' || count('.led-fields [data-led-field]') === strips); + const cache = () => { + const group = root().querySelector('.led-fields'); + return group ? { size: Number(group.dataset.ledCache), recomputes: Number(group.dataset.ledRecomputes) } : null; + }; + + const started = performance.now(); + document.getElementById('host').replaceChildren(card); + setStates(states); + await until(() => card._loadOk && card._model?.length === 3); + if (strips) await until(() => ledStable()); + else { await until(() => card._booting === false); await frame(); } + await frame(); + const firstStableRenderMs = Number((performance.now() - started).toFixed(2)); + + const warm = performance.now(); + card._pickSpace('perf-floor-2'); + await card.updateComplete; + if (strips) await until(() => ledStable()); + await frame(); + const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2)); + + const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_')); + const stateStarted = performance.now(); + setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) }); + await card.updateComplete; + if (strips) await until(() => ledStable('off')); + await frame(); + const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2)); + setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) }); + await card.updateComplete; + if (strips) await until(() => ledStable()); + await frame(); + + const before = strips ? cache() : null; + // 100 unrelated HA ticks. + const sensor = Object.keys(states).find((id) => id.startsWith('sensor.')); + for (let tick = 0; tick < 100; tick++) { + setStates({ ...states, [sensor]: { ...states[sensor], state: String(20 + (tick % 10) / 10) } }); + await card.updateComplete; + } + await frame(); + const afterTicks = strips ? cache() : null; + // Pan/zoom: the camera scenario, then 100 steps for the counter. + const stage = root().querySelector('.stage'); + const rect = stage.getBoundingClientRect(); + const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', { + deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true, + })); + const stopPan = longTasks(); + const panStarted = performance.now(); + wheel(-120); + await card.updateComplete; + await frame(); + const panZoomMs = Number((performance.now() - panStarted).toFixed(2)); + const panZoomLongTaskMaxMs = await stopPan(); + // 100 more camera steps for the counters only (not a timing window). + for (let step = 0; step < 100; step++) { + wheel(step % 2 ? 60 : -60); + await card.updateComplete; + } + await frame(); + const afterCamera = strips ? cache() : null; + // A colour-only change rebuilds no geometry or visibility. + setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, + { ...states[id], attributes: { ...states[id].attributes, rgb_color: [255, 120, 80] } }])) }); + await card.updateComplete; + await frame(); + const afterColour = strips ? cache() : null; + + // 20 cycles A→B→C→A after one warm cycle; cache size equal after each. + const cycle = async () => { + for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) { + card._pickSpace(id); + await card.updateComplete; + if (strips) await until(() => ledStable()); + } + await frame(); + return strips ? cache().size : 0; + }; + await cycle(); + await gc(); + const heapBefore = performance.memory?.usedJSHeapSize ?? null; + const sizes = []; + for (let index = 0; index < cycles; index++) sizes.push(await cycle()); + await gc(); + const heapAfter = performance.memory?.usedJSHeapSize ?? null; + card.remove(); + await frame(); + return { + firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, + retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore), + counters: strips ? { + recomputesOnHaTicks: afterTicks.recomputes - before.recomputes, + recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes, + recomputesOnColour: afterColour.recomputes - afterCamera.recomputes, + cacheEntries: Math.max(...sizes), + cacheGrowthOverCycles: Math.max(...sizes) - Math.min(...sizes), + } : null, + }; + }, { fixture, strips: STRIPS, cycles: CYCLES }); + row.ledRequests = [...new Set(requests)].sort(); + if (iteration >= warmups) rows.push(row); + } finally { + await browser.close(); + } +} + +const metric = (name) => { + const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b); + const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)]; + return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values }; +}; +const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs', 'retainedHeapBytes']; +const report = { + profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium, + samples, warmups, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1, + metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])), + counters: rows[0]?.counters ?? null, + counterSamples: rows.map((row) => row.counters), + ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(), +}; +const failures = []; +const limits = budgets.sizes[size] || {}; +for (const [name, limit] of Object.entries(limits)) { + for (const stat of ['median', 'p95']) { + const value = report.metrics[name]?.[stat]; + if (value == null) { if (name !== 'retainedHeapBytes') failures.push(`${name} ${stat} missing`); continue; } + if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`); + } +} +if (STRIPS) { + for (const counters of report.counterSamples) { + for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) { + if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`); + } + if (counters.cacheEntries > budgets.cacheEntries) failures.push(`cache entries ${counters.cacheEntries} > ${budgets.cacheEntries}`); + } + if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field')) + failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`); +} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`); +if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk'); +report.failures = failures; +const text = `${JSON.stringify(report, null, 2)}\n`; +if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); } +console.log(text); +if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); } diff --git a/demo/fixtures/visual-matrix.mjs b/demo/fixtures/visual-matrix.mjs index 4cdd75b2..6a2c17c7 100644 --- a/demo/fixtures/visual-matrix.mjs +++ b/demo/fixtures/visual-matrix.mjs @@ -136,7 +136,53 @@ const applianceSpace = { decor: [], }; -const runtime = (includeAppliance = false) => { +// #780: the LED strip reference scene — the designer's four strips on a +// synthetic plan with the reference floor colour #868D94: a free straight +// strip, a strip on the inner face of the bottom wall, a polyline along the +// wall faces around a corner and a closed rectangle. The colours are scene +// values of the four light sources, not a product palette. +const ledRooms = [ + { id: 'led-living', name: 'LED living', area: 'golden_led_living', + poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] }, + { id: 'led-studio', name: 'LED studio', area: 'golden_led_studio', + poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] }, +]; +// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240). +const LED_FACE = 2 / 240; +export const LED_REFERENCE_STRIPS = Object.freeze([ + { id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255], + points: [[0.14, 0.26], [0.44, 0.26]] }, + { id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255], + points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] }, + { id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128], + points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)], + [round(0.94 - LED_FACE), 0.42]] }, + { id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88], + points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] }, +]); + +const ledSpace = { + id: 'golden-led', + title: 'LED strips', + plan_url: null, + view_box: [0, 0, 1, 0.7], + cell_cm: 5, + settings: { + fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true, + show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static', + }, + rooms: ledRooms, + walls: wallsFor('led', ledRooms, 20), + openings: [ + { id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 }, + ], + partitions: [], + wall_columns: [], + decor: [], + led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })), +}; + +const runtime = (includeAppliance = false, includeLed = false) => { const devices = {}; const entities = {}; const states = { @@ -199,6 +245,15 @@ const runtime = (includeAppliance = false) => { unit_of_measurement: 'lqi', }); + if (includeLed) { + for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name }; + for (const strip of LED_REFERENCE_STRIPS) { + add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area, + 0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 }); + layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 }; + } + } + if (includeAppliance) { const washerId = 'golden-washer'; devices[washerId] = { @@ -247,16 +302,23 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({ columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length, }); -export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({ +export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({ config: { spaces: [ structuredClone(geometrySpace), structuredClone(lightingSpace), ...(applianceLifecycle ? [structuredClone(applianceSpace)] : []), + ...(ledStrips ? [structuredClone(ledSpace)] : []), ], // A persisted marker is part of the fixture contract for scenarios that // override per-source Glow controls. The device/layout alone are not a // saved marker configuration and must not be silently treated as one. - markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }], + markers: [ + { id: 'golden-light-two', binding: 'device:golden-light-two' }, + // #780: a strip link writes the marker's explicit space (ТЗ §5). + ...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({ + id: marker, binding: `device:${marker}`, space: 'golden-led', + })) : []), + ], settings: { glow_radius_cm: 360, north_deg: 0, @@ -269,7 +331,7 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => }, }, }, - ...runtime(applianceLifecycle), + ...runtime(applianceLifecycle, ledStrips), counts: applianceLifecycle ? { ...VISUAL_MATRIX_COUNTS, spaces: VISUAL_MATRIX_COUNTS.spaces + 1, diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index 026825c9..ba77f923 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -43,7 +43,9 @@ const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1'; const fixtureFor = (scenario) => scenario.fixture === 'large' ? makeLargeHouseFixture({ includeStairs: false }) - : makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle }); + : makeVisualMatrixFixture({ + applianceLifecycle: !!scenario.applianceLifecycle, ledStrips: !!scenario.ledStrips, + }); const themeVars = { dark: { diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 979e7523..7930e724 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -1,7 +1,7 @@ import { fixtureWallKey } from '../fixtures/visual-matrix.mjs'; /** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */ -export const GOLDEN_MATRIX_VERSION = 70; +export const GOLDEN_MATRIX_VERSION = 71; const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } }; const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } }; @@ -266,6 +266,8 @@ export const STAGE6_ACCEPTANCE_SCENARIOS = Object.freeze([ }, ]); +const LED_STRIPS_OFF = Object.fromEntries(['light.demo_led_strip', 'light.demo_led_strip_wall', 'light.demo_led_strip_corner', 'light.demo_led_strip_loop'].map((id) => [id, { state: 'off' }])); + export const GOLDEN_SCENARIOS = Object.freeze([ ...STAGE6_ACCEPTANCE_SCENARIOS, // #663 AC3/AC10/AC12: both stair geometries, both rise directions and @@ -1314,4 +1316,21 @@ export const GOLDEN_SCENARIOS = Object.freeze([ { id: 'card-editor-invalid-default-floor-dark-ru', fixture: 'visual', cardEditorInvalidDefaultFloor: 'removed-floor', language: 'ru', theme: 'dark', viewport: { width: 900, height: 760 }, ...page }, + // #780: LED strips. The two design references keep all four strips of the + // designer's composition at once (free, wall face, corner polyline, closed + // loop) on the reference floor #868D94; the others pin the dark Glow field, + // the off stripe in the light theme and the raised 2.5D stripe. + { id: 'led-strip-design-reference-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led', + mode: 'view', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light', + viewport: { width: 1000, height: 760 }, ...stage }, + { id: 'led-strip-design-reference-on-light', fixture: 'visual', ledStrips: true, space: 'golden-led', + mode: 'view', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, ...stage }, + { id: 'lighting-led-strip-glow-dark', fixture: 'visual', ledStrips: true, space: 'golden-led', + mode: 'view', fillMode: 'none', language: 'en', theme: 'dark', viewport: { width: 1000, height: 760 }, ...stage }, + { id: 'led-strip-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led', + mode: 'view', fillMode: 'none', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light', + viewport: { width: 1000, height: 760 }, ...stage }, + { id: 'iso-led-strip-dark', fixture: 'visual', ledStrips: true, space: 'golden-led', + mode: 'view', projection: 'iso', fillMode: 'none', language: 'en', theme: 'dark', + viewport: { width: 1000, height: 760 }, ...stage }, ]); diff --git a/demo/performance/README.md b/demo/performance/README.md index 224589bd..0e8d0ec8 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -424,3 +424,26 @@ they must be reviewed against the first paired Ubuntu artifacts before the feature is promoted from beta. Same-runner relative checks in the full workflow remain the primary regression signal; the candidate-only smoke only guards against catastrophic failures. + +## `led-strips-v1` (#780) + +`demo/benchmark_led_strips.mjs` (`npm run benchmark:led-strips -- --size=…`) +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, +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` +3400/5000, `warmSpaceReadyMs` 1500, `stateUpdateMs` 1000/1500, `panZoomMs` 500, +the longest pan/zoom Long Task 150 ms, retained heap after 20 A→B→C→A cycles +64 MiB — and on exact counters: zero field geometry recomputes over 100 +unrelated HA ticks, 100 camera steps and a colour-only change, at most 50 cache +entries, no cache growth over the cycles, the LED chunks loaded with strips +and none without them, never the editor chunk in the View. Base predates the +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/performance/budgets-led-strips.json b/demo/performance/budgets-led-strips.json new file mode 100644 index 00000000..01c86c48 --- /dev/null +++ b/demo/performance/budgets-led-strips.json @@ -0,0 +1,25 @@ +{ + "profile": "led-strips-v1", + "issue": 780, + "note": "ТЗ #780 §13.2: absolute limits applied to median and p95; 10x5 rests on the interaction profile's absolute ceilings, 50x50 are the acceptance bounds set in the ТЗ. The no-strip control is judged by the existing interaction profile against base.", + "cacheEntries": 50, + "sizes": { + "10x5": { + "firstStableRenderMs": 3400, + "warmSpaceReadyMs": 1500, + "stateUpdateMs": 1000, + "panZoomMs": 500, + "panZoomLongTaskMaxMs": 150, + "retainedHeapBytes": 67108864 + }, + "50x50": { + "firstStableRenderMs": 5000, + "warmSpaceReadyMs": 1500, + "stateUpdateMs": 1500, + "panZoomMs": 500, + "panZoomLongTaskMaxMs": 150, + "retainedHeapBytes": 67108864 + }, + "none": {} + } +} diff --git a/demo/performance/led-strips-fixture.mjs b/demo/performance/led-strips-fixture.mjs new file mode 100644 index 00000000..a62a27a6 --- /dev/null +++ b/demo/performance/led-strips-fixture.mjs @@ -0,0 +1,44 @@ +/** + * #780 `led-strips-v1`: the `large-house-v1` fixture with N existing devices + * of every floor shown as LED strips of K points that are on. No device is + * added: a converted device keeps its id and layout; its entity becomes a + * light (`light.perf_led__`) and its marker gets the strip's space. + */ +import { makeLargeHouseFixture } from '../fixtures/large-house.mjs'; + +const round = (value) => Number(value.toFixed(6)); + +export function makeLedStripsFixture(strips, points) { + const fixture = makeLargeHouseFixture(); + if (!strips) return fixture; + const floors = fixture.config.spaces; + for (const [floorIndex, space] of floors.entries()) { + const deviceIds = Object.keys(fixture.layout).filter((id) => fixture.layout[id].s === space.id).slice(0, strips); + if (deviceIds.length < strips) throw new Error(`${space.id} has only ${deviceIds.length} devices`); + space.led_strips = deviceIds.map((deviceId, k) => { + const at = fixture.layout[deviceId]; + const w = 0.05, h = 0.016; + const pts = Array.from({ length: points }, (_, i) => [ + round(Math.min(0.995, Math.max(0.005, at.x - w / 2 + (w * i) / (points - 1)))), + round(Math.min(0.995, Math.max(0.005, at.y + (i % 2 ? h : -h) / 2 + (k % 3) * 0.004))), + ]); + return { id: `perf-led-${floorIndex}-${k}`, points: pts, marker: deviceId }; + }); + for (const [k, deviceId] of deviceIds.entries()) { + for (const [entityId, entity] of Object.entries(fixture.entities)) { + if (entity.device_id !== deviceId) continue; + delete fixture.entities[entityId]; + delete fixture.states[entityId]; + } + const entityId = `light.perf_led_${floorIndex}_${k}`; + fixture.entities[entityId] = { entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf', + config_entry_id: 'perf_entry', disabled_by: null }; + fixture.states[entityId] = { entity_id: entityId, state: 'on', + attributes: { friendly_name: `LED ${floorIndex}.${k}`, rgb_color: [128, 213, 255], brightness: 255 } }; + fixture.config.markers = fixture.config.markers.filter((marker) => marker.id !== deviceId); + fixture.config.markers.push({ id: deviceId, binding: `device:${deviceId}`, space: space.id, is_light: true }); + } + } + return fixture; +} + diff --git a/demo/smoke_led_strip_bind.mjs b/demo/smoke_led_strip_bind.mjs new file mode 100644 index 00000000..90894752 --- /dev/null +++ b/demo/smoke_led_strip_bind.mjs @@ -0,0 +1,138 @@ +/** + * #780 AC6/AC19/AC20 (ТЗ §5): binding and the «icon ↔ LED strip» switch. + * The picker binds a light (lights first): the live marker is created with + * the strip's explicit space, the ordinary icon disappears and comes back on + * «Show as icon», whose shape stays hidden and is restored exactly by «Show as + * LED strip» — no drawing, no new record, no HA service. An ordinary icon + * clicked while a strip is selected opens its own dialog without the LED tray. + * Deleting the bound marker leaves an unbound strip. A failed write rolls back + * and records no command; Undo/Redo of the switch is one command each. + */ +import { launch, check, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 1000, height: 820 }, 1); +const evaluate = (fn, arg) => page.evaluate(fn, arg); + +await evaluate(async () => { + const c = window.__card; + window.__ledSaves = 0; window.__ledFail = false; window.__ledServices = []; + const orig = c.hass.callWS; + c.hass.callWS = async (m) => { + if (m.type === 'houseplan/config/set') { + if (window.__ledFail) throw new Error('offline'); + window.__ledSaves++; + return { ok: true, rev: (c._cfgRev || 0) + 1 }; + } + return orig.call(c.hass, m); + }; + const service = c.hass.callService; + c.hass.callService = async (...args) => { window.__ledServices.push(args.slice(0, 2).join('.')); return service(...args); }; + // One unbound strip in the Living room of the demo floor. + c._serverCfg.spaces[0].led_strips = [{ id: 'led-a', points: [[0.12, 0.22], [0.40, 0.22], [0.40, 0.40]], marker: null }]; + c._modelCache = null; c._frame = null; c._cfgEpoch++; + c._setMode('devices'); await c.updateComplete; +}); +await page.waitForTimeout(800); +check('an editable strip loads the tool for the Devices editor', await evaluate(() => !!window.__card._ledEditor)); +const icon = (id) => evaluate((id) => !!window.__card.shadowRoot.querySelector(`.dev[data-id="${id}"], [data-hp="device"][data-id="${id}"]:not(.led-hit)`), id); +check('the light starts as an ordinary icon', await icon('d_bedlight')); + +await evaluate(() => { window.__card._ledEditor.select('led-a'); window.__card._ledEditor.picker = 'led-a'; window.__card.requestUpdate(); }); +await page.waitForTimeout(200); +check('lights first in the picker', await evaluate(() => { + const picks = [...window.__card.shadowRoot.querySelectorAll('[data-led-pick]')].map((b) => b.dataset.ledPick); + const firstOther = picks.findIndex((id) => !['d_light1', 'd_lamp', 'd_bedlight'].includes(id)); + return picks.slice(0, 3).every((id) => ['d_light1', 'd_lamp', 'd_bedlight'].includes(id)) && firstOther === 3; +})); +await page.click('[data-led-pick="d_bedlight"]'); +await page.waitForTimeout(500); +const bound = await evaluate(() => { + const c = window.__card; + return { strip: c._serverCfg.spaces[0].led_strips[0], marker: c._serverCfg.markers.find((m) => m.id === 'd_bedlight') }; +}); +check('bound to the light', bound.strip.marker, 'd_bedlight'); +check('the live marker carries the explicit strip space', bound.marker?.space, 'f1'); +check('the ordinary icon is gone', await icon('d_bedlight'), false); +check('the device keeps its catalogue place', await evaluate(() => window.__card._devices.some((d) => d.id === 'd_bedlight' && d.space === 'f1'))); + +// An ordinary icon while the strip is selected: its own dialog, no LED tray. +await evaluate(() => window.__card._ledEditor.select('led-a')); +await page.waitForTimeout(100); +await evaluate(() => window.__card._editorRuntime._openMarkerDialog(window.__card._devices.find((d) => d.id === 'd_lamp'))); +await page.waitForTimeout(200); +check('an icon dialog drops the strip selection', await evaluate(() => window.__card._ledEditor.sel), null); +await evaluate(() => window.__card._editorRuntime._closeMarkerDialog()); +await page.waitForTimeout(150); +check('closing that dialog shows no LED tray', await evaluate(() => !/Delete strip/.test(window.__card.shadowRoot.querySelector('.editor-secondary')?.textContent || ''))); + +// Tray: «Show as icon» hides the shape and returns the icon. +await evaluate(() => window.__card._ledEditor.select('led-a')); +await page.waitForTimeout(150); +await page.click('[data-led-action="icon"]'); +await page.waitForTimeout(500); +check('«Show as icon» keeps the exact shape hidden', await evaluate(() => { + const s = window.__card._serverCfg.spaces[0].led_strips[0]; + return s.active === false && s.marker === 'd_bedlight' && JSON.stringify(s.points) === JSON.stringify([[0.12, 0.22], [0.4, 0.22], [0.4, 0.4]]); +})); +check('the icon is back and nothing of the strip is drawn', await icon('d_bedlight') + && await evaluate(() => !window.__card.shadowRoot.querySelector('[data-led-strip], [data-led-select]'))); +check('the switch is one command', await evaluate(() => window.__card._devicePositionHistory.undoName), 'Device representation'); +await page.keyboard.press('Control+z'); await page.waitForTimeout(500); +check('Undo shows the strip again', await evaluate(() => window.__card._serverCfg.spaces[0].led_strips[0].active !== false)); +await page.keyboard.press('Control+Shift+z'); await page.waitForTimeout(500); +check('Redo hides it again', await evaluate(() => window.__card._serverCfg.spaces[0].led_strips[0].active), false); + +// Device dialog: «Show as LED strip» restores the hidden shape at once. +await evaluate(() => window.__card._editorRuntime._openMarkerDialog(window.__card._devices.find((d) => d.id === 'd_bedlight'))); +await page.waitForTimeout(300); +check('the dialog offers unbind/delete for a hidden shape', await evaluate(() => [...window.__card.shadowRoot + .querySelectorAll('[data-led-representation] [data-led-action]')].map((b) => b.dataset.ledAction).join()), 'show-strip,unbind,delete'); +await page.click('[data-led-representation] [data-led-action="show-strip"]'); +await page.waitForTimeout(500); +check('restored without drawing, same record', await evaluate(() => { + const strips = window.__card._serverCfg.spaces[0].led_strips; + return strips.length === 1 && strips[0].id === 'led-a' && strips[0].active === true && !window.__card._ledEditor.tool; +})); +check('no HA service on any representation change', await evaluate(() => window.__ledServices.length), 0); + +// A failed write rolls back and records no command. +const undoBefore = await evaluate(() => window.__card._devicePositionHistory.undoName); +await evaluate(() => { window.__ledFail = true; }); +await evaluate(() => window.__card._ledEditor.setActive('led-a', false, 'f1')); +await page.waitForTimeout(300); +check('failed write: state unchanged', await evaluate(() => window.__card._serverCfg.spaces[0].led_strips[0].active), true); +check('failed write: no new command', await evaluate(() => window.__card._devicePositionHistory.undoName), undoBefore); +await evaluate(() => { window.__ledFail = false; }); + +// Converting a plain icon by drawing: one write, the same marker. +await evaluate(() => window.__card._editorRuntime._openMarkerDialog(window.__card._devices.find((d) => d.id === 'd_lamp'))); +await page.waitForTimeout(300); +await page.click('[data-led-representation] [data-led-action="show-strip"]'); +await page.waitForTimeout(400); +const toScreen = (x, y) => evaluate(([x, y]) => { + const c = window.__card; const r = c.shadowRoot.querySelector('.stage').getBoundingClientRect(); + const a = c._screenToVb(0, 0), b = c._screenToVb(1000, 1000); + return [r.left + (x - a[0]) / (b[0] - a[0]) * 1000, r.top + (y - a[1]) / (b[1] - a[1]) * 1000]; +}, [x, y]); +for (const [x, y] of [[150, 480], [380, 480]]) { const [sx, sy] = await toScreen(x, y); await page.mouse.click(sx, sy); await page.waitForTimeout(80); } +await page.keyboard.press('Escape'); await page.waitForTimeout(500); +check('drawing converted the lamp in one write, no picker', await evaluate(() => { + const c = window.__card; const strip = c._serverCfg.spaces[0].led_strips.find((s) => s.marker === 'd_lamp'); + return !!strip && strip.active === true && !c._ledEditor.picker && c._serverCfg.markers.filter((m) => m.id === 'd_lamp').length === 1; +})); + +// Deleting the bound marker leaves an unbound strip with its geometry. +await evaluate(async () => { + const c = window.__card; + c._confirmDanger = async () => true; + c._editorRuntime._openMarkerDialog(c._devices.find((d) => d.id === 'd_bedlight')); + await c.updateComplete; + await c._editorRuntime._deleteMarker(); +}); +await page.waitForTimeout(500); +check('deleting the marker unbinds the strip in the same write', await evaluate(() => { + const s = window.__card._serverCfg.spaces[0].led_strips.find((x) => x.id === 'led-a'); + return s.marker === null && s.active === true && s.points.length === 3; +})); + +await finish(browser); diff --git a/demo/smoke_led_strip_draw.mjs b/demo/smoke_led_strip_draw.mjs new file mode 100644 index 00000000..c58c5741 --- /dev/null +++ b/demo/smoke_led_strip_draw.mjs @@ -0,0 +1,171 @@ +/** + * #780 AC3/AC4/AC5/AC20 (ТЗ §4, §6): the LED strip tool of the Devices editor. + * Clean clicks draw a chain; a mouse pan, a second finger and a cancelled + * pointer add nothing; Ctrl+Z removes the chain's own point; Esc finishes and + * opens the device picker; a click on the first point closes the loop; leaving + * the tool finishes, never loses the chain. A new segment stops at the first + * face of a thick wall, a fast vertex drag cannot jump it. The selection lives + * in the session only, its tray closes on Esc and on a clean background click. + * Plan/Background have no LED tool, handles or targets. + */ +import { launch, check, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 1000, height: 820 }, 1); +const evaluate = (fn, arg) => page.evaluate(fn, arg); + +await evaluate(async () => { + const c = window.__card; + window.__ledSaves = []; + const orig = c.hass.callWS; + c.hass.callWS = async (m) => { + if (m.type === 'houseplan/config/set') { + window.__ledSaves.push(JSON.parse(JSON.stringify(m.config))); + return { ok: true, rev: (c._cfgRev || 0) + 1 }; + } + return orig.call(c.hass, m); + }; + // A thick partition across a plain room: the placement body of AC5. + c._serverCfg = { model_version: 10, spaces: [{ id: 'led', title: 'LED', cell_cm: 5, view_box: [0, 0, 1, 0.7], + rooms: [{ id: 'room', name: 'Room', area: null, poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.6], [0.1, 0.6]] }], + wall_segments: [], partitions: [{ id: 'p1', a: [0.5, 0.15], b: [0.5, 0.55], cm: 40 }] }], markers: [], settings: {} }; + c._layout = {}; c._space = 'led'; c._modelCache = null; c._frame = null; c._cfgEpoch++; + c._setMode('plan'); await c.updateComplete; +}); +await page.waitForTimeout(300); +check('plan editor has no LED tool', await evaluate(() => !window.__card.shadowRoot.querySelector('[data-tool="led-strip"]'))); +await evaluate(async () => { window.__card._setMode('devices'); await window.__card.updateComplete; }); +await page.waitForTimeout(300); +check('devices editor has the LED tool next to Add', await evaluate(() => { + const tools = [...window.__card.shadowRoot.querySelectorAll('.devbar [data-hp="tool"]')].map((b) => b.dataset.tool); + return tools.indexOf('led-strip') === tools.indexOf('add-device') + 1; +})); +check('no LED chunk before the tool', await evaluate(() => window.__card._ledEditor === null)); + +const toScreen = (x, y) => evaluate(([x, y]) => { + const c = window.__card; const r = c.shadowRoot.querySelector('.stage').getBoundingClientRect(); + const a = c._screenToVb(0, 0), b = c._screenToVb(1000, 1000); + return [r.left + (x - a[0]) / (b[0] - a[0]) * 1000, r.top + (y - a[1]) / (b[1] - a[1]) * 1000]; +}, [x, y]); +const clickAt = async (x, y) => { const [sx, sy] = await toScreen(x, y); await page.mouse.click(sx, sy); await page.waitForTimeout(80); }; +const chain = () => evaluate(() => window.__card._ledEditor?.chain?.points.length ?? null); +const strips = () => evaluate(() => window.__card._serverCfg.spaces[0].led_strips || []); + +await page.click('[data-tool="led-strip"]'); +await page.waitForTimeout(500); +check('tool armed, capture layer over icons', await evaluate(() => !!window.__card._ledEditor?.tool + && !!window.__card.shadowRoot.querySelector('[data-hp-led-capture]'))); +check('touch-safe start hint in the tray', await evaluate(() => /Click to start the strip/.test( + window.__card.shadowRoot.querySelector('.editor-secondary')?.textContent || ''))); + +await clickAt(200, 200); +await clickAt(400, 200); +check('two clean clicks, two points', await chain(), 2); +// A mouse pan: press, travel past the slop, release. +{ + const [sx, sy] = await toScreen(400, 300); + await page.mouse.move(sx, sy); await page.mouse.down(); await page.mouse.move(sx + 60, sy + 10, { steps: 6 }); await page.mouse.up(); + await page.waitForTimeout(100); +} +check('a pan adds no point', await chain(), 2); +// A pinch: a second finger joins, the release adds nothing. +await evaluate(() => { + const layer = window.__card.shadowRoot.querySelector('[data-hp-led-capture]'); + const ev = (type, id, x) => layer.dispatchEvent(new PointerEvent(type, { pointerId: id, pointerType: 'touch', clientX: x, clientY: 300, bubbles: true, button: 0 })); + ev('pointerdown', 11, 300); ev('pointerdown', 12, 340); ev('pointerup', 11, 300); ev('pointerup', 12, 340); + ev('pointerdown', 13, 360); ev('pointercancel', 13, 360); ev('pointerup', 13, 360); +}); +check('pinch and cancel add no point', await chain(), 2); +await clickAt(400, 400); +check('third point', await chain(), 3); +await page.keyboard.press('Control+z'); +check('Ctrl+Z removes the chain’s own point', await chain(), 2); +check('nothing written while drawing', await evaluate(() => window.__ledSaves.length), 0); +await page.keyboard.press('Escape'); +await page.waitForTimeout(400); +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.click('[data-led-action="later"]'); +await page.waitForTimeout(200); +check('Later keeps the unbound strip, selected with its tray', await evaluate(() => { + const c = window.__card; + return c._serverCfg.spaces[0].led_strips.length === 1 && !!c._ledEditor.sel + && /Strip is not bound to a device/.test(c.shadowRoot.querySelector('.editor-secondary')?.textContent || ''); +})); +check('unbound strip is a grey dashed mark in Devices', await evaluate(() => !!window.__card.shadowRoot.querySelector('[data-led-unbound]'))); + +// AC5: a new segment stops at the first face of the 40 cm partition. +await page.click('[data-tool="led-strip"]'); await page.waitForTimeout(200); +await clickAt(300, 450); +await clickAt(700, 450); +const clamp = await evaluate(() => window.__card._ledEditor.chain.points.map((p) => p.map((v) => Math.round(v * 10) / 10))); +check('segment stopped at the partition face', clamp[1][0] < 500 && clamp[1][0] > 470 && clamp[1][1] === 450); +// Leaving the tool finishes the chain without an extra segment. +await page.click('[data-tool="led-strip"]'); await page.waitForTimeout(400); +check('leaving the tool finished the chain', (await strips()).length, 2); +await page.keyboard.press('Escape'); await page.waitForTimeout(100); + +// A click on the first point (≥3 vertices) closes the strip. +await page.click('[data-tool="led-strip"]'); await page.waitForTimeout(200); +await clickAt(150, 150); await clickAt(250, 150); await clickAt(250, 250); await clickAt(150, 152); +await page.waitForTimeout(400); +const loop = (await strips())[2]; +check('closed by a click on the first point', !!loop && JSON.stringify(loop.points[0]) === JSON.stringify(loop.points.at(-1)) && loop.points.length === 4); +await page.keyboard.press('Escape'); await page.waitForTimeout(100); + +// AC5: a fast vertex drag across the partition keeps the last safe position. +await evaluate(() => window.__card._ledEditor.select(window.__card._serverCfg.spaces[0].led_strips[0].id)); +await page.waitForTimeout(200); +const handle = await evaluate(() => { const r = window.__card.shadowRoot.querySelector('[data-led-handle="1"]').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }); +const far = await toScreen(800, 200); +await page.mouse.move(handle[0], handle[1]); await page.mouse.down(); await page.mouse.move(far[0], far[1], { steps: 2 }); await page.mouse.up(); +await page.waitForTimeout(400); +const dragged = (await strips())[0].points[1]; +check('vertex drag cannot jump the wall', dragged[0] < 0.5 && dragged[0] > 0.45); +check('the drag is one LED history command', await evaluate(() => window.__card._devicePositionHistory.undoName), 'LED strip shape'); +await page.keyboard.press('Control+z'); await page.waitForTimeout(400); +check('Undo restores the shape', JSON.stringify((await strips())[0].points), JSON.stringify([[0.2, 0.2], [0.4, 0.2]])); + +// The selection is session state: Esc and a clean background click close the tray. +await evaluate(() => window.__card._ledEditor.select(window.__card._serverCfg.spaces[0].led_strips[0].id)); +const savesBefore = await evaluate(() => window.__ledSaves.length); +await page.keyboard.press('Escape'); await page.waitForTimeout(100); +check('Esc drops the selection', await evaluate(() => window.__card._ledEditor.sel), null); +await evaluate(() => window.__card._ledEditor.select(window.__card._serverCfg.spaces[0].led_strips[0].id)); +await page.waitForTimeout(100); +await clickAt(700, 550); +check('a clean background click drops the selection', await evaluate(() => window.__card._ledEditor.sel), null); +check('dropping the selection writes nothing', await evaluate(() => window.__ledSaves.length), savesBefore); + +// «Optimize plans» reports a strip that passes through the partition and changes nothing in it. +const optimizeNote = await evaluate(async () => { + const c = window.__card; + const space = c._serverCfg.spaces[0]; + space.led_strips = [...space.led_strips, { id: 'through', points: [[0.3, 0.3], [0.7, 0.3]], marker: null }]; + c._modelCache = null; c._frame = null; c._cfgEpoch++; + await c.updateComplete; + c._editorRuntime.optimizePlans.open(); + await c.updateComplete; + for (let i = 0; i < 40 && !c.shadowRoot.querySelector('[data-led-walls]'); i++) { + await new Promise((r) => setTimeout(r, 50)); + c.requestUpdate(); await c.updateComplete; + } + const note = c.shadowRoot.querySelector('[data-led-walls]'); + const kept = JSON.stringify(c._alignDialog?.config?.spaces?.[0]?.led_strips?.find((s) => s.id === 'through')?.points); + c._alignDialog = null; await c.updateComplete; + return { n: note?.dataset.ledWalls, text: note?.textContent || '', kept }; +}); +check('optimize reports strips through walls', optimizeNote.n, '1'); +check('the report names the space', /Strips passing through walls: 1 \(LED\)/.test(optimizeNote.text)); +check('optimize keeps the strip as drawn', optimizeNote.kept, JSON.stringify([[0.3, 0.3], [0.7, 0.3]])); + +// Plan/Background: passive translucent marks only, no handles, no targets. +await evaluate(async () => { window.__card._setMode('decor'); await window.__card.updateComplete; }); +await page.waitForTimeout(300); +check('Background editor: no LED editor layer or tool', await evaluate(() => { + const r = window.__card.shadowRoot; + return !r.querySelector('[data-hp-led-editor]') && !r.querySelector('[data-tool="led-strip"]') && !r.querySelector('[data-led-handle]'); +})); + +await finish(browser); diff --git a/demo/smoke_led_strip_glow.mjs b/demo/smoke_led_strip_glow.mjs new file mode 100644 index 00000000..a5d4b7ff --- /dev/null +++ b/demo/smoke_led_strip_glow.mjs @@ -0,0 +1,135 @@ +/** + * #780 AC4/AC7/AC12/AC14/AC17 (ТЗ §3, §5, §7, §8, §13.1): the LED strip in + * 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 + * click toggles once, a touch pan calls nothing. The static card is passive + * and computes no field with `light_pools: false`. + */ +import { launch, check, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 1000, height: 820 }, 1); +const ledRequests = []; +page.on('request', (request) => { if (/led-strip-(runtime|field|editor)-/.test(request.url())) ledRequests.push(request.url().replace(/.*\//, '')); }); +const evaluate = (fn, arg) => page.evaluate(fn, arg); +const settle = () => page.waitForTimeout(500); +const setStrips = (strips, settings) => evaluate(async ([strips, settings]) => { + await window.__hpTest.setServerConfig((cfg) => { + cfg.spaces[0].led_strips = strips; + cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), ...settings }; + cfg.markers = [ + ...(cfg.markers || []).filter((m) => !['d_light1', 'd_lamp'].includes(m.id)), + { id: 'd_light1', binding: 'device:d_light1', space: 'f1' }, + { id: 'd_lamp', binding: 'device:d_lamp', space: 'f1' }, + ]; + }); +}, [strips, settings]); +const setState = (entity, state, attributes = {}) => evaluate(async ([entity, state, attributes]) => { + const c = window.__card; const st = c.hass.states[entity]; + c.hass = { ...c.hass, states: { ...c.hass.states, [entity]: { ...st, state, attributes: { ...st.attributes, ...attributes } } } }; + await c.updateComplete; +}, [entity, state, attributes]); +const stripe = (marker) => evaluate((marker) => { + const g = window.__card.shadowRoot.querySelector(`[data-led-strip][data-marker="${marker}"]`); + return g ? { state: g.dataset.state, core: g.querySelector('.led-core')?.getAttribute('stroke'), + dash: g.querySelector('.led-core')?.getAttribute('stroke-dasharray') || null } : null; +}, marker); +const field = () => evaluate(() => window.__card.shadowRoot.querySelectorAll('[data-led-field]').length); + +await settle(); +check('no strips: no LED chunk requested', ledRequests.length, 0); +await setStrips([{ id: 'hidden', points: [[0.12, 0.40], [0.40, 0.40]], marker: 'd_lamp', active: false }, + { id: 'loose', points: [[0.12, 0.45], [0.40, 0.45]], marker: null }], { glow_enabled: true }); +await settle(); +check('hidden shape and unbound strip: still no LED chunk', ledRequests.length, 0); +check('a hidden shape leaves the ordinary icon', await evaluate(() => !!window.__card.shadowRoot.querySelector('.dev[data-id="d_lamp"]'))); + +await setStrips([{ id: 'ceiling', points: [[0.12, 0.30], [0.40, 0.30], [0.40, 0.45]], marker: 'd_light1' }, + { id: 'loose', points: [[0.12, 0.45], [0.30, 0.45]], marker: null }], { glow_enabled: true }); +await settle(); +check('a displayed active strip loads the runtime', ledRequests.some((url) => url.startsWith('led-strip-runtime-'))); +check('the editor chunk is not loaded in the View', ledRequests.some((url) => url.startsWith('led-strip-editor-')), false); +check('the icon is replaced by the strip', await evaluate(() => !window.__card.shadowRoot.querySelector('.dev[data-id="d_light1"]'))); +check('unbound strip is absent from the View', await evaluate(() => window.__card.shadowRoot.querySelectorAll('[data-led-strip]').length), 1); +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 || {})))); +check('on with Glow: white core and a field', JSON.stringify([await stripe('d_light1'), await field() > 0]), + JSON.stringify([{ state: 'on', core: '#FFFFFF', dash: null }, true])); + +// One target over the whole length: a click toggles exactly once. +const calls = await evaluate(async () => { + const c = window.__card; const log = []; + const service = c.hass.callService; + window.__ledToggle = log; + c.hass = { ...c.hass, callService: async (d, s, data) => { log.push(`${d}.${s}:${data?.entity_id}`); return service(d, s, data); } }; + await c.updateComplete; + return log.length; +}); +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(); +check('a click on the stripe toggles once', await evaluate(() => window.__ledToggle.length), calls + 1); +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])); +// A pan along the stripe calls nothing; the next clean click works at once. +const along = (k) => evaluate((k) => { + const el = window.__card.shadowRoot.querySelector('.led-hit'); + const p = el.getPointAtLength(el.getTotalLength() * k), m = el.getScreenCTM(); + return [p.x * m.a + m.e, p.y * m.d + m.f]; +}, k); +{ + const [x0, y0] = await along(0.3), [x1, y1] = await along(0.45); + await page.mouse.move(x0, y0); await page.mouse.down(); + await page.mouse.move(x1, y1, { steps: 8 }); await page.mouse.up(); +} +await settle(); +const ceiling = () => evaluate(() => window.__card.hass.states['light.ceiling'].state); +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(); +check('the next clean click works at once', await ceiling(), 'on'); +{ + const [x, y] = await along(0.6); + await page.mouse.click(x, y); +} +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])); +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(); +const colored = await stripe('d_light1'); +check('on without Glow: coloured core, no field', JSON.stringify([colored?.state, colored?.core !== '#FFFFFF', await field()]), + JSON.stringify(['on', true, 0])); + +// The static card: passive; the field only with light_pools. +await setStrips(await evaluate(() => window.__card._serverCfg.spaces[0].led_strips), { glow_enabled: true }); +const staticCards = await evaluate(async () => { + await customElements.whenDefined('houseplan-space-card'); + const host = document.createElement('div'); host.style.width = '700px'; document.body.appendChild(host); + const make = (pools) => { const el = document.createElement('houseplan-space-card'); el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: pools }); el.hass = window.__card.hass; host.appendChild(el); return el; }; + const plain = make(false), pools = make(true); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + for (let i = 0; i < 40; i++) { + await wait(50); + if (plain.shadowRoot?.querySelector('[data-led-strip]') && pools.shadowRoot?.querySelector('[data-led-field]')) break; + } + const read = (el) => ({ stripe: !!el.shadowRoot?.querySelector('[data-led-strip]'), + field: !!el.shadowRoot?.querySelector('[data-led-field]'), hit: !!el.shadowRoot?.querySelector('.led-hit') }); + return { plain: read(plain), pools: read(pools) }; +}); +check('static card without light_pools: passive stripe, no field', JSON.stringify(staticCards.plain), JSON.stringify({ stripe: true, field: false, hit: false })); +check('static card with light_pools: the field', JSON.stringify(staticCards.pools), JSON.stringify({ stripe: true, field: true, hit: false })); + +await finish(browser); diff --git a/demo/stand/README.md b/demo/stand/README.md index c8c8bc1c..5da06d65 100644 --- a/demo/stand/README.md +++ b/demo/stand/README.md @@ -87,6 +87,16 @@ Everything is in English; the demo user's language is Auto, so the interface follows the browser. **Smart Plug 2** is deactivated in the HA registry on purpose — it is the ready-made example for the disabled-devices behaviour. +**LED strips (#780).** The synthetic light `light.demo_led_strip` (device +`demo-led-strip`) and its three siblings `light.demo_led_strip_wall`, +`light.demo_led_strip_corner` and `light.demo_led_strip_loop` carry the +designer's four reference strips — free, on a wall face, along a corner, +closed loop — in the `golden-led` space of the visual fixture +(`demo/fixtures/visual-matrix.mjs`, `makeVisualMatrixFixture({ ledStrips: true })`), +which the LED golden scenes and the design acceptance use. On the stand seed +the same entity id is the place for a strip drawn with the Devices editor's +«LED strip» tool; the hourly reset removes whatever a visitor draws. + The public demo user is an administrator, so the full registry scenario can be checked on the stand; a limited/read-only user needs the local harness or a separate unprivileged user. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a735dc64..36c866b9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -689,3 +689,22 @@ compatibility export surface over `mutation-registry.mjs` (declarations), runs); dependencies never point back to the CLI. Guard-input caching is invocation-scoped (one resolver, one tracked-file snapshot); persisted success exists only in the explicit caught-witness ledger. Usage: [TESTING](TESTING.md). + +## LED strips: lazy boundaries (#780) + +`space.led_strips` belongs to the space; the link is one-way strip → marker +and the device model stays the only owner of state and services. Four modules: + +| Module | Graph | Holds | +|---|---|---| +| `led-strip-gate.ts` | initial | which markers a space shows as a strip, the anchor, the page-wide loaders of both chunks | +| `led-strip-card.ts` | initial | delegation only: the Devices toolbar button, the device-dialog section, notes, the LED branch of the device history | +| `led-strip-runtime.ts` (+ `led-strip-geometry.ts`) | lazy `led` | frame, stripe, hit/focus, 2.5D, static card | +| `led-strip-field.ts` | lazy `led-field` | the linear field, loaded by the runtime only for an on strip in a Glow room | +| `led-strip-editor.ts` (+ `i18n/led`) | lazy `led-editor` | the Devices tool, tray, picker, representation switch, LED history commands | + +A View without a displayed active strip, and the Devices editor without the +tool or an editable strip, load none of them. Each chunk checks the entry +build fingerprint; a failed load is fail-dark for the strips only and retried +on the next explicit entry. Budgets: `LAZY_LED_GZIP_CEILING` and +`LAZY_LED_EDITOR_GZIP_CEILING` in `scripts/bundle-budget.mjs`. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 03607c61..3cf4b8f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- LED strips: in the Devices editor, the new «LED strip» tool next to «Add» + draws a strip on the plan point by point — it snaps to the grid and to wall + faces and stops at walls, windows and columns, passing doors and passages. + Bind it to a light (or any device) and the strip replaces the device's icon: + a two-stroke stripe that, when the light is on with Glow, casts a soft + continuous band of its colour (50 cm by default) that does not pass through + walls or closed doors; without Glow the stripe itself takes the light's + colour. In the View the whole strip is one tap target, also in 2.5D and on + the space card (light only with `light_pools`). Any device can switch between + «Show as LED strip» and «Show as icon» in its settings; the shape is kept + and comes back without redrawing. Drawing, editing vertices and switching are + part of Undo/Redo; full and space exports keep the strips, a deleted device + leaves an unbound strip, and «Optimize plans» reports strips passing through + walls ([#780](https://github.com/Matysh/houseplan-card/issues/780)). + ## v1.79.0-beta.2 — 2026-10-02 - Recreating the card in Home Assistant now keeps the editor camera steady and the diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index cb21f0dd..02ba405f 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,22 @@ ## Не выпущено +- LED-ленты: в редакторе устройств рядом с «Добавить» появился инструмент + «LED-лента» — лента рисуется на плане по точкам, притягивается к сетке и к + граням стен и упирается в стены, окна и колонны, проходя через двери и + проёмы. Привяжите её к светильнику (или любому устройству) — и лента заменит + значок устройства: полоса из двух штрихов, а при включённом свете с Glow — + мягкая непрерывная полоса света его цвета (по умолчанию 50 см), которая не + проходит сквозь стены и закрытые двери; без Glow цвет света принимает сама + полоса. В просмотре вся лента — одна цель нажатия, в том числе в 2.5D и в + карточке пространства (свет — с `light_pools`). Любое устройство + переключается между «Показывать LED-лентой» и «Показывать значком» в своих + настройках; форма сохраняется и возвращается без перерисовки. Рисование, + правка вершин и переключение входят в Отменить/Повторить; полный экспорт и + перенос пространства сохраняют ленты, удаление устройства оставляет ленту + непривязанной, а «Оптимизировать планы» сообщает число лент, проходящих + сквозь стены ([#780](https://github.com/Matysh/houseplan-card/issues/780)). + ## v1.79.0-beta.2 — 2026-10-02 - При техническом пересоздании карточки Home Assistant сохраняются положение плана в редакторе diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index a232e9b8..9861adb8 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -1117,3 +1117,35 @@ authentication. Plans and marker files are served only by the authenticated Stored configs may still hold older `/houseplan_files/plans|files/…` URLs: `contentUrl()` rewrites them on every read and portable import accepts both prefixes, so there is no storage migration. + +## LED strips: `space.led_strips` (#780) + +```ts +led_strips?: Array<{ id: string; points: [number, number][]; marker: string | null; active?: boolean }>; +``` + +- Optional; absent = no strips; no `model_version` bump. Coordinates are the + space's normalised coordinates (scalar JSON-noise cleanup only, no lattice + snap — a strip lies on real wall faces). +- 2–50 points (a closed strip repeats its first point and needs three distinct + vertices), non-zero length, finite coordinates; at most 50 records per space + **including** hidden shapes; unique `id` per space; at most one record per + marker in the whole config. `active` is a strict boolean; `false` needs a + marker. +- **Write-path normalisation** (`led_strips.py`, after structure/duplicate + checks, before referential validation, one transaction): a link to a marker + that is missing (deleted, tombstoned) becomes `marker: null, active: true` + with id and points kept; an existing live marker with an empty `space` + adopts the strip's space. A non-empty foreign `space` or a second link + still rejects the whole write. `config/set` reports the counts + `{led_strips: {unbound, space_adopted}}`; older clients may ignore them. +- **Older clients** keep the unknown array on an ordinary save; deleting a + bound marker succeeds and leaves an unbound strip. +- **Transfer.** Full export/import keeps geometry, links and `active`. A + space import remaps links through the same marker-id map as the devices; + a strip whose marker did not travel (skipped duplicate, absent) arrives + unbound — the count is in the import summary. «Plan only» export drops every + link (`marker: null, active: true`). +- **Diagnostics** count strips, unbound and hidden ones only — no ids, + coordinates, room names or HA identifiers. +- **Optimize plans** reports strips passing through walls and never edits them. diff --git a/docs/DEVICE-PRESENTATION.md b/docs/DEVICE-PRESENTATION.md index 079eefe7..f1c49b39 100644 --- a/docs/DEVICE-PRESENTATION.md +++ b/docs/DEVICE-PRESENTATION.md @@ -110,6 +110,22 @@ cover → light sources → device role, шторы и медиаплееры) только тогда, когда меняет победившее решение или наблюдаемый результат; полное декартово произведение binding × source × display × activity запрещено. +## LED strip representation (#780) + +A marker may be shown as an LED strip instead of an icon (`space.led_strips`, +`active` ≠ `false`). It is the same device: name, `hidden`, light source and +state, `tap_*`, confirmation, `controls`, light colour/brightness/radius and +the value badge keep working; the individual `display`, `size`, `angle`, +`ripple_*` neither shape the strip nor get erased, and apply again on «Show as +icon». While shown as a strip the icon is not drawn, its saved position and +auto-grid slot are not used; the anchor — the point at half the polyline +length — replaces the position for the room, tooltip, label and badge (an +explicit valid `room_id` wins). A hidden shape (`active: false`) changes +nothing: the icon works normally. No pulses and no alarm red on the stripe. +`hidden` / HA-disabled suppress the stripe, its target and its light like an +icon; temporarily unavailable keeps it as a dashed grey stripe. In the device +catalogue the marker stays «On the plan». + ## Implementation notes - `device-value-badge.ts` owns candidate discovery, source keys, HA formatting, diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index 1462262f..2f5b7a53 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -292,6 +292,16 @@ Room names remain screen-facing and lose stroke, text shadow, drop shadow and halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one; contrast comes from colour, never an outline. +## LED strips in 2.5D (#780) + +An LED stripe is projected with the plan like a device tile: the body is +raised by `ISO_TILE.lift` (0.075 D), its edge is swept `ISO_TILE.depth` +(0.1 D) below in `isoEdgeColor`, and an inert blurred floor shadow from +`isoTileShadow` follows theme and floor; D takes the shared `ISO_ICON_SCALE`. +The linear field stays on the floor plane; the hit path moves with the raised +body. Everything is in plan units, so zoom never detaches vertices or the +target. Editors and `houseplan-space-card` keep Flat. + ## Stage 6: public mode, tiles, sun and materials (#649) The visual language and the numbers come from the designer lab (sketch 07, diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 29c75a2a..178107b2 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -409,3 +409,42 @@ slower and remains a performance watch item. During pinch/pan and the bounded 500 ms source fade, the whole-layer blur is bypassed and its parameters stay frozen; the final screen-space feather is restored once after the transition instead of rebuilding and evaluating the filter for every animation frame. + +## LED strips: a linear source (#780) + +A marker shown as an LED strip is one source whose emitter is the whole +polyline (`space.led_strips`, ТЗ #780). It never also paints a round pool from +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 + 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 + with `lighten` inside one isolated group, so corners and the closing of a + loop neither seam nor double the brightness. Intensity and the 500 ms fade + are the shared `glowAlpha` / `GLOW_FADE_MS`. +- **Visibility.** The strip is cut into consecutive pieces no longer than the + radius; each piece is clipped to the visibility fans of its own emitters + (the shared `visibilityPolygon` over the same barrier scene as pools; the + fans are separate paths of one clipPath, no boolean pass per piece), and the + whole field layer is clipped once to the floor. A piece with no occluder + within the radius needs no fan at all. Windows, columns, + thick walls and Solid zero walls block; doors/gates pass by their actual + opening; Dashed zero walls are transparent. Emitters on a thick face sit + `epsilonGeom` (0.001 cm) outward into free floor; a part buried in a body + emits nothing; a strip entirely inside a wall has no field. +- **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. + 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); + Plan/Background show a passive translucent stripe. `houseplan-space-card` + draws the passive stripe always and the field only with `light_pools: true` + — with the option off no barrier, visibility or timer is created. +- **Laziness.** The stripe/hit/2.5D code (`led-strip-runtime`) and the field + (`led-strip-field`) are separate lazy chunks; the initial graph holds only + the presence check and the loader (`led-strip-gate`). Caches are bounded per + space (50 shapes, 50 visibilities) and released on space change. diff --git a/docs/TOUCH-SUPPORT.md b/docs/TOUCH-SUPPORT.md index 4a3d0f08..74f3134e 100644 --- a/docs/TOUCH-SUPPORT.md +++ b/docs/TOUCH-SUPPORT.md @@ -178,6 +178,16 @@ cannot operate stale geometry; leaving the editor is always a single safe action. The decorative smoothness of the editor chrome remains best effort on coarse-pointer devices, while the correct final View frame is release-blocking. +### LED strip tool (#780) + +The LED tool applies the same safety floor as the wall chain: only a clean +tap adds a point; pan, pinch, a second finger, `pointercancel`, a lost +capture and the synthetic click after navigation add nothing, finish nothing +and open no picker. The touch hint does not require keyboard modifiers: +switching the tool off finishes the chain. In the View the whole stripe is +one target with `max(22 CSS px, t/2)` hit radius; a pan or pinch over it calls +no action, long press or more-info, and the next clean tap works at once. + ## Deliberate degradation rule When an editor change would be expensive to implement correctly for touch, the diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index bc45f0d2..2ba44397 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1041,6 +1041,35 @@ binding to **Available again**. A disabled or missing binding keeps its saved category and receives a separate Home Assistant status instead of silently moving to another tab. +### LED strip (#780) + +An LED strip is drawn on the plan as a polyline and can stand in for any +device instead of its round icon. Everything happens in the **Devices +editor** — there is no need to switch to the Plan editor. + +| Action | Result | +|---|---| +| **LED strip** (next to «Add») | Starts drawing. A clean click adds a point; the grid and wall faces attract it, a zero-thickness wall its axis; `Shift` gives 45° steps. A new segment stops at the first face of a thick wall, partition, column or window («The strip stopped at a wall»); doors, gates and passages let it through | +| `Ctrl/Cmd+Z` while drawing | Removes the chain's last point, not another history step | +| `Esc`, a double click on the last point, pressing **LED strip** again, leaving the editor | Finish the strip; a chain of fewer than two distinct points is not saved | +| A click on the first point (at least three vertices) | Closes the strip | +| After finishing | The device picker opens: lights first, devices bound to another strip are disabled with an explanation, **New device…** creates one and binds it in the same write, **Later** keeps an unbound strip (grey dashes, visible in the Devices editor only) | +| A click on a strip | Selects it and shows its vertices; they can be dragged, and walls stop them even on a fast drag. The tray offers **Device settings**, **Bind / Change device**, **Unbind**, **Show as icon**, **Delete strip**. `Esc` or a clean click on free space drops the selection | +| In the device dialog | **Show as LED strip** draws a shape for the same marker (or brings a saved one back at once); **Show as icon** returns the ordinary icon, the shape is kept hidden and comes back without redrawing. The usual unsaved-changes question comes first | + +Binding creates no new Home Assistant device: the same marker, light +settings, tap actions and badge. The strip glows when the device does: with +Glow on, a white core and a soft band of colour 50 cm wide by default (or the +device's own radius); without Glow the core takes the light's colour. Light +does not pass through walls or closed doors. In View the whole strip is one +tap target, like an icon. Every finished shape change and every switch of the +representation is one **Undo/Redo** step. + +«Unbind» keeps an unbound shape, «Delete strip» deletes the shape but not the +device. Deleting the device leaves the strip unbound. Up to 50 points per +strip and 50 strips per space (hidden shapes included). «Optimize plans» +reports strips passing through walls and changes nothing in them. + ### Marker basics The dialog has five cards: **Basics**, a compact tap-action card without a @@ -2154,6 +2183,7 @@ Marker attachments accept PDF/PNG/JPG/WebP/TXT up to 50 MB, with a | Room details use hover in View | A touch-only user may need an editor or another visible metric | | Sun has no exterior 3D model | It cannot know shadows from trees, awnings or neighbouring structures | | Icon rules use regular expressions | First matching rule wins and invalid expressions are rejected | +| LED strips are not cut by walls | A thickened wall does not edit a strip: parts inside the wall do not glow, «Optimize plans» shows how many strips pass through walls | Storage guards allow up to 50 spaces, 400 rooms per space, 2,000 markers, 500 openings, 1,000 Background items and bounded wall/physical-object catalogues diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 5a1a3bb5..571dd166 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1137,6 +1137,35 @@ binding tombstone не вырезает её из живого устройст ![Живой предпросмотр выбранного отображения устройства](images/06-device-display-preview.png) +### LED-лента (#780) + +Светодиодную ленту можно нарисовать на плане как ломаную и показать ею любое +устройство вместо круглого значка. Всё делается в **редакторе устройств** — +в редактор плана переходить не нужно. + +| Действие | Результат | +|---|---| +| **LED-лента** (рядом с «Добавить») | Включает рисование. Чистый клик ставит точку; сетка и грани стен притягивают точку, у стены нулевой толщины — её ось; `Shift` даёт шаг 45°. Новый отрезок упирается в первую грань толстой стены, перегородки, колонны или окна («Лента упёрлась в стену»); двери, ворота и проходы пропускают | +| `Ctrl/Cmd+Z` во время рисования | Убирает последнюю точку цепочки, а не чужое действие истории | +| `Esc`, двойной клик по последней точке, повторное нажатие **LED-лента**, выход из редактора | Завершают ленту; цепочка меньше двух различных точек не сохраняется | +| Клик по первой точке (не меньше трёх вершин) | Замыкает ленту | +| После завершения | Открывается выбор устройства: сначала светильники, занятые другой лентой недоступны с пояснением, **Новое устройство…** создаёт его и привязывает в той же записи, **Позже** оставляет непривязанную ленту (серый пунктир, видна только в редакторе устройств) | +| Клик по ленте | Выделяет её и показывает вершины; их можно перетаскивать, упор в стены действует и при быстром перетаскивании. Внизу — лоток: **Настройки устройства**, **Привязать / Сменить устройство**, **Отвязать**, **Показывать значком**, **Удалить ленту**. `Esc` или чистый клик по свободному месту снимают выделение | +| В диалоге устройства | **Показывать LED-лентой** — нарисовать форму для этого же маркера (или сразу вернуть сохранённую); **Показывать значком** — вернуть обычный значок, форма сохраняется скрыто и восстанавливается без перерисовки. Перед этим действует обычный вопрос о несохранённых изменениях | + +Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же +настройки света, действия по нажатию и бейдж. Лента светится, когда светится +устройство: при включённом Glow — белое ядро и мягкая цветная полоса света +радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета +источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента +— одна цель нажатия по всей длине, как значок. Каждое законченное изменение +формы и смена представления — один шаг **Отменить/Повторить**. + +«Отвязать» оставляет непривязанную форму, «Удалить ленту» удаляет форму, но не +устройство. Удаление устройства оставляет ленту непривязанной. Не более 50 точек +в ленте и 50 лент в пространстве (включая скрытые формы). «Оптимизировать +планы» сообщает число лент, проходящих сквозь стены, и ничего в них не меняет. + ### Основные настройки маркера Диалог устройства собран в пять карточек: **Основное**, компактная карточка @@ -2406,6 +2435,7 @@ cycle: 0 | Редакторы desktop-first | На touch-устройствах функции редактирования могут быть неудобны, ограничены или отсутствовать; для создания и обслуживания плана используйте компьютер | | Солнце не знает внешнюю 3D-геометрию | Нет теней от соседних крыльев, навесов и деревьев | | Иконки по регулярным выражениям | Правила мощные, но требуют знания regex; первая совпавшая строка побеждает | +| LED-лента не режется по стенам | После утолщения стены форма не правится сама: участки внутри стены не светятся, «Оптимизировать планы» показывает число таких лент | Технические пределы защиты хранилища: до 50 пространств, 400 комнат на пространство, 2000 маркеров, 500 проёмов, 1000 элементов декора и ограниченные текущей схемой каталоги стен/физических объектов. Конфигурационный пакет ограничен 2 МБ. diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index b43786de..c91b1287 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -249,6 +249,22 @@ layer you cannot see is a layer you cannot edit. 👁 "Show hidden" (local editor tool; replaced the shared show-all toggle), ⬡ icon rules. +- **LED strip** (#780) — the only place a strip is drawn or edited. The tool + sits next to «Add»; it is a temporary drawing mode inside Devices, not a + separate editor. Clean clicks add points (pan, pinch, a second finger, + cancel and the synthetic click after navigation never do), snapping to the + grid and to physical wall faces / zero-wall axes; a new segment stops at + the first face of masonry, a partition, a column or a window. `Ctrl/Cmd+Z` + removes the chain's own point first, `Esc` finishes. A finished strip opens + the device picker. A clicked strip is selected (session state only) and + shows vertex handles and the LED branch of the secondary tray. An ordinary + icon clicked while a strip is selected still opens its own dialog. The + device dialog switches the representation («Show as LED strip» / «Show as + icon»). Geometry and representation changes are LED commands of the same + device history; the stack still never absorbs marker settings or + Plan/Background edits. Plan and Background show strips as a passive + translucent mark only — no tool, handles or targets. + ## Background — the decor underlay - Toolbar tools: Select / optional Plan backdrop / Line / Rectangle / Oval / diff --git a/docs/design/led-strips/ACCEPTANCE.md b/docs/design/led-strips/ACCEPTANCE.md new file mode 100644 index 00000000..0f1538e7 --- /dev/null +++ b/docs/design/led-strips/ACCEPTANCE.md @@ -0,0 +1,72 @@ +# #780 · LED strips — acceptance against the designer frames + +AC8/AC18 of [#780](https://github.com/Matysh/houseplan-card/issues/780). The +product reproduces the designer's four strips — a free straight strip (blue), +a strip on the inner face of the bottom wall (violet), a polyline along the +wall faces around a corner (yellow) and a closed rectangle (green) — on a +synthetic plan with the reference floor `#868D94`. Scene colours +`#80D5FF`, `#E680FF`, `#FFEA80`, `#58FF58` are values of the four sources, not +a product palette. + +## How the frames were made + +- **Designer** — `source/previews/Led-On.png`, `source/previews/Led-Off.png` + (2543 × 1572, unchanged). +- **Product** — golden scenes `led-strip-design-reference-on-light` and + `led-strip-design-reference-off-light` (`demo/golden/matrix.mjs`, fixture + `makeVisualMatrixFixture({ ledStrips: true })` in + `demo/fixtures/visual-matrix.mjs`: viewport 1000 × 760, DPR 1, English, light + theme, `icon_size` 3.4, 20 cm walls, cell 5 cm). Captured with + `node demo/golden/run.mjs --mode=capture --scenario=` on the built bundle; + the reviewed baselines are accepted from the Linux CI artifact of the task + (label `ci:golden`). + +The plans differ (the designer frame is a fragment of a real floor with +furniture and icons), so the comparison is of the visual language and the four +behaviours, not of positions. + +## On + +![Designer Led On (left) and product (right)](pairs/pair-on.png) + +## Off + +![Designer Led Off (left) and product (right)](pairs/pair-off.png) + +## ТЗ §3 — visual contract + +| Requirement | Product | Evidence | +|---|---|---| +| `D = icon_size/100 × iconUnit(space)`, not `marker.size`; 2.5D uses the shared scale | `ledFrame` takes `iconPct` from the full card's or the space card's own `icon_size`; `ISO_ICON_SCALE` in 2.5D | golden `iso-led-strip-dark`, reference pair | +| Thickness 0.08 D off / 0.12 D on; outline `#383838` t, core t/2, round joins and caps | `renderLedStripes` | `led-strip-off-light`, reference pair | +| Off: white core, no field, both themes | stripe state `off` | smoke `smoke_led_strip_glow.mjs`, `led-strip-off-light` | +| 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 | +| 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) | + +## Designer §11 — ten visual criteria + +| # | Criterion | Result | +|---|---|---| +| 1 | Same geometry on/off; only colour/thickness/field change | Stored points never change; only `t` and the derived offset follow the state — pairs on/off | +| 2 | White core and dark outline keep contrast on grey floor, near hatched walls, over the field | Visible in both pairs; `#383838` outline, opaque core | +| 3 | Straight parts are not a chain of circles | One stroked path per strip; the field is bands of stroked paths, not discs | +| 4 | No hard rectangular cut of light at the ends | Round caps of the field bands (blue strip in the pair) | +| 5 | No dark gaps or bright spots at corners | Round joins; `lighten` blend of pieces (yellow corner, green loop) | +| 6 | A wall strip lights into the room, not behind the wall | Visibility clip from emitters `epsilonGeom` outward (violet, yellow) | +| 7 | A closed rectangle gives a continuous field along the perimeter | Green loop in the On pair | +| 8 | Colour changes with the entity without changing shape/radius/edges | Colour from `resolveGlowAppearance`; the stored points and the radius do not depend on state (four colours of one fixture in the On pair) | +| 9 | When off the field fades smoothly and the thin stripe stays | Shared 500 ms fade; Off pair keeps the stripe | +| 10 | Zoom keeps the proportions of thickness, outline, hit area and 2.5D lift | All sizes in plan units of D; hit radius `max(22 px, t/2)` in screen px (unit AC12) | + +## Accepted differences + +- Pixel sizes and the mockup blur (22.2–30 px) are not product filters: the + product uses 0.08/0.12 D and the shared falloff, so its stripe is thinner at + 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). diff --git a/docs/design/led-strips/README.md b/docs/design/led-strips/README.md new file mode 100644 index 00000000..249f1894 --- /dev/null +++ b/docs/design/led-strips/README.md @@ -0,0 +1,22 @@ +# LED strips — designer materials (#780) + +The designer's archive for the LED strips, kept **unchanged** under +[`source/`](source/): `Issue-662-LED-light-spec-2026-10-01.zip` attached to +[#662 (comment)](https://github.com/Matysh/houseplan-card/issues/662#issuecomment-5935038397) +(archive SHA-256 of every member in [`source/MANIFEST-SHA256.txt`](source/MANIFEST-SHA256.txt); +all six checksums verified when the files were copied). The Figma section is +[Led Light, 626:34](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34) +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 +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. + +- [`ACCEPTANCE.md`](ACCEPTANCE.md) — the two paired frames and the acceptance + table against §3 of the ТЗ and the ten visual criteria of the designer's §11. +- [`pairs/`](pairs/) — designer frame (left) next to the product scene (right), + 256-colour diagnostic copies. diff --git a/docs/design/led-strips/pairs/pair-off.png b/docs/design/led-strips/pairs/pair-off.png new file mode 100644 index 00000000..510d61c6 Binary files /dev/null and b/docs/design/led-strips/pairs/pair-off.png differ diff --git a/docs/design/led-strips/pairs/pair-on.png b/docs/design/led-strips/pairs/pair-on.png new file mode 100644 index 00000000..7e668d59 Binary files /dev/null and b/docs/design/led-strips/pairs/pair-on.png differ diff --git a/docs/design/led-strips/source/FIGMA-LINK.url b/docs/design/led-strips/source/FIGMA-LINK.url new file mode 100644 index 00000000..c385c1e1 --- /dev/null +++ b/docs/design/led-strips/source/FIGMA-LINK.url @@ -0,0 +1,3 @@ +[InternetShortcut] +URL=https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34 + diff --git a/docs/design/led-strips/source/ISSUE-LINK.url b/docs/design/led-strips/source/ISSUE-LINK.url new file mode 100644 index 00000000..069b66b8 --- /dev/null +++ b/docs/design/led-strips/source/ISSUE-LINK.url @@ -0,0 +1,3 @@ +[InternetShortcut] +URL=https://github.com/Matysh/houseplan-card/issues/662 + diff --git a/docs/design/led-strips/source/MANIFEST-SHA256.txt b/docs/design/led-strips/source/MANIFEST-SHA256.txt new file mode 100644 index 00000000..aa89241e --- /dev/null +++ b/docs/design/led-strips/source/MANIFEST-SHA256.txt @@ -0,0 +1,6 @@ +A0E61CCF1416CE8F92332CD6B855C8131628E32C27ADDD77EDBEE3A6A2B3F37A README.md +1A98198905F147E3D1B8720F45481B6D5295FBDB94F4AD407F92FAC044B7A1DA TZ-issue-662-LED-strips.md +7AD21EE0042384443B2E41AE337ED31416127E183A72765EBB493AD808FCB651 FIGMA-LINK.url +16317687BA78439DA6052B384825E5FEF480A71EB3F46FCDDC4B180EDD09760C ISSUE-LINK.url +EBAC64901AAA055DD74A2D04E2EE18093AA59FE65E152CB699BECD8C5C9A82FB previews/Led-On.png +8DCDD5F2FE784EE9C9DAB98D092A89457CA171554481586927E23E0DF7FFC18D previews/Led-Off.png diff --git a/docs/design/led-strips/source/README.md b/docs/design/led-strips/source/README.md new file mode 100644 index 00000000..aa7a2099 --- /dev/null +++ b/docs/design/led-strips/source/README.md @@ -0,0 +1,22 @@ +# Issue #662 — LED-ленты + +Архив содержит техническое задание и визуальные референсы для задачи [Matysh/houseplan-card#662](https://github.com/Matysh/houseplan-card/issues/662). + +## Состав + +- [`TZ-issue-662-LED-strips.md`](./TZ-issue-662-LED-strips.md) — сводное ТЗ: поведение из issue и визуальные требования из Figma. +- [`previews/Led-Off.png`](./previews/Led-Off.png) — четыре примера выключенных LED-лент. +- [`previews/Led-On.png`](./previews/Led-On.png) — четыре примера включённых LED-лент и рассеивания света. +- [`FIGMA-LINK.url`](./FIGMA-LINK.url) — ссылка на исходный макет. +- [`ISSUE-LINK.url`](./ISSUE-LINK.url) — ссылка на исходную задачу. +- [`MANIFEST-SHA256.txt`](./MANIFEST-SHA256.txt) — контрольные суммы файлов архива. + +## Основные ссылки + +- Issue: +- Figma, секция `Led Light`: +- Фрейм `Led On`: +- Фрейм `Led Off`: + +Дата подготовки: 1 октября 2026 года. + diff --git a/docs/design/led-strips/source/TZ-issue-662-LED-strips.md b/docs/design/led-strips/source/TZ-issue-662-LED-strips.md new file mode 100644 index 00000000..830a6234 --- /dev/null +++ b/docs/design/led-strips/source/TZ-issue-662-LED-strips.md @@ -0,0 +1,210 @@ +# Техническое задание: LED-ленты на плане + +Связанная задача: [Matysh/houseplan-card#662](https://github.com/Matysh/houseplan-card/issues/662) +Визуальный макет: [Figma — `Led Light`](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-34) +Фреймы: [`Led On`](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-35) и [`Led Off`](https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=626-53) +Дата фиксации: 01.10.2026 + +## 1. Назначение + +Добавить в House Plan протяжённое представление устройств `light.*`: LED-лента отображается не точечным значком, а ломаной линией в реальном месте установки. Пользователь должен сразу понимать положение, форму, состояние и цвет ленты, а также включать и выключать её нажатием по любой части линии. + +Поведенческий контракт определяется issue #662. Figma определяет визуальное направление, форму выключенной полосы, характер свечения, работу света у стен, в углах и на свободном участке пола. При возможном расхождении логика и размеры в единицах плана берутся из issue, внешний вид — из Figma. + +## 2. Визуальные референсы + +### 2.1. Выключенное состояние + +![Led Off — четыре выключенные ленты](./previews/Led-Off.png) + +Во фрейме `Led Off` показаны четыре варианта геометрии и размещения: + +1. прямая лента на свободном участке плана; +2. прямая лента вплотную к стене; +3. Г-образная лента с поворотом в углу; +4. замкнутая прямоугольная лента по периметру зоны. + +Требования к выключенной ленте: + +- световое пятно отсутствует; +- ядро белое `#FFFFFF`; +- внешняя обводка тёмная `#383838`; +- концы и стыки визуально скруглены; +- линия остаётся различимой на светлом и тёмном фоне плана; +- в исходном Figma-фрейме номинальная высота белого ядра — 6 px, внешняя обводка — 3 px, радиус скругления — 5 px; +- в продукте итоговая толщина масштабируется с планом и подчиняется C6 issue: `0,08 D` для выключенного состояния, где `D` — диаметр значка маркера. + +### 2.2. Включённое состояние + +![Led On — четыре включённые ленты](./previews/Led-On.png) + +Во фрейме `Led On` показаны четыре сценария: + +1. синяя прямая лента на расстоянии от стены — свет расходится по обе стороны; +2. фиолетовая лента вдоль нижней стены — свет направлен в комнату; +3. жёлтая Г-образная лента вдоль верхней и правой стен — свет корректно продолжается через угол и ограничивается стенами; +4. зелёная лента по периметру помещения — свет соединяется на углах без разрывов и не выходит за стены. + +Требования к включённой ленте: + +- сама полоса остаётся читаемой поверх свечения; +- цвет свечения поступает от устройства: live RGB/цветовая температура либо `glow_color`; +- показанные в макете синий, фиолетовый, жёлтый и зелёный цвета являются примерами, а не фиксированной палитрой; +- свет распространяется непрерывно вдоль каждого сегмента; +- на свободных концах должен быть мягкий округлый спад без прямоугольного обрыва; +- в точках поворота световые поля сегментов соединяются без тёмного шва и без заметного удвоения яркости; +- свет, направленный к толстой стене, обрезается стеной; лента на грани стены освещает только сторону комнаты; +- в продукте толщина включённой линии — `0,12 D`; свечение не входит в эту толщину; +- появление и исчезновение поля света происходит через существующий `GLOW_FADE_MS`. + +## 3. Измеренные параметры макета Figma + +Параметры ниже документируют исходник 2543×1572 px и нужны для визуального сравнения. Они не заменяют масштабируемые величины `D` из issue. + +| Элемент | Параметр в Figma | +|---|---| +| Ядро выключенной полосы | `#FFFFFF`, высота 6 px | +| Обводка | `#383838`, 3 px, Outside | +| Скругление | 5 px | +| Синий пример | `#80D5FF` → прозрачный | +| Фиолетовый пример | `#E680FF` → прозрачный | +| Жёлтый пример | `#FFEA80` → прозрачный | +| Зелёный пример | `#58FF58`, мягкие внутренние и внешние тени | +| Линейное поле | ориентировочная глубина 86–102 px в исходном фрейме | +| Радиальные окончания | диаметр 188–206 px | +| Blur радиальных окончаний | 22,2 px | +| Blur протяжённого поля | до 30 px в примерах | + +В рабочем рендерере мягкость должна строиться градиентами по `GLOW_FALLOFF`; CSS/SVG `filter` не является обязательной частью реализации и не должен ухудшать производительность. + +## 4. Модель данных + +В каждом пространстве допускается необязательный массив: + +```ts +space.led_strips: { + id: string; + points: number[][]; + marker: string | null; +}[]; +``` + +- `points` содержит от 2 до 50 точек в канонических координатах пространства; +- не более 50 лент в одном пространстве; +- `id` уникален в пространстве; +- один маркер может быть привязан не более чем к одной ленте во всех пространствах; +- версия модели не меняется, миграция не требуется; +- полный и per-space экспорт/импорт сохраняют геометрию; отсутствующий при импорте маркер превращает ленту в непривязанную. + +Якорь ленты — точка на половине суммарной длины ломаной. Он используется для определения комнаты, подписи, бейджа, подсказки и агрегатов. + +## 5. Рисование и редактирование + +- В редакторе плана после инструмента «Перегородка» появляется кнопка `LED-лента` с иконкой `mdi:led-strip-variant`. +- Лента рисуется цепочкой точек по контракту инструмента стен. +- Клик или тап добавляет вершину; Shift ограничивает направление шагом 45°; Ctrl+Z удаляет последнюю добавленную точку. +- Завершение: Esc, двойной клик по последней точке, смена инструмента или выход из редактора. +- Цепочка короче двух точек отбрасывается. +- Панорамирование, pinch, второй палец и `pointercancel` не добавляют точки. +- Точки прилипают к сетке и к физическим граням стен с порогом магнита мебели. +- В v1 можно перетаскивать вершины и удалить ленту целиком. Вставка и удаление отдельной вершины не входят в задачу. + +## 6. Стены и проёмы + +- Лента не может пересекать тело стены с толщиной. +- При попытке пересечения новый сегмент останавливается на ближайшей грани тела стены. +- Касание стены и движение точно вдоль её грани допустимы. +- Через `door`, `gate` и `passage` лента проходит по геометрическому проёму независимо от текущего состояния двери. +- Окно остаётся частью тела стены и блокирует ленту. +- Стены нулевой толщины, Solid и Dashed, не являются телом и не блокируют рисование. +- Перетаскивание вершины проверяет оба соседних сегмента по тем же правилам. +- Если стена была утолщена или добавлена позже, геометрия ленты автоматически не исправляется; Optimize сообщает число проблемных лент. + +## 7. Привязка устройства + +- После завершения ломаной открывается существующий диалог добавления устройства; сущности `light.*` показываются первыми. +- Сохранение создаёт или переиспользует live-маркер и связывает его с лентой атомарно. +- Закрытие или «Позже» сохраняет непривязанную ленту. +- Непривязанная лента отображается серым пунктиром только в редакторах и не видна во View, киоске и space-card. +- После привязки обычный значок маркера исчезает, но его `layout` сохраняется. +- В лотке выбранной ленты доступны: привязать, сменить устройство, отвязать и удалить ленту. +- После отвязки или удаления маркер снова отображается значком на сохранённой позиции либо в авторасстановке. + +## 8. Представление и состояния + +| Состояние | Представление | +|---|---| +| On | линия `0,12 D`, полный цвет `resolveGlowAppearance`, поле света при активной заливке «Свечение источников света» | +| Off | линия `0,08 D`, белое/приглушённое ядро с тёмной обводкой по визуальному референсу, без поля света | +| Unavailable | серый пунктир, без поля света | +| Hidden | не отображается | +| Unbound | серый пунктир только в редакторах | + +- Линия строится SVG `path` со скруглёнными концами и соединениями. +- Подпись и `value_badge` располагаются относительно якоря. +- Hover, long press, контекстное меню и клавиатурный фокус работают как у обычного маркера. +- Enter и Space запускают тот же `_clickDevice()`. +- У ленты нет пульсации и тревожного красного состояния. + +## 9. Свечение + +- Световой источник линейный: по сегментам строятся капсулы радиуса `glow_radius_cm`, а при отсутствии персонального значения — половины общего радиуса. +- Точки выборки: все вершины плюс промежуточные точки с шагом около 100 см, не более восьми точек на ленту. +- Капсулы одной ленты смешиваются через `lighten` внутри изолированной группы; с остальными пятнами группа смешивается через `screen`. +- Поле пересекается с полом и с объединением полигонов видимости; за стеной свет отсутствует. +- Для ленты на грани толстой стены точки выборки сдвигаются на эпсилон в сторону комнаты. +- Лента на стене нулевой толщины освещает обе стороны. +- Точка выборки внутри тела стены не создаёт веер; если внутри все точки, светового кандидата нет. +- В остальных режимах заливки световое поле не отображается. + +## 10. Взаимодействие и 2.5D + +- Активная зона проходит по всей ломаной: расстояние до неё не более `max(22 px экрана, половина толщины)`. +- Точка в 20 px от ленты должна попадать в неё, в 30 px — нет. +- При наложении с окрашенной капсулой значка приоритет у значка; между лентами выбирается ближайшая, затем стабильный порядок по `id`. +- В 2.5D лента поднимается на `0,075 D`, получает торец `0,1 D` и тень на полу; само световое поле остаётся на полу. +- `houseplan-space-card` отображает ленту и свет, но остаётся read-only. + +## 11. Критерии визуальной приёмки по Figma + +1. Во включённом и выключенном состоянии геометрия совпадает: изменяются цвет/толщина и наличие свечения, а не траектория. +2. Белое ядро и тёмная обводка сохраняют контраст на сером полу, возле штрихованных стен и поверх светового поля. +3. Прямые участки не выглядят как цепочка отдельных кругов. +4. На концах нет резкого прямоугольного обрыва света. +5. На углах отсутствуют тёмные разрывы и чрезмерно яркие круглые пятна. +6. Свечение ленты у стены направлено в помещение и не окрашивает пол за стеной. +7. Замкнутая прямоугольная лента даёт непрерывное поле по периметру. +8. Цвет меняется от сущности без изменения формы, радиуса и поведения границ. +9. При выключении поле плавно исчезает, а тонкая полоса остаётся видимой. +10. Масштабирование плана сохраняет пропорции толщины, обводки, активной зоны и 2.5D-подъёма. + +## 12. Проверки + +Обязательные группы приёмки из issue #662: + +- backend: лимиты, уникальность, конечность координат, ссылки на маркеры, экспорт/импорт; +- unit: якорь, рендер состояний, hit-test, линейные кандидаты света, стены, auto-grid, space-card; +- smoke: рисование, привязка/отвязка, свечение и clip-path, 2.5D, упор в стену; +- golden: `lighting-led-strip-glow-dark`, `led-strip-off-light`, `iso-led-strip-dark`; +- i18n: паритет ru/en/de/fr; +- performance: benchmark десяти лент по пять точек. + +## 13. Вне скоупа + +- эффекты и адресные сегменты WLED; +- вставка или удаление отдельных вершин; +- отдельная высота крепления и дополнительная 3D-модель света от стены; +- кривые Безье и произвольные гирлянды; +- интерактивность в `houseplan-space-card`; +- автоматическая починка лент, оказавшихся внутри стены. + +## 14. Комплект поставки + +- реализация модели, редактора, представления, света, 2.5D, импорта/экспорта и валидации; +- сущность `light.demo_led_strip` в demo/golden/smoke fixtures; +- четыре словаря i18n; +- unit, backend, smoke, golden и mutant-проверки по issue; +- документация и changelog RU/EN; +- замер производительности в handoff; +- визуальное сравнение с двумя PNG из этого архива. + diff --git a/docs/design/led-strips/source/previews/Led-Off.png b/docs/design/led-strips/source/previews/Led-Off.png new file mode 100644 index 00000000..77cc1c3e Binary files /dev/null and b/docs/design/led-strips/source/previews/Led-Off.png differ diff --git a/docs/design/led-strips/source/previews/Led-On.png b/docs/design/led-strips/source/previews/Led-On.png new file mode 100644 index 00000000..5287e435 Binary files /dev/null and b/docs/design/led-strips/source/previews/Led-On.png differ diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 15df5228..82c89fc4 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -14,11 +14,11 @@ to prove that the Node witness actually kills it. | --- | ---: | --- | | Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. | | Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. | -| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | -| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | +| Paint, cascade and layer composition | 30 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | +| Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | | Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | -| Custom-element and HA browser lifecycle | 99 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **216 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | +| Custom-element and HA browser lifecycle | 100 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | +| **Total** | **223 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | ## Measured effect @@ -91,6 +91,10 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p - `iso-sun-card-drops-occluders` - `iso-sun-flat-wedges-remain` - `iso-theme-dark-wall-rule-returns` +- `led-core-coloured-under-glow` +- `led-icon-not-suppressed` +- `led-source-stays-round-at-anchor` +- `led-unbound-in-view` - `stage3-w4-device-target-loses-44px-floor` - `stage3-w5-runtime-nudge-writes-storage` - `stage3-w6-no-borders-keeps-raised-plates` @@ -119,6 +123,8 @@ The invariant depends on hit testing, pointer capture, touch/keyboard dispatch o - `furniture-shift-listeners-not-attached` - `furniture-wall-runtime-drops-drag-side` - `furniture-wall-runtime-drops-raw-intent` +- `led-pan-adds-point` +- `led-pinch-calls-action` - `live-pinch-compositor-demoted-on-active-lit-commit` - `opening-dimension-overlay-hidden` - `opening-search-hides-none` @@ -228,8 +234,6 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `device-focus-tooltip-handler-removed` - `device-focus-tooltip-room-hover-overwrites` - `device-inbox-batch-rollback` -- `household-enter-stops-acting` -- `household-marker-drops-keyboard-reach` - `device-markers-rendered-without-keys` - `device-pointer-leave-clears-focus-fallback` - `device-position-cancel-routed-to-commit` @@ -240,10 +244,13 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `floor-geometry-key-global-epoch` - `floor-geometry-key-ignores-content` - `french-locale-wrong-dictionary` +- `household-enter-stops-acting` +- `household-marker-drops-keyboard-reach` - `hp-dialog-escape-does-not-close` - `junction-limit-baseline-cache-stale` - `junction-limit-candidate-fail-open` - `junction-limit-write-gate-removed` +- `led-auto-slot-reserved` - `locale-failure-toast-dropped` - `marker-reject-keeps-optimistic-candidate` - `marker-rollback-keeps-enqueue-time-revision` @@ -278,16 +285,16 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `support-stale-preview-response-revives-consent` - `support-timeout-claims-success` - `vacuum-overlay-back-to-the-dock-space-filter` -- `volumetric-kiosk-ignores-setting` - `view-current-space-aria-removed` +- `volumetric-kiosk-ignores-setting` - `wall-face-apply-skips-overlap-guard` - `wallthick-hit-narrowed` -- `warm-pending-mode-leaves-revive-waiting` -- `warm-resume-overwrites-view-return-camera` -- `warm-resume-camera-depends-on-dialog` -- `warm-pan-during-runtime-keeps-refit-blocked` - `warm-late-resume-beats-user-mode` - `warm-late-resume-crosses-space` +- `warm-pan-during-runtime-keeps-refit-blocked` +- `warm-pending-mode-leaves-revive-waiting` +- `warm-resume-camera-depends-on-dialog` +- `warm-resume-overwrites-view-return-camera` - `writer-history-skips-finished-chain-normalization` - `zigbee-topology-endpoint-cleanup-skipped` - `zigbee-topology-endpoint-elevation-removed` diff --git a/package.json b/package.json index e20c7fa9..fc8912b2 100755 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "benchmark:isometric-stage3-dense": "node demo/benchmark_large_house.mjs --profile=isometric-stage3-dense-v1", "benchmark:large-house-plan-snap": "node demo/benchmark_large_house.mjs --profile=large-house-plan-snap-v1", "benchmark:large-house-interaction": "node demo/benchmark_large_house.mjs --profile=large-house-interaction-v1", + "benchmark:led-strips": "node demo/benchmark_led_strips.mjs", "benchmark:glow": "node demo/benchmark_glow.mjs", "benchmark:compare": "node demo/performance/compare.mjs", "benchmark:optimize-geometry-preflight": "node demo/benchmark_optimize_geometry_preflight.mjs", diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 5b3a2b4b..fcfaeeee 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -668,6 +668,16 @@ export const LAZY_GRAPH_CEILING_BAND = 2_000; */ export const LAZY_ONBOARDING_GZIP_CEILING = 28_396; +/** + * #780 ТЗ §13.1: the LED chunks are a new feature's own budget, not a raise of + * an existing ceiling. Measured on the production build at the hand-off + * (runtime + linear field + shared geometry 8 698 B; Devices-editor tool + + * its English dictionary + shared geometry 9 913 B), plus 10 %, rounded up to + * a KiB. Absolute walls: a View without strips loads neither graph. + */ +export const LAZY_LED_GZIP_CEILING = 10 * 1024; +export const LAZY_LED_EDITOR_GZIP_CEILING = 11 * 1024; + /** * Потолок ленивого графа: `null`, пока значение внутри полосы. * @@ -749,6 +759,21 @@ export function assertBundleBudget( if (manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path))) { throw new Error('lazy editor graph overlaps lazy moon graph'); } + // #780 ТЗ §13.1: the LED runtime/field and the LED tool are lazy, own-budget + // graphs; neither enters the first frame or the static editor graph. + for (const [files, bytes, ceiling, label] of [ + [manifest.lazyLedFiles, manifest.lazyLedGzipBytes, LAZY_LED_GZIP_CEILING, 'lazy LED graph'], + [manifest.lazyLedEditorFiles, manifest.lazyLedEditorGzipBytes, LAZY_LED_EDITOR_GZIP_CEILING, 'lazy LED editor graph'], + ]) { + if (!files?.length) throw new Error(`bundle has no ${label}`); + if (manifest.initialViewFiles.some((path) => files.includes(path))) { + throw new Error(`initial View graph overlaps ${label}`); + } + if (manifest.lazyEditorFiles.some((path) => /\/led-strip-/.test(path) && files.includes(path))) { + throw new Error(`lazy editor graph overlaps ${label}`); + } + if (bytes > ceiling) throw new Error(`${label} ${bytes} B gzip exceeds its ${ceiling} B ceiling`); + } // #474: designer furniture artwork is lazy; a static import anywhere in the // View graph would pull ~10 KB gzip back into the initial graph silently. if (!manifest.lazyFurnitureArtFiles?.length) { @@ -822,6 +847,8 @@ export function assertBundleBudget( lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes, lazyPdfGzipBytes: manifest.lazyPdfGzipBytes, lazyMoonGzipBytes: manifest.lazyMoonGzipBytes, + lazyLedGzipBytes: manifest.lazyLedGzipBytes, + lazyLedEditorGzipBytes: manifest.lazyLedEditorGzipBytes, }; } @@ -857,6 +884,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { `lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`, `lazy PDF: ${result.lazyPdfGzipBytes} B gzip`, `lazy moon: ${result.lazyMoonGzipBytes} B gzip`, + `lazy LED: ${result.lazyLedGzipBytes} B gzip (потолок ${LAZY_LED_GZIP_CEILING} B)`, + `lazy LED editor: ${result.lazyLedEditorGzipBytes} B gzip (потолок ${LAZY_LED_EDITOR_GZIP_CEILING} B)`, ]; for (const line of lines) console.log(line); const warning = lowHeadroomWarning(headroom); diff --git a/scripts/config-field-registry.mjs b/scripts/config-field-registry.mjs index 986aeb79..d3f5090c 100644 --- a/scripts/config-field-registry.mjs +++ b/scripts/config-field-registry.mjs @@ -545,6 +545,20 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ migration: 'v9 migration resolves opening-vs-zero-wall conflicts (#316)', compatibility: 'v8 configs without partition hosts stay valid', }, + { + id: 'spaces[].led_strips', + selector: { path: ['spaces', '*', 'led_strips'] }, + storage: 'House Plan server config', + type: "Array<{ id, points: [x, y][], marker: string | null, active?: boolean }>", + default: 'absent = no strips', + level: 'space', + ui: 'Devices editor: LED strip tool, LED tray, device dialog «Show as LED strip / icon» (#780)', + runtime: 'LED stripe and linear Glow field (lazy chunks), anchor of the bound marker, static card', + introduced: 'v1.79.0', + status: 'current', + migration: 'none: optional array, no model_version bump; orphan links normalise to unbound geometry on write', + compatibility: 'older clients keep the unknown array; deleting a bound marker unbinds the strip instead of failing', + }, ]); export const CONFIG_FIELD_STATUSES = Object.freeze([ diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index a93a7400..a0a4bc4c 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -1,8 +1,8 @@ { "delegates": 154, "portMembers": 349, - "hostRefs": 5050, + "hostRefs": 5057, "portPrivates": 103, "harnessPrivates": 100, - "bundleBytes": 2664132 + "bundleBytes": 2667879 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index f88e07f4..9045b444 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13741,9 +13741,8 @@ const MUTANT_DEFINITIONS = [ + 'Перевёрнутое условие закрывает «грязный» диалог молча и спрашивает у чистого', patches: [{ file: 'src/editors/marker-dialog.ts', - // #780: the guard is the dialog's shared `leave` (close and the LED representation switch). - find: " if (edit && dirty && !await this.host._confirmDanger({", - replace: " if ((!edit || !dirty) && !await this.host._confirmDanger({", + find: " if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }", + replace: " if (!edit || dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }", }], }, { @@ -14235,6 +14234,146 @@ const MUTANT_DEFINITIONS = [ replace: " ? body.slice(0, at).replace(/\\s+$/, '')", }], }, + { + id: 'led-icon-not-suppressed', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: "#780 ТЗ §5: a marker shown as an LED strip draws no ordinary icon; the old icon must not stay under the stripe", + patches: [{ + file: 'src/houseplan-card.ts', + find: "(d) => d.space === space.id && (!d.hidden || showGhosts) && !leds.has(d.id));", + replace: "(d) => d.space === space.id && (!d.hidden || showGhosts));", + }], + }, + { + id: 'led-source-stays-round-at-anchor', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: "#780 ТЗ §3: the strip is a linear source — no second round pool from its anchor", + patches: [{ + file: 'src/houseplan-card.ts', + find: " if (leds.has(key.slice(space.id.length + 1))) continue;\n", + replace: "\n", + }], + }, + { + id: 'led-auto-slot-reserved', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: "#780 ТЗ §5: a marker represented by a strip reserves no auto-grid slot", + patches: [{ + file: 'src/houseplan-card.ts', + find: "d.space === s.id && !leds.has(d.id));", + replace: "d.space === s.id);", + }], + }, + { + 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", + patches: [{ + file: 'src/led-strip-geometry.ts', + find: "export const LED_DEFAULT_RADIUS_CM = 50;", + replace: "export const LED_DEFAULT_RADIUS_CM = 360;", + }], + }, + { + id: 'led-core-coloured-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", + 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;", + }], + }, + { + id: 'led-wall-offset-removed', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-geometry.test.mjs', + because: "#780 AC8: on a thick face the visible stripe moves t/2 into the free floor", + patches: [{ + file: 'src/led-strip-geometry.ts', + find: " const shift = piece.free ? [piece.free[0] * offset, piece.free[1] * offset] : [0, 0];", + replace: " const shift = [0, 0];", + }], + }, + { + id: 'led-emits-from-body', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-geometry.test.mjs', + because: "#780 ТЗ §6: a part of the strip inside a body emits nothing", + patches: [{ + file: 'src/led-strip-geometry.ts', + find: " if (ctx && ctx.inside(s)) continue;\n", + replace: "\n", + }], + }, + { + id: 'led-long-polyline-loses-vertices', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-geometry.test.mjs', + because: "#780 AC10: every segment of a long polyline emits — no vertex beyond the first eight is dropped", + patches: [{ + file: 'src/led-strip-geometry.ts', + find: " for (const piece of stripPieces(points, ctx)) {\n const len = dist(piece.a, piece.b);", + replace: " for (const piece of stripPieces(points, ctx).slice(0, 8)) {\n const len = dist(piece.a, piece.b);", + }], + }, + { + id: 'led-pan-adds-point', + guard: 'node demo/smoke_led_strip_draw.mjs', + because: "#780 AC4: a pan or pinch is no clean click and adds no chain point", + patches: [{ + file: 'src/led-strip-editor.ts', + find: " const clean = !!start && !this.gesture && e.button === 0", + replace: " const clean = !!start && e.button === 0", + }], + }, + { + id: 'led-pinch-calls-action', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: "#780 AC4: the stripe shares the card gesture owner, so a pan over it calls no service", + patches: [{ + file: 'src/led-strip-runtime.ts', + find: " @pointerdown=${(e: PointerEvent) => h.pointerdown(e, own(e))}\n" + + " @pointermove=${(e: PointerEvent) => h.pointermove(e, own(e))}\n" + + " @pointerup=${(e: PointerEvent) => h.pointerup(e, own(e))}", + replace: " @pointerdown=${(e: PointerEvent) => { e.stopPropagation(); h.pointerdown(e, own(e)); }}\n" + + " @pointermove=${(e: PointerEvent) => e.stopPropagation()}\n" + + " @pointerup=${(e: PointerEvent) => { e.stopPropagation(); h.pointerup(e, own(e)); }}", + }], + }, + { + id: 'led-binding-not-unique', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/led-strip-editor.test.mjs', + because: "#780 ТЗ §5: a marker bound to another strip cannot be taken silently", + patches: [{ + file: 'src/led-strip-editor.ts', + find: " if (owner && owner.strip.id !== stripId) { this.host._showToast(this.t('led.taken')); return; }", + replace: " void owner;", + }], + }, + { + id: 'led-unbound-in-view', + guard: 'node demo/smoke_led_strip_glow.mjs', + because: "#780 ТЗ §5: an unbound strip has no View representation, light or target", + patches: [{ + file: 'src/led-strip-runtime.ts', + find: " if (!device || device.space !== input.space.id) continue;", + replace: " if (device && device.space !== input.space.id) continue;", + }], + }, + { + id: 'led-transfer-remap-ignored', + guard: 'node scripts/backend-test-guard.mjs issue_780_space_import_remaps ' + + 'tests_backend/test_ha_import_export.py', + because: '#780 ТЗ §9: a transferred strip follows the marker-id remap; a coinciding old id never binds it', + patches: [{ + file: 'custom_components/houseplan/import_export.py', + find: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: new_id for old_id, new_id in marker_map.items()', + replace: ' unbound_led_strips = unbind_strips(space, remap={\n old_id: old_id for old_id, new_id in marker_map.items()', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 42a83c6a..b0d8d4ac 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -600,6 +600,19 @@ export const SMOKE_LINKS = [ + 'isometric smoke inspects door/window/gate bases from the same helper, but neither ' + 'browser bundle exposes the helper name in its test steps', }, + { + // #780: the LED chunks are lazy modules reached only through the bundle; + // the smokes drive the Devices editor, the View and the static card by + // DOM (`data-led-*`) and never name a helper of these files. + files: [ + 'src/led-strip-gate.ts', 'src/led-strip-card.ts', 'src/led-strip-geometry.ts', + 'src/led-strip-runtime.ts', 'src/led-strip-field.ts', 'src/led-strip-editor.ts', + ], + smokes: ['smoke_led_strip_draw.mjs', 'smoke_led_strip_bind.mjs', 'smoke_led_strip_glow.mjs'], + because: '#780: drawing, placement against walls, binding, the icon ↔ strip switch, the LED ' + + 'history, the View target, the field states and the static card are observed only as ' + + 'rendered strips and saved configs; no smoke names the lazy modules’ functions', + }, { symbols: ['cachedStairMarkup', 'stairTreadPath', 'StairMarkup'], smokes: ['smoke_stairs.mjs'], diff --git a/src/editors/marker-dialog.ts b/src/editors/marker-dialog.ts index 9dbbeb3a..005491de 100644 --- a/src/editors/marker-dialog.ts +++ b/src/editors/marker-dialog.ts @@ -168,21 +168,17 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult && (edit ? dirty : true); const saveTitle = problems[0] ? st(problems[0].message) : bindingUnverified && !!d.binding && d.binding !== 'virtual' ? t('marker.ha_registry_limited') : ''; - // #780: the same save/discard guard also runs before a representation change. - const leave = async () => { - if (edit && dirty && !await this.host._confirmDanger({ + const requestClose = async (event: Event) => { + if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; } + const dialog = event.currentTarget as { rejectClose?: () => void } | null; + const discard = await this.host._confirmDanger({ key: 'discard-marker-dialog', kind: 'warning', title: st('dialog.discard_title'), message: st('dialog.discard_message'), objectName: d.name.trim() || previewDevice?.name || undefined, confirmLabel: st('dialog.discard_confirm'), cancelLabel: st('dialog.discard_keep'), icon: 'mdi:content-save-off-outline', confirmIcon: 'mdi:content-save-off-outline', - })) return false; - forgetMarkerBaseline(this.host); this._closeMarkerDialog(); - return true; - }; - const requestClose = async (event: Event) => { - const dialog = event.currentTarget as { rejectClose?: () => void } | null; - if (!await leave()) dialog?.rejectClose?.(); + }); + if (discard) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); } else dialog?.rejectClose?.(); }; const reviewFirst = () => { const first = problems[0]; @@ -864,7 +860,7 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult .badge=${previewDevice ? this.host._spaceModelById(previewDevice.space)?.title ?? '' : this.host._spaceModel()?.title ?? ''} icon="mdi:shape-plus" wide @hp-close=${requestClose}>
- ${basics}${d.devId ? this.host._ledSection(d.devId, leave) : nothing}${tap}${light}${appearance}${details} + ${basics}${this.host._ledSection(d.devId)}${tap}${light}${appearance}${details}