feat: LED strips — editor notes, docs, demo, smokes, golden scenes, profile (#780)

Stage 5 of #780.

- Import summary: «Strips left unbound after import: {n}» from the backend
  `unbound_led_strips` count; «Optimize plans» reports strips passing
  through walls per space and edits none (AC16).
- Linear field for long strips (ТЗ §13.2): pieces of at most the radius
  along the polyline, emitters thinned to r/4, each piece clipped to the
  visibility fans of its own emitters as separate clipPath children (no
  boolean pass per piece), one floor clip for the whole layer, no fan at
  all where nothing blocks within the radius; a grid index of body faces
  and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the
  large house: first stable frame ~1.4 s, warm space ~1.1 s locally.
- led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived
  large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table
  in demo/performance/budgets-led-strips.json, exact counters (zero
  recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over
  20 cycles); added to the full performance workflow.
- Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB
  (measured + 10 %, rounded up); overlaps with the initial and editor
  graphs refused; the lazy editor graph stays inside its ceiling.
- Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants
  in the registry (7 browser guards in the inventory); config field registry
  entry `spaces[].led_strips`.
- Golden: five new scenes on the `golden-led` space of the visual fixture
  (`ledStrips` option, the designer's four strips on #868D94), matrix v71.
- Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES,
  ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand
  README, performance README; docs/design/led-strips with the unchanged
  designer archive, two paired frames and ACCEPTANCE.md; both changelogs.

Issue: #780
User-Visible: yes
This commit is contained in:
Codex
2026-10-02 13:35:11 +03:00
parent 1511996c5e
commit 9825819cd4
64 changed files with 2075 additions and 134 deletions
+29
View File
@@ -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);
+14
View File
@@ -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([
+2 -2
View File
@@ -1,8 +1,8 @@
{
"delegates": 154,
"portMembers": 349,
"hostRefs": 5050,
"hostRefs": 5057,
"portPrivates": 103,
"harnessPrivates": 100,
"bundleBytes": 2664132
"bundleBytes": 2667879
}
+142 -3
View File
@@ -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');
+13
View File
@@ -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'],