From fa89d655edd5ee8264e84a33baa17513f6292ff4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 2 Oct 2026 12:13:23 +0300 Subject: [PATCH] feat(card): the LED strip tool in the Devices editor (#780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with clean clicks only (pan, pinch, a second finger, cancel and the synthetic click after navigation add nothing), snaps to the grid and to physical wall faces / zero-wall axes, stops at the first face of masonry, partitions, columns and windows (doors, gates and passages are cut by geometry), Shift gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a double click or a click on the first point (≥3 vertices) closes; fewer than two distinct points write nothing. A finished strip opens the device picker (lights first, taken markers explained, «New device…» binds in that dialog's own write, «Later» keeps unbound geometry). A selected strip shows its vertex handles (a drag re-checks both neighbours and the whole path) and a new Devices tray branch: device settings, bind/change, unbind, show as icon, delete. The device dialog gets the representation section: show as strip (restores a hidden shape at once or draws one for this marker, behind the dialog's own save/discard guard), show as icon, unbind/delete for a hidden shape. Every geometry or representation change is one optimistic write and one LED command of the device history; Undo/Redo restores only its own strip record and refuses when a newer change sits on it. A rebinding renames the link in the marker save, a deleted marker leaves an unbound strip, a bound marker may not move to another space. Plan/Background show strips as a passive translucent mark. The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only on the tool, an editable strip in the shown space or a strip device's dialog. The initial graph gets the loader and delegation only (src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling. Mutant anchors follow the moved code (marker dialog guard, static LED layer). Issue: #780 User-Visible: no --- docs/data-hp-contract.json | 1 + scripts/bundle-budget.mjs | 4 +- scripts/bundle-manifest.mjs | 24 +- scripts/mutation-registry.mjs | 13 +- src/editors/marker-dialog.ts | 18 +- src/houseplan-card.ts | 20 +- src/houseplan-editor-runtime.ts | 13 +- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/fr.json | 1 + src/i18n/led.ts | 29 + src/i18n/led/de.json | 35 ++ src/i18n/led/en.json | 35 ++ src/i18n/led/fr.json | 35 ++ src/i18n/led/led-de.ts | 6 + src/i18n/led/led-fr.ts | 6 + src/i18n/led/led-ru.ts | 6 + src/i18n/led/ru.json | 35 ++ src/i18n/ru.json | 1 + src/led-strip-card.ts | 73 +++ src/led-strip-editor.ts | 815 +++++++++++++++++++++++++ src/led-strip-gate.ts | 45 +- src/led-strip-runtime.ts | 6 +- test/bundle-assets.test.mjs | 22 +- test/i18n-lazy-namespaces.test.mjs | 5 +- test/led-strip-editor.test.mjs | 272 +++++++++ test/mutation-browser-offload.test.mjs | 2 +- tsconfig.test.json | 4 +- 28 files changed, 1479 insertions(+), 49 deletions(-) create mode 100644 src/i18n/led.ts create mode 100644 src/i18n/led/de.json create mode 100644 src/i18n/led/en.json create mode 100644 src/i18n/led/fr.json create mode 100644 src/i18n/led/led-de.ts create mode 100644 src/i18n/led/led-fr.ts create mode 100644 src/i18n/led/led-ru.ts create mode 100644 src/i18n/led/ru.json create mode 100644 src/led-strip-card.ts create mode 100755 src/led-strip-editor.ts create mode 100644 test/led-strip-editor.test.mjs diff --git a/docs/data-hp-contract.json b/docs/data-hp-contract.json index dfc9bc5f..54e7b79f 100644 --- a/docs/data-hp-contract.json +++ b/docs/data-hp-contract.json @@ -34,6 +34,7 @@ "import", "info", "kiosk", + "led-picker", "marker", "onboarding", "opening", diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index fa3bea0d..5b3a2b4b 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -427,6 +427,7 @@ const GRAPH_LABELS = { lazyEditorFiles: 'lazy editor graph', lazyOnboardingFiles: 'lazy onboarding graph', lazyNamespaceLocaleFiles: 'lazy namespace locale graph', + lazyLedEditorFiles: 'lazy LED editor graph', }; /** @@ -470,6 +471,7 @@ export const NAMESPACE_ENGLISH_CONSUMERS = { support: ['lazyEditorFiles'], topology: ['lazyEditorFiles'], tools: ['lazyEditorFiles'], + led: ['lazyLedEditorFiles'], }; /** @@ -776,7 +778,7 @@ export function assertBundleBudget( const namespaceLocales = manifest.lazyNamespaceLocaleFiles; if (!Array.isArray(namespaceLocales) || namespaceLocales.length !== NAMESPACE_LOCALE_CHUNKS.length) { throw new Error(`lazy namespace locale graph has ${namespaceLocales?.length ?? 0} files,` - + ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools × ru/de/fr)`); + + ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools/led × ru/de/fr)`); } for (const [graph, label] of [ ['initialViewFiles', 'initial View graph'], diff --git a/scripts/bundle-manifest.mjs b/scripts/bundle-manifest.mjs index 36f2e8b5..c6ae26bc 100644 --- a/scripts/bundle-manifest.mjs +++ b/scripts/bundle-manifest.mjs @@ -14,13 +14,14 @@ const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__'; const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__'; const LED_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_RETRY_ASSET__'; const LED_FIELD_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_FIELD_RETRY_ASSET__'; +const LED_EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_EDITOR_RETRY_ASSET__'; /** * #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per * namespace × language. Their loader modules are the recognition marker for * the manifest graph and the target of each second-attempt retry token. */ -export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools'] +export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools', 'led'] .flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({ namespace, language, @@ -124,6 +125,9 @@ export function buildBundleManifest(bundle, fingerprint) { // strip is on in a Glow room with a light scene. : modules.some((id) => id.endsWith('/src/led-strip-field.ts')) ? 'led-field' + // #780: the Devices-editor LED tool, loaded by the editor runtime. + : modules.some((id) => id.endsWith('/src/led-strip-editor.ts')) + ? 'led-editor' // #474: designer furniture artwork — its own lazy chunk, shared by // the editor (static import) and the View runtime (dynamic). : modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts')) @@ -176,6 +180,7 @@ export function buildBundleManifest(bundle, fingerprint) { || path.includes('led-strip-runtime-')), ...files.filter((file) => file._role === 'led-field').map((file) => file.path), ]; + const ledEditorRoots = files.filter((file) => file._role === 'led-editor').map((file) => file.path); // #627: namespace dictionaries are dynamic imports of LAZY chunks, never of // the initial graph, so they are found by their module, not as a root. const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale') @@ -195,6 +200,7 @@ export function buildBundleManifest(bundle, fingerprint) { const lazyPdf = graphFrom(pdfRoots); const lazyMoon = graphFrom(moonRoots); const lazyLed = graphFrom(ledRoots); + const lazyLedEditor = graphFrom(ledEditorRoots); const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots); const sum = (paths) => [...paths] .reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0); @@ -227,6 +233,8 @@ export function buildBundleManifest(bundle, fingerprint) { lazyMoonGzipBytes: sum(lazyMoon), lazyLedFiles: [...lazyLed].sort(), lazyLedGzipBytes: sum(lazyLed), + lazyLedEditorFiles: [...lazyLedEditor].sort(), + lazyLedEditorGzipBytes: sum(lazyLedEditor), lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(), lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale), files: files.map(({ _role, ...file }) => file), @@ -302,6 +310,9 @@ export function editorRuntimeRetryUrlPlugin() { const ledField = chunks.find((chunk) => Object.keys(chunk.modules) .some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-field.ts'))); if (!ledField) throw new Error('LED strip field chunk was not emitted'); + const ledEditor = chunks.find((chunk) => Object.keys(chunk.modules) + .some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-editor.ts'))); + if (!ledEditor) throw new Error('LED strip editor chunk was not emitted'); const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => { const chunk = chunks.find((candidate) => Object.keys(candidate.modules) .some((id) => id.replaceAll('\\', '/').endsWith(entry.module))); @@ -320,6 +331,7 @@ export function editorRuntimeRetryUrlPlugin() { let moonReplacements = 0; let ledReplacements = 0; let ledFieldReplacements = 0; + let ledEditorReplacements = 0; for (const chunk of chunks) { if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) { let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName); @@ -375,6 +387,12 @@ export function editorRuntimeRetryUrlPlugin() { ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1; chunk.code = chunk.code.replaceAll(LED_RETRY_ASSET_TOKEN, asset); } + if (chunk.code.includes(LED_EDITOR_RETRY_ASSET_TOKEN)) { + let asset = posix.relative(posix.dirname(chunk.fileName), ledEditor.fileName); + if (!asset.startsWith('.')) asset = `./${asset}`; + ledEditorReplacements += chunk.code.split(LED_EDITOR_RETRY_ASSET_TOKEN).length - 1; + chunk.code = chunk.code.replaceAll(LED_EDITOR_RETRY_ASSET_TOKEN, asset); + } if (chunk.code.includes(LED_FIELD_RETRY_ASSET_TOKEN)) { let asset = posix.relative(posix.dirname(chunk.fileName), ledField.fileName); if (!asset.startsWith('.')) asset = `./${asset}`; @@ -392,9 +410,9 @@ export function editorRuntimeRetryUrlPlugin() { if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1 || germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1 || pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1 - || ledFieldReplacements !== 1) { + || ledFieldReplacements !== 1 || ledEditorReplacements !== 1) { throw new Error('lazy retry URL placeholder counts are ' - + `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}, expected 1/1/1/1/1/1/1/1/1/1`); + + `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}/${ledEditorReplacements}, expected 1/1/1/1/1/1/1/1/1/1/1`); } // #627: the same strict rule for every namespace × language token — // exactly one second-attempt URL each, never zero and never two. diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 458cae08..f88e07f4 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -6084,9 +6084,9 @@ const MUTANT_DEFINITIONS = [ patches: [{ file: 'custom_components/houseplan/validation.py', find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n' - + ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n)', + + ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n', replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n' - + ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n)', + + ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n', }], }, { @@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [ find: ' ${passageGlowTunnels}\n' + ' ${decorImages}\n' + ' ${stairShapes}\n' - + ' ${glowPools}\n ${wallUnion', + + ' ${glowPools}\n ${leds.size', replace: ' ${passageGlowTunnels}\n' + ' ${!space.bg && !disp.showNames ? svg`${space.rooms.map((room) => {\n' + ' const center = roomCenter(room);\n' @@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [ + ' ${decorImages}\n' + ' ${stairShapes}\n' + ' ${glowPools}\n' - + ' ${wallUnion', + + ' ${leds.size', }], }, { @@ -13741,8 +13741,9 @@ const MUTANT_DEFINITIONS = [ + 'Перевёрнутое условие закрывает «грязный» диалог молча и спрашивает у чистого', patches: [{ file: 'src/editors/marker-dialog.ts', - find: " if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }", - replace: " if (!edit || dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }", + // #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({", }], }, { diff --git a/src/editors/marker-dialog.ts b/src/editors/marker-dialog.ts index 2fcd78fc..9dbbeb3a 100644 --- a/src/editors/marker-dialog.ts +++ b/src/editors/marker-dialog.ts @@ -168,17 +168,21 @@ 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') : ''; - 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({ + // #780: the same save/discard guard also runs before a representation change. + const leave = async () => { + if (edit && dirty && !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', - }); - if (discard) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); } else dialog?.rejectClose?.(); + })) 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?.(); }; const reviewFirst = () => { const first = problems[0]; @@ -860,7 +864,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}${tap}${light}${appearance}${details} + ${basics}${d.devId ? this.host._ledSection(d.devId, leave) : nothing}${tap}${light}${appearance}${details}