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
+30 -1
View File
@@ -49,13 +49,18 @@ test('full performance is isolated to stable, scheduled and manual entry points'
'- overlay',
'- space-default',
'- space-glow',
'- led-strips',
'PROFILE: ${{ matrix.profile }}',
'name: full-performance-${{ matrix.profile }}',
'--samples=7 --warmups=1',
]) assert.ok(workflow.includes(contract), `missing full-gate contract: ${contract}`);
assert.ok(workflow.includes('if [ -f baseline/scripts/bundle-sync.mjs ]; then'));
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 20);
// #780: led-strips-v1 runs three candidate-only sizes judged by absolute limits.
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 23);
for (const size of ['10x5', '50x50', 'none']) {
assert.ok(workflow.includes(`npm run benchmark:led-strips -- --size=${size} --samples=7 --warmups=1`), size);
}
assert.equal((workflow.match(/--allow-stage2-base/g) || []).length, 1,
'only the Stage 3 comparison base may bypass the candidate-only DOM contract');
assert.ok(workflow.includes('budgets-isometric-stage3-dense.json'));
@@ -281,3 +286,27 @@ test('#347: a rewritten before forces the full run instead of guessing the range
assert.ok(!fallback.includes('cat-file'),
'merge-base-фолбэк остаётся только для нулевого before — без повторной проверки существования');
});
test('#780 led-strips-v1: the derived fixture converts devices without adding icons, the budget is the ТЗ table', async () => {
const { makeLedStripsFixture } = await import('../demo/performance/led-strips-fixture.mjs');
const { makeLargeHouseFixture } = await import('../demo/fixtures/large-house.mjs');
const base = makeLargeHouseFixture();
const led = makeLedStripsFixture(50, 50);
assert.equal(Object.keys(led.devices).length, Object.keys(base.devices).length, 'no device added');
assert.equal(Object.keys(led.layout).length, Object.keys(base.layout).length, 'no icon position added');
for (const space of led.config.spaces) {
assert.equal(space.led_strips.length, 50);
for (const strip of space.led_strips) {
assert.equal(strip.points.length, 50);
assert.equal(led.states[`light.perf_led_${space.id.slice(-1) - 1}_${strip.id.split('-').pop()}`]?.state, 'on');
assert.equal(led.config.markers.filter((marker) => marker.id === strip.marker).length, 1);
assert.equal(led.config.markers.find((marker) => marker.id === strip.marker).space, space.id);
}
}
const budgets = JSON.parse(readFileSync(new URL('../demo/performance/budgets-led-strips.json', import.meta.url), 'utf8'));
assert.deepEqual(budgets.sizes['10x5'], { firstStableRenderMs: 3400, warmSpaceReadyMs: 1500, stateUpdateMs: 1000,
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024 });
assert.deepEqual(budgets.sizes['50x50'], { firstStableRenderMs: 5000, warmSpaceReadyMs: 1500, stateUpdateMs: 1500,
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024 });
assert.equal(budgets.cacheEntries, 50);
});