Files
houseplan-card/docs/testing-notes/geometry.md
T
Claudeandclaude[bot] 7dc7597260 docs(process): ролевые конспекты, замер входа, Snapshot генерируется, TESTING.md разделён
Вход агента до первого файла кода стоил ≈ 26 700 слов (аудит 22.09).

- docs/process/AUTHOR.md и REVIEWER.md — выжимки PROCESS.md: каждый пункт
  ссылается на раздел канона, ключевые формулировки дословные;
  test/process-digests.test.mjs сверяет якоря, ссылки и правила.
- scripts/entry-cost.mjs — маршрут чтения по роли и бюджет (автор ≤ 12 000
  слов, AC1); AGENTS.md «Read this first» называет те же маршруты.
- docs/STATUS.md: блок Snapshot генерирует scripts/status-snapshot.mjs
  (версии — release-contract, счётчики — inventory, теги — git); feature
  surface и ранние milestones перенесены дословно в docs/STATUS-FEATURES.md.
- docs/TESTING.md — действующая инструкция (684 строки, AC3); ручные
  чек-листы и приложения по issue перенесены дословно в docs/testing-notes/
  с индексом и тестом на полноту.
- Промпт ревьюера в process.yml читает конспект вместо пересказа правил;
  машинные требования (строка вердикта, REVIEW_DOC, запрет fetch, таблица
  «чем краснеет», разделы повторного раунда) сохранены и закреплены тестом.
- PROCESS.md: правила не менялись; добавлены ссылка на конспекты в шапке и
  уточнение в §10.4, что ревьюер конвейера читает конспект.
- 7 мутантов в реестре.

Issue: #634
User-Visible: no
2026-09-24 02:33:08 +00:00

805 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Геометрия: стены, проёмы, комнаты, холст
> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634).
> Индекс всех приложений — [`README.md`](README.md).
## Stable wall-segment identity (#282)
- [ ] Wall junction limits (#329): drawing refuses an apex under 15°, a seventh
wall in one node, a wall shorter than 20 cm or than its own thickness,
nodes closer than 5 cm and a room with under 25 cm² of interior, each
through the surface's own channel — a toast naming the rule for drawing
and Thickness, a stopped wall for Resize. A T-joint stays legal, a short
filler atom compensating a thickness step stays legal (length is measured
along the collinear same-thickness wall run), and an inherited violation
never blocks an unrelated edit
[unit: junction-limits; auto: smoke_junction_limits, smoke_island_rooms;
mutants: junction-limit-angle-not-enforced,
junction-limit-write-gate-removed, degenerate-apex-bevelled-again].
- [ ] A degenerate sharp apex renders as ONE point on both faces — no flat
chamfer, no bow-tie fold, no jags between the inner and outer vertex; the
room ring of the #329 fixture triangle has exactly three distinct
vertices [unit: junction-limits §4].
- [ ] Shared fixture `test/fixtures/282-wall-identity-parity.json` produces the
same exact v8 catalog, room references, opening host and draft IDs in
TypeScript and Python.
- [ ] Initial v7 migration is deterministic and idempotent; new post-v8 atoms
use UUIDs. A split/promoted draft keeps one documented carrier ID, while
reserved/colliding deterministic IDs receive stable `-2`, `-3` suffixes.
- [ ] The three structural writer families — interactive commit, Undo/Redo
restore and Optimize — are enumerated by the source guard and each has an
independent bypass mutant in `scripts/mutation-gate.mjs`. A rejected
migration changes neither config, Undo history nor revision.
- [ ] Full/space imports cover v7→v7 (no upgrade), v7→v8 and v8→v8; copy/merge
remaps every ID and reference together. A byte-equivalent legacy-client
round-trip of v8 is accepted, while a structural legacy change is rejected.
- [ ] Resize, Split/Merge, opening edit and Optimize browser smokes retain wall
thickness and ownership across reload. Performance gate:
`npm run benchmark:wall-model` materialises 10,000 atoms with p95 below
500 ms on the reference Windows machine.
- [ ] Local commands: `npm test`, `npm run typecheck`,
`npm run benchmark:wall-model`; backend parity/schema tests run through
`tests_backend/test_wall_segment_model.py` and
`tests_backend/test_validation.py`. HA import/export coverage runs in the
normal Linux/CI Home Assistant harness when unavailable natively.
## Legacy draft migration and atomic wall-chain writes (#314, #478)
- [ ] `demo/smoke_v8_draft_write.mjs` (compatibility filename) proves that a
model-v9 draft migrates once to ordinary partitions, preserves existing
edge IDs/thicknesses and leaves no `room_drafts` in model v10.
- [ ] The same fake-WS smoke proves each accepted current wall reaches storage,
a later edge preserves earlier identities, Undo removes only the terminal
wall, and a closed chain creates a durable room without carrier debris.
- [ ] A rejected in-flight physical write synchronously rolls back its whole
pending batch: active path, session partition IDs, pending map and command
history are empty, so no optimistic ghost wall survives.
- [ ] Frontend/backend model tests consume the same
`478-room-draft-migration-vectors.json`: missing/null IDs, colliding IDs,
numeric strings, `null`/boolean thickness and epsilon-length edges must
produce the same exact partitions or rejection reason in both runtimes.
Backend coverage also rejects a stale v9 `room_drafts` write over v10.
- [ ] `demo/smoke_unified_wall_tool.mjs` accepts a room over a partially
coincident older partition, preserves only its outside tail and an
unrelated partition, then proves an immediate Optimize reports zero
reconciliation and no config change. A forced missing post-mutation wall
model restores the whole room transaction.
- [ ] Local commands: `npm test`, `npm run bundle:sync`,
`node demo/smoke_v8_draft_write.mjs`, targeted backend pytest and
`node scripts/check-docs.mjs --external`.
- [ ] Mutation `current-rejected-physical-write-keeps-optimistic-wall` proves
the browser rollback scenario fails when rejection recovery is bypassed.
- [ ] Mutation `room-accept-leaves-coincident-partitions` proves the real editor
smoke fails if accepted room carriers stop being consumed atomically.
## Current-writer fixed point (#477)
- [ ] `test/writer-fixed-point.test.mjs` proves seed-bounded repeated collinear
merge, safe positive coincident reconciliation/opening rehost, identical
fail-closed cases, exact room-reference rewrite/restore and the executable
writer-owner manifest.
- [ ] `demo/smoke_writer_fixed_point.mjs` finishes a real straight Walls chain
through the production bundle, then proves the immediate, post-Undo and
post-Redo Optimize previews are no-ops. The same smoke deletes a room and
restores/reapplies direct and cross-space vacuum references without
overwriting unrelated marker fields.
- [ ] `demo/benchmark_wall_draw_click.mjs` keeps the #461 terminal-click budgets
and structural counters, then separately proves finish uses one bounded
physical/junction transaction, one write, no extra history and sublinear
scaling when unrelated rooms are added.
- [ ] `test/align-grid.test.mjs` proves Optimize leaves complete furniture/image
transforms byte-equivalent while an ordinary decor rectangle remains
grid-bound.
- [ ] The `writer-*` mutations in `scripts/mutation-gate.mjs` remove one finish
owner, pre-adoption safety, history normalization, direct/vacuum reference
rewrite, free-transform exclusion, terminal-click separation, seed merge
and seed reconciliation; each named witness must turn red.
## Resize: реальный pointer pipeline (#293)
- [ ] `demo/smoke_resize_pointer_real_plan.mjs` загружает tracked fixture
второго этажа обычным `houseplan/config/get`, включает Resize кнопкой и
двигает доступную общую стену только реальными `page.mouse` событиями.
Прямые вызовы приватных resize-методов в этом smoke запрещены source
guard-юнитом.
- [ ] На десятом шаге сетки обе комнаты имеют видимый preview, а server config
ещё байт-в-байт исходный. Pointerup создаёт одну history-команду и одну
запись; wall count и набор толщин сохраняются; Ctrl+Z возвращает исходную
геометрию.
- [ ] Pointer capture продолжает жест минимум в двух диаметрах и 120 px от
хэндла, чужой
pointer id игнорируется, а Escape и `lostpointercapture` возвращают DOM и
config без дополнительной записи.
- [ ] Невозможная физическая preview-геометрия останавливает стену на последней
безопасной позиции и показывает один локализованный toast за жест. Отдельно
проверяется отказ финального preflight без commit/history.
- [ ] Мутанты `resize-pointer-delta-zeroed`,
`resize-shared-seam-not-coalesced`, `resize-pointer-capture-removed`,
`resize-preview-reject-silent` и
`safe-resize-commit-preflight-bypassed` обязаны красить соответствующие
unit/production smoke guards.
- [ ] Fixed-topology wall records (#298): moving-wall breakpoints translate
rigidly, side-wall interior endpoints never scale proportionally,
the exact first-floor 49→52 gesture ends on 17/52/57/101, unrelated
records remain byte-equivalent, and a full-span carrier/lattice proof
rejects gaps before preview. Key-only legacy records move only by one
whole-edge identity; partial midpoint ambiguity produces no preview,
history or config write [unit: `wall-thickness.test.mjs`; auto:
`smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness`, six
`smoke_edit_walk` runs; mutation:
`safe-resize-wall-endpoints-affine-scaled`,
`safe-resize-legacy-midpoint-fail-open`].
## Opening symbol centreline (#242, #250)
- [ ] Unit and browser checks prove that door/window/gate stay on the wall
centreline for both `flip_v` values, door/window flips change only their
direction, and gate `flip_v` reverses the first-leaf 10° turn on shared room walls,
independent partitions and hidden Iso without translating the gate
[unit: `opening-symbol.test.mjs`, `iso-openings.test.mjs`; auto:
`smoke_wall_thickness.mjs`, `smoke_isometric_contract.mjs`; mutations:
`opening-symbol-flip-restores-edge-offset`,
`opening-gate-flip-cancels-turn`].
- [ ] Matrix v37 adds four dedicated semantic scenes. Before PNG comparison
they assert the saved flip value, wall centreline, visible-group offset,
full jamb depth, window glass membership and opposite gate turn signs:
`opening-symbol-room-wall-light`,
`opening-symbol-diagonal-partition-dark`,
`opening-symbol-flip-pairs-light`,
`isometric-opening-symbol-parity-dark`.
- [ ] #250 reuses those four scenes and requires `offset: center` for every
door/window/gate entry, including flipped pairs. The semantic guard must
fail before PNG comparison if any saved flip restores a wall-face offset.
- [ ] The exact existing golden impact set below contains **67** scenes. It was
measured by comparing `actualSha256` for HEAD and `origin/dev` under the
same Chromium build; baseline status alone is not used because `dev`
already has unrelated pending pre-release candidates. Every listed frame
uses a shared fixture containing an affected opening or retains that plan
behind an editor/dialog. No other existing frame changed:
`isometric-geometry-view-dark`, `isometric-geometry-view-light`,
`isometric-live-layers-dark`, `isometric-no-borders-dark`,
`isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`,
`geometry-view-dark-fit`, `geometry-view-light-fit`,
`room-label-parity-view-dark`, `room-label-parity-plan-dark`,
`room-label-parity-view-light`, `room-label-parity-plan-light`,
`day-cycle-dawn-dark`, `day-cycle-day-dark`, `day-cycle-dusk-dark`,
`day-cycle-night-dark`, `geometry-plan-editor-dark`,
`space-tab-drop-before-light`, `space-tab-drop-after-dark`,
`plan-snap-endpoint-light`, `plan-snap-line-gaps-dark`,
`junction-patch-resilience-plan-dark`,
`opening-placement-door-thick-wall-dark`,
`opening-placement-passage-thick-wall-dark`,
`opening-placement-passage-thick-wall-light`,
`geometry-devices-editor-dark`, `geometry-decor-editor-dark`,
`tray-wide-selection-en`, `tray-wide-tool-ru`,
`tray-medium-group-en`, `tray-medium-selection-ru`,
`tray-narrow-palette-en`, `tray-narrow-tool-ru`,
`geometry-diagonal-45-opening-dark`, `openings-thick-wall-dark`,
`lighting-glow-sun-dark`, `device-value-badge-positions-dark`,
`device-icon-state-table-light`, `device-icon-state-table-dark`,
`device-text-shell-long-light`, `device-text-shell-long-dark`,
`lighting-sun-window-state-only-dark`,
`lighting-fill-light-axis-split-dark`,
`lighting-fill-temp-axis-split-dark`,
`lighting-fill-lqi-axis-split-dark`, `lighting-temp-glow-dark`,
`lighting-temp-glow-light`, `lighting-custom-glow-dark`,
`lighting-opaque-glow-two-doorways-dark`,
`lighting-custom-glow-light`, `lighting-temp-glow-no-sources-dark`,
`lighting-temp-glow-room-override-dark`,
`lighting-manual-auto-spill-overlap-dark`, `hover-over-glow-dark`,
`hover-nested-room-dark`, `large-house-zoom-040-dark`,
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`,
`device-dialog-desktop-en`, `device-help-popover-light-ru`,
`decor-color-popover-desktop-en`, `general-color-popover-desktop-en`,
`space-room-color-popover-desktop-ru`,
`backup-full-preview-desktop-en`,
`backup-plan-only-export-desktop-en`,
`optimize-preflight-dialog-dark-en`,
`optimize-preflight-dialog-light-ru`.
- [ ] Baselines for the 67 existing and four dedicated scenes are accepted
only from the reviewed full Linux pre-beta artifact. Local
`golden:accept` remains forbidden.
## Empty-space lifecycle (#113)
- [ ] Active selection keeps active-or-first compatibility, while an empty
model returns `undefined`; exact lookup of a stale saved id never falls
back to another space [unit: `space-model-selection.test.mjs`].
- [ ] There are no unguarded `_spaceModel().…` dereferences, explicit-id calls
use `_spaceModelById()`, and marker/position persistence validates its target
before config/file/WS side effects
[unit: `optional-space-model-contract.test.mjs`].
- [ ] Delete the last space while an editor gesture and debounced write are
active: the empty card renders, View is restored, pointer/draft/dialog
state is cleared, the pending write is cancelled and Add space still
opens Create. Recreate a plan, then receive an empty WS config and repeat
under a theme/resize/read-only tick [auto: `smoke_optional_space_model`].
- [ ] Removing the authoritative empty-state cleanup makes that smoke red
[mutation: `empty-space-cleanup-disabled`].
## Fixed card space (#210)
- [ ] `floor` resolves exact stable IDs and zero-based finite integer indexes;
quoted numeric strings stay IDs, and explicit empty, unknown, fractional,
negative or out-of-range values fail closed [unit: `initial-load.test.mjs`].
- [ ] Three coexisting instances (fixed ID, fixed index and unpinned) keep
independent authority while sharing the legacy navigation key. Fixed
cards ignore hash, tabs, guarded internal transitions, warm remount,
kiosk swipe/cycle/dots and never read or write saved navigation. Invalid
config renders an accessible error without a spatial stage, while the
unpinned card still restores legacy navigation
[auto: `smoke_fixed_floor.mjs`, `smoke_nav_persist.mjs`, `smoke_kiosk.mjs`].
- [ ] The GUI offers stable IDs only, preserves an existing numeric YAML value
during unrelated edits and deletes the `floor` property when cleared
[unit: `fixed-floor-contract.test.mjs`; auto: `smoke_fixed_floor.mjs`].
- [ ] Bypassing the shared transition guard makes the focused browser scenario
red; before-fix evidence also records that `origin/dev` has no fixed
resolver or guarded transition
[mutation: `fixed-floor-transition-guard-bypassed`].
## Open passage (#157)
- [ ] Подменю и диалог показывают четвёртый тип в порядке Окно / Дверь /
Открытый проём / Ворота; новый проём имеет ширину 90 см. [auto: open-passage-contract, opening-placement]
- [ ] В Plan/View у passage отсутствуют створка, дуга, рамка и пунктир, но
сохраняются hitbox, wall cut и room-coloured tunnel. [auto: opening-symbol, smoke_open_passage]
- [ ] Static вырезает и заполняет тоннель только для passage, не меняя старые
door/window/gate. [auto: space-geometry, smoke_open_passage]
- [ ] Внутренний passage пропускает Glow, внешний и неизвестный будущий тип
остаются fail-dark. [auto: light-visibility, smoke_open_passage]
- [ ] Passage в скрытой изометрии имеет full-height cut и zero leaves.
[auto: iso-openings, golden]
- [ ] Смена типа предупреждает о датчике/замке; Save удаляет пять
неприменимых ключей, Cancel не меняет config. [auto: open-passage-contract, smoke_open_passage]
- [ ] Full/space import отвергает forged binding до preview, а старое битое
значение можно прочитать и очистить. [auto: test_validation, test_ha_import_export]
- [ ] Пять passage-мутантов из `scripts/mutation-gate.mjs` пойманы своими
guards до передачи в review. [auto: mutation-gate]
## Independent-wall openings and structural axes (#132, #185)
- [ ] Door/window/gate/passage placement on a finished independent wall stores
`host.kind/id/t`; a coincident room wall chooses that explicit host, while
crossing or duplicate-host ties are rejected. [auto: opening-placement,
partition-openings]
- [ ] Every hosted type cuts only its host full-depth in Plan/View/Static/Iso;
exact composite room masonry is also cut, nearby bodies remain intact,
and malformed hosts fail dark. [auto: physical-geometry,
smoke_partition_openings]
- [ ] Rigid host drag preserves `t` and updates projections atomically; delete
lists hosted openings, Cancel changes nothing, Confirm cascades in one
Undo/Redo command. [auto: partition-openings, smoke_partition_openings]
- [ ] Contact/lock actions keep existing security rules; passage stays inert;
windows and exterior passages stay opaque to Glow, and partition windows
produce no sun wedge. [auto: runtime contracts, smoke_glow]
- [ ] Door/window/gate/passage presentation gaps do not split the structural
axis used by the Walls face graph; real `open_spans` still do. [auto:
plan-snap-overlay, smoke_room_autoclose, smoke_partition_openings]
- [ ] Backend rejects missing host references, out-of-range `t`, non-fitting or
overlapping hosted openings and stale host stripping; exports round-trip
the host. [auto: test_validation, test_ha_import_export]
- [ ] The exact #276 Optimize candidate is shared by frontend and backend tests:
Python independently proves the removed partition, two-room solid wall,
envelope, opening identity and non-overlap; config/set and every partial
or mutated candidate remain rejected. Linux HA WS persists and reloads
the implicit opening, then Undo restores the partition and explicit host.
[auto: coincident-partitions, test_validation, test_ha_websocket]
## Independent-wall opening jamb margin (#186)
- [ ] Strict resolver and placement reserve half the host depth for
door/window/gate/passage at both endpoints, including exact-boundary,
diagonal, reversed, thickness and scale matrices; room-wall placement
keeps its zero-jamb rule. [auto: partition-openings, opening-placement]
- [ ] Direct drag, dialog length edits and rebind share the same formatted
RU/EN guidance; a rejected edit writes neither config nor history.
[auto: smoke_partition_openings]
- [ ] Backend config/set and optimize reject a new/direct invalid geometry with
`invalid_partition_opening_jamb_margin`, while unrelated writes, rigid
translation and full backup restore preserve a legacy near-end record.
[auto: test_validation, test_ha_websocket, test_ha_import_export]
## Room resize (docs/RESIZE.md)
- [ ] `ResizeController` is the sole owner of selection, gesture, preview,
labels and eligibility cache. The card is only the DOM/render/persistence
adapter; controller state-machine unit tests cover foreign pointers,
repeated deltas, rejection rollback, cancel and exact commit
- [ ] One production wall-record preservation helper serves Resize and the
invariant CLI. Resize checks exact multiplicity for every finite value,
including `cm: 0`; the CLI keeps positive-value presence semantics
[unit: wall-record-preservation + resize-controller]
- [ ] The «Размер» tool appears in the Plan editor toolbar; in EVERY other
tool (and in Devices/Decor/View) there is not a single `.rszhandle`
- [ ] Every edge has a finger-sized midpoint handle. Eligible handles capture
the pointer; ineligible handles remain visible/dimmed, expose a localized
reason through hover/focus/tap, carry `aria-disabled=true`, and create no
drag, Undo or write [auto: smoke_room_resize + resize-production-path]
- [ ] Only a numerically horizontal/vertical wall with perpendicular side
edges is eligible. Diagonal, partial/unequal shared, coincident physical
extra and third-owner cases fail closed with the stable reason matrix
[unit: resize.test]
- [ ] A non-shared drag changes exactly one room; an exact endpoint-to-endpoint
shared drag changes exactly two. Both existing endpoints move by one
vector and every room keeps its vertex count/order [unit + smoke]
- [ ] An irregular exact pair moves only until the first corner/grid node that
would change the moving segment or collapse a side. No third room can
join the gesture. The anonymized private #277 topology stays predictably
disabled [unit fixture + production pointer smoke]
- [ ] Side-wall ownership stays atomic (#289): the anonymized 43-step repro is
disabled before pointer capture in both directions, while an outer side
reaches but cannot cross the next room's edge. No thickness record can
become partly shared and partly outer
[unit: resize.test + fixture 289-mixed-role-resize; auto:
smoke_room_resize; mutation: safe-resize-side-ownership-bypassed]
- [ ] Wall compaction preserves physical ownership (#299): equal thickness on
`shared(A,B) -> outer(A)` and `shared(A,B) -> shared(A,C)` remains split
at the exact role breakpoint, while equal neighbouring atoms inside one
role still compact. Optimize on `real-plan-first-floor.json` is immutable,
invariant-clean and idempotent; real-plan edit-walk seeds 1 and 3 exercise
Optimize and Delete-room/Keep-walls without producing a mixed-role record
[unit: wall-thickness + plan-optimizer; auto: smoke_edit_walk seeds 1/3;
mutation: wall-compaction-owner-role-bypassed]
- [ ] Live badges while dragging: lengths of the dragged wall + both
adjacent walls, and the m² area at the room centre; dragging a shared
wall shows BOTH areas; all numbers update continuously
- [ ] Stops are contiguous from zero: 30 cm room clearance, first topology
corner, foreign room/island, partition/column and every side-wall
opening. The opening jamb includes half the moving wall thickness, and a
wall cannot jump through an invalid interval to a later valid position
- [ ] An ordinary door/window/gate ON the moving wall travels exactly once;
length/type/angle/other fields remain byte-equivalent. A hosted opening
never transfers to a room wall through Resize
- [ ] The corner scale frame and its four handles are absent. Source guard
proves `applyRoomScale`, `clampRoomScale`, partial-shared insertion and
commit-time `simplifyPoly` are unreachable from `houseplan-card.ts`
- [ ] Esc, pointercancel and lost capture cancel instantly with original
persisted geometry, zero Undo and zero config writes
- [ ] Ctrl+Z / ⌘Z after releasing a handle restores the previous geometry —
one release = one undo step (rooms AND openings)
- [ ] The Plan toolbar names the next Undo/Redo operation; Ctrl+Shift+Z and
Ctrl+Y redo it, and a new geometry edit after Undo clears the redo branch.
Fifty committed operations remain available in the shared stack
[auto: command-stack.test]
- [ ] History shortcuts are layout-independent without conflating physical and
labelled keys: Cyrillic Ctrl/Cmd+Z works, QWERTZ Ctrl+Z/Ctrl+Y pick the
labelled command, and AZERTY Ctrl+W never becomes Undo. Focused inputs
keep native history [auto: smoke_editor_tabs]
- [ ] Exact eligible wall thickness/open spans re-key losslessly in the same
overlay; unrelated extras and rooms are byte-equivalent. Any production
wall/floor failure on the exact final preview cancels before save
- [ ] `npm run benchmark:safe-resize`: pointer clamp p95 ≤16 ms, ≤20% over the
same-run historical baseline (with bounded noise); cached pointerup
preflight p95 ≤75 ms; active-plan delta cache ≤4096 entries
- [ ] `npm run benchmark:safe-resize-render`: on the 20-room/80-handle floor,
a warm Resize layer takes one geometry snapshot per frame and stays at
p95 ≤25 ms
- [ ] Six Resize mutants are caught: axis eligibility, third-room cascade,
topology signature, side ownership, physical jamb and controller commit
preflight
- [ ] The test-only Resize eligibility audit calls the production resolver,
pins exact post-Optimize totals/reason counts and per-handle identities
for both real-plan fixtures, and reports stable handle ids when the
baseline changes. The known second-floor shared seam has two enabled
owner handles; raw-vs-optimized classification proves near-axis repair
removes only false angle reasons [unit: resize-availability-audit.test;
source: resize-production-path; mutation: resize-audit-resolver-bypassed]
- [ ] Disabled Resize handles expose the same actionable localized explanation
through aria-label, click, Enter and Space; pointerdown starts no drag and
creates no history/write [auto: smoke_room_resize]
- [ ] Device markers do not move; the room settings gear re-centres itself
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
## Infinite canvas (docs/CANVAS.md, dev)
- [ ] **A plan drawn past the old square opens whole**: a space whose rooms
live at normalised 1.5..3.0 renders complete and centred (it used to
frame empty canvas with the house off-screen) [auto:
smoke_infinite_canvas; units: test/canvas.test.mjs]
- [ ] **Nothing stops at an edge any more**: in the Plan / Devices / Decor
editors a room, a marker and a decor shape can be drawn, dragged and
SAVED far outside `0..1`, on any floor; reload keeps them there
[backend: tests_backend/test_validation.py::test_infinite_canvas_range]
- [ ] **A typical small plan is visually unchanged** — same framing, same
room and label positions as before the feature. The ONE intended
difference is icon size (below) [auto: smoke_infinite_canvas
(legacyFrameUnchanged); the whole smoke suite is the regression net]
- [ ] **Icons no longer grow with zoom** (§6, owner is aware): a marker keeps
the same pixel size at zoom 1, 4 and at the zoom-out floor; the
per-device size multiplier, kiosk icon/font scales, badges, LQI chips
and presence rings all still scale from `--dev-size`
- [ ] **Start view follows the content**: opening a space frames what is
drawn plus a small margin, on every floor, with and without a backdrop
image (with one the IMAGE sets the extent — it must not be cropped to
the outlined rooms)
- [ ] **What is not drawn does not frame** (audit DEV-2C947-01): tick «hide
from plan» on a marker standing far from the house and the view snaps
back to the house — the hidden marker neither renders nor stretches the
frame, on the full card and on `houseplan-space-card`. Untick it and the
frame takes it in again; room LQI counted it the whole time
[auto: smoke_canvas_frame]
- [ ] **The editor frame does not follow you out** (audit DEV-2C947-02): move
the only room five canvases away in the Plan editor (the frame grows
there, deliberately), close the editor — View frames the room where it
is NOW, not the union with where it was; re-entering the editor starts
from the current geometry [auto: smoke_canvas_frame]
- [ ] **A far stray does not inflate the icons either** (audit DEV-2C947-03):
one ROOM dragged an order of magnitude away is rejected from the frame
(as before) and the markers of the main plan keep the size they have
without it; auto-placement spacing goes with them
[auto: smoke_canvas_frame + unit canvas.test.mjs]
- [ ] **A far stray does not break the view** (§4.1): a marker dragged an
order of magnitude away leaves the opening view alone and raises the
inline chip «Объектов далеко от плана: N» with «Показать». No modal.
«Показать» fits the plan AND the stray; the chip then disappears
- [ ] **«Вписать всё»** (middle zoom button): fits the content from any pan
and any zoom, is never disabled, tooltip en/ru
- [ ] **Zoom-out floor**: the wheel / the minus button stop at three times the
content frame; zoom-in still stops at 800 %
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
every direction; when the plan is fully off screen a small arrow points
home and one click fits it back
- [ ] **Pan at ANY zoom** (owner's report 2026-08-04): dragging empty scene
moves the view at 100 %, at 50 % and at the zoom-out floor — in View and
in all three editors, with every plan tool selected. The tools keep the
pointer they own (a resize handle resizes, a device badge in the Devices
editor moves the device, an opening slides along its wall — none of them
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
- [ ] **Kiosk: a pan stays a pan to the very end** (dev, audit DEV-1DA1-02):
on a wall tablet at 100 % start a drag with a small VERTICAL lead-in
(the plan starts following the finger — the gesture is locked as `pan`),
then curve it far to the left or right and lift. The floor must NOT
change: the decision taken on the first movement is final, and only a
gesture locked as a swipe may switch storeys. Mirror check: a horizontal
lead-in locks the swipe — the plan never slides under it, and if the
trajectory then bends vertically and no longer qualifies as a swipe, the
gesture simply does nothing (it does not turn into a pan). Straight
swipes still switch, straight vertical drags still pan, a motionless
double tap still resets the zoom [auto: smoke_kiosk_pan_lock]
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
not merge into a grey wash — fine dots thin out, every 5th/10th node
stays bigger; zoomed in the grid is the usual one and snapping still
lands on the same nodes as before
- [ ] **Everything else on a far-out plan**: sun wedges, glow radii, open
boundaries, room resize handles/rulers, opening rulers, split/merge,
vacuum trails, the static `houseplan-space-card` and kiosk carousel all
behave exactly as on a plan inside `0..1`
- [ ] **Real config regression**: a production config (e.g. the dacha, 3
floors / 106 markers) frames bit-identically to the previous release —
no outliers reported, no frame movement
## «+» adds a space from anywhere (dev, unreleased)
- [ ] **The button is where the floors are, always**: as an admin, open the
card in View — the «+» sits at the end of the tab row next to the floor
names, is at least icon-sized and actually hittable (nothing overlaps
it), and opens the NEW-space dialog. Repeat in all three editors (Plan,
Devices, Background): the same button in the same place, not only in the
Plan editor as before [auto: smoke_gear_tabs]
- [ ] **A kiosk has no «+»**: a card with `kiosk: true` does not RENDER the
button at all — checking that the header is `display:none` is not
enough, a hidden node is still clickable from script [auto: smoke_gear_tabs]
- [ ] **The tab row still fits a phone**: at 390 px the row wraps, nothing
scrolls sideways out of the card, and the «+» stays inside the card and
hittable [auto: smoke_gear_tabs measures `scrollWidth` vs `clientWidth`]
- [ ] **A non-admin never sees it**: the button follows the same rule as the
per-space gear (`_canEdit`) [manual, needs a non-admin HA user]
## Honest new-space display defaults (#204, dev, unreleased)
- [ ] **The dialog and Save agree**: open a new space. File begins with room
borders/names `false/false`; before either control is touched, switching
to Draw shows `true/true` and switching back restores `false/false`.
Saving either source and reopening it yields the exact visible pair
[auto: `space-dialog.test`, `smoke_space_create_display_defaults`,
`smoke_space_settings`].
- [ ] **One touch protects both choices**: on Draw change either display
switch, including the mixed `true/false` and `false/true` cases. Further
File ↔ Draw switches change only the source; Save never silently restores
`true/true` [auto: `space-dialog.test`,
`smoke_space_create_display_defaults`; mutation:
`space-create-hidden-display-override`].
- [ ] **Draft state does not leak**: Cancel and a fresh Create return to File
`false/false`. In Floors/Areas onboarding every next floor also starts
clean and cannot inherit the preceding floor's touched state
[auto: `smoke_space_create_display_defaults`].
## Wall thickness (docs/WALL-THICKNESS.md, Unreleased)
- [ ] **Walls + adjacent draw thickness**: the first Plan-editor tool is named
“Walls” / «Стены» rather than “Add”; while it is active, its thickness
field is the immediately following toolbar element, before Merge. The
field still defaults to 15 cm and disappears when another tool is selected
[auto: smoke_draw_wall_thickness]
- [ ] **Tool + hover + input**: Plan editor → Thickness. Hover highlights
the whole wall; click opens the cm/in field; empty/0 clears; Esc closes
without applying; «Apply to all walls of this room» fills every allowed
edge [auto: smoke_wall_thickness]
- [ ] **Hover width follows wall centimetres (#303)**: on a 30 cm cell a
50 cm wall's hover fill matches the masonry within 2%; a zero-thickness
wall keeps the same physical visual minimum across cell sizes, while the
pointer still hits at five grid pitches from the axis
[unit: grid-scale.test.mjs; auto: smoke_wallthick_hover_width;
mutations: wallthick-hover-floor-back,
wallthick-zero-strip-not-visual, wallthick-hit-narrowed]
- [ ] **Hatched body, clean-floor area**: after setting thickness a `.wallbody`
path appears; room-card and tooltip m² both decrease to the same inner-
contour area
[auto: smoke_wall_thickness]
- [ ] **Exact thickness transition after Split**: split one zero-thickness room,
apply 10 cm to every wall of one child and keep the other child at zero.
Both halves of the 10 cm facade end at the divider endpoint; no hatch,
paper or light masonry continues along the zero side. Plan, View, static
and hidden Iso consume the same stepped geometry
[auto: smoke_wall_thickness_transition + test/wall-thickness.test.mjs]
- [ ] **One virtual-junction patch cannot blank the plan (#197)**: load the
complete 8-room, 25-wall, 3-cut regression fixture. Its ULP-noisy patch
is stabilised below geometry tolerance; a forced failure of one optional
patch retains the previous body and later patches still run. Plan, View,
kiosk, static and hidden Iso keep one non-empty canonical masonry path;
paper, clean-floor and light/sun consumers stay non-empty, and theme/HA
ticks neither rebuild topology nor write configuration
[auto: test/wall-thickness.test.mjs +
smoke_junction_patch_resilience + junction-patch-resilience golden
scenarios].
- [ ] **A bounded T-junction keeps its exterior half-wall (#261)**: in the
anonymised #197 fixture the measured point `(895.5, 556)` is filled by
room masonry, final masonry and paper, and excluded from clean floor.
Plan, View, kiosk, Static, hidden Iso and light/sun barriers agree at that
point, while the old excessive #249 spike remains absent
[unit: test/wall-thickness.test.mjs; auto:
smoke_junction_patch_resilience; golden:
junction-patch-resilience-plan-dark +
junction-patch-resilience-view-dark; mutation:
multi-wall-paper-full-origin-cut].
- [ ] **Degree-3 repair stops at every finite ray endpoint (#271)**: canonical
co-directional rays retain separate short-thick and long-thin supports;
the rebuilt masonry, paper and light barrier contain the real short arm
but no area after its endpoint. Plan, View, kiosk, Static, hidden Iso and
clean floor agree, independent of owner order, winding and scale
[unit: test/wall-thickness.test.mjs; auto:
smoke_junction_patch_resilience; golden:
junction-patch-resilience-plan-dark +
junction-patch-resilience-view-dark; mutation:
multi-wall-finite-ray-disabled].
- [ ] **A degree-3+ junction has no enclosed white triangle (#272)**: every
excessive bevel sector remains empty but is connected to the exterior
through a finite-width local corridor. Equal/mixed T and X nodes at
`cell_cm: 1/5` have zero local holes in room/final masonry and paper;
Plan, View, kiosk, Static, hidden Iso and light/sun agree
[unit: test/wall-thickness.test.mjs; auto: smoke_multiwall_junction;
golden: multiwall-junction-bevel-view-dark; mutation:
multi-wall-exterior-corridor-disabled].
- [ ] **A perpendicular T/X junction keeps every real wall strip (#275)**:
every finite ray with a perpendicular partner remains filled through its
node even when the removed sector is exterior-connected or neighbouring
node masks overlap. Mixed orthogonal/diagonal nodes protect only the
qualifying rays; the non-orthogonal #249 wedge stays empty. Raw,
Optimize preview, applied storage and reload agree at `cell_cm: 5/1`, as
do Plan, View, kiosk, Static, hidden Iso, paper, clean floor and light
[unit: test/wall-thickness.test.mjs; auto:
smoke_multiwall_strip_containment; golden:
orthogonal-strip-cell-5-view-dark +
orthogonal-strip-cell-1-view-dark; mutation:
multi-wall-orthogonal-strip-protection-disabled; exact local gate:
scripts/wall-strip-containment.mjs].
- [ ] **A short multi-wall ray cannot erase its attached shared wall (#288)**:
the real second-floor `349 / 120 / 5` node keeps the 20 cm wall beginning
at the short ray's far endpoint; both tracked real plans have zero
undeclared centreline gaps, while the #271 outer phantom remains absent
[unit: test/wall-thickness.test.mjs; auto:
smoke_real_plan_masonry; mutation:
multi-wall-shared-continuation-protection-disabled].
- [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in
the body; the door swing is offset toward the inner face and gate leaves
toward the exterior face; with
`hide_openings` the symbols hide but the cut remains
[auto: smoke_wall_thickness]
- [ ] **Opening tunnel repeats the room fill**: check a door, window and gate on
outer and shared thick walls. An outer opening uses its room colour for
the complete wall depth; a shared opening with different fills has one
hard transition exactly on the wall axis and no white/alpha seam. Window
glass and all architectural symbols remain above it. Repeat with hidden
opening symbols, hidden wall borders and in the Background editor; Glow
and sun geometry must not change
[auto: smoke_opening_tunnel_fill + test/wall-thickness.test.mjs +
test/logic.test.mjs]
- [ ] **Opening association and overlap edges**: a parallel room separated by
an air gap does not colour the outer half; a perpendicular T arm does not
capture the opening; a 45° wall keeps its local-axis split; nested rooms
resolve deterministically; a legacy opening beyond an endpoint paints
only the real wall interval. Exact and partial duplicate openings do not
stack alpha, and an angle-invalid opening is consistently rejected by
symbol offset, wall cut and tunnel fill
[auto: test/wall-thickness.test.mjs]
- [ ] **Door/gate light uses the clear tunnel**: place an off-centre light beside a
door or gate in a thick wall. In the neighbouring room the glow is limited by
sight lines through both the near and far inner-face corners; neither
side crosses a solid jamb return. Clearing wall thickness restores the
wider centreline-based sector [auto: test/logic.test.mjs; manual visual]
- [ ] **Wide gate stays compact**: add a 300–400 cm Gate. It has two equal
leaves with no swing arc; without a contact they open exactly 10°
outwards, and a closed/open contact changes the angle between 0° and
10°. A lock badge and Glow tunnel behave exactly like a door
[auto: test/logic.test.mjs + test/wall-thickness.test.mjs +
tests_backend/test_validation.py + smoke_styling_hooks]
- [ ] **Shared once / zero → line / safe Resize**: one body for a shared wall;
setting thickness to zero restores the centreline. Resize preserves
thickness on an eligible uniformly thick exact wall. Partial/unequal
shared and mixed-thickness walls remain visibly disabled; an attempted
drag changes no rooms, openings, wall atoms or Undo history
[auto: smoke_wall_thickness + smoke_zero_walls +
smoke_resize_wall_thickness + smoke_room_resize +
test/wall-thickness.test.mjs + mutation-gate]
- [ ] **Wall key survives storage round-trip (#258)**: exact grid endpoints and
their nine-decimal stored form produce one midpoint key, including odd
and even lengths, negative/reversed coordinates and render-space scale.
Both known persisted key variants resolve the same exact span immediately
without accepting a parent, child, neighbour or parallel wall. The
affected T-node stays filled in Plan, View, kiosk, Static and hidden Iso;
clean-floor and light barriers use the same masonry. Explicit Optimize
rewrites the stable key and the next in-memory/backend echo is a no-op
[unit: test/wall-thickness.test.mjs + test/plan-optimizer.test.mjs +
test/model-invariants.test.mjs; auto: smoke_wall_key_roundtrip; golden:
wall-key-roundtrip-view-dark; mutation: wall-key-storage-normalization-disabled +
wall-exact-span-fallback-disabled + invariant-wall-key-storage-normalization-disabled].
- [ ] **Zero-wall T-junction**: when two positive thick arms from different
room contours meet at a zero-wall endpoint, the outside corner is a clean
mitre with no stair-step. Editors paint the complete zero axis above the
real hatch; View paints it below the body so jambs mask its ends without
changing stored geometry [auto: test/wall-thickness.test.mjs +
smoke_zero_walls].
- [ ] **Zero fragment normalisation**: adjacent or overlapping `cm:0` atoms
with the same ownership compact without losing the exact breakpoint at a
positive thickness or owner-role change. Resize transforms their stable
endpoints without recreating legacy `open_spans/open_to`
[auto: test/wall-segment-model.test.mjs + test/wall-thickness.test.mjs +
smoke_zero_walls].
- [ ] **Near-axis authoring and explicit repair (#290)**: the shared
`0.25°` classifier includes `316×1`, excludes `316×2` and 30° diagonals,
and Walls preview/click persist `316×0` without claiming the wrong saved
endpoint. Optimize deduplicates the tracked shared wall across two room
owners, reports one wall and an exact physical maximum, rekeys thickness,
passes production preflight, applies one atomic write, reloads as a no-op
and restores the original through one Undo. Independent walls use the
same classifier; unsafe candidates are counted as skipped
[unit: test/near-axis.test.mjs; auto: smoke_plan_drawing_repairs +
smoke_near_axis_optimize; mutations: `near-axis-threshold-weakened`,
`near-axis-inclusive-boundary-disabled`,
`near-axis-authoring-snap-bypassed`].
- [ ] **Explicit Optimize cleans only an isolated micro-interval (#198)**:
`22 → 15 → 22` with a centre shorter than half a grid step and no
room/opening node becomes one 22 cm run in Preview and Apply; Cancel
writes nothing and server Undo restores the three exact entries. Exact
half-step, end, unequal-neighbour, chained and topological cases remain
lossless at normalized/render scales; a second Optimize is idempotent
[unit: test/plan-optimizer.test.mjs; auto:
smoke_optimize_micro_interval; mutation:
`optimizer-micro-interval-cleanup-disabled`].
- [ ] **A single T-node does not preserve an artificial thickness island
(#273)**: the minimized beta.5 `22 → 15 → 22` profile has a 1.381904-unit
centre beside one perpendicular room edge. Preview/Apply store one 22 cm
run, the T coordinate and incident room stay unchanged, render probes
see one continuous outer face, reload is idempotent and server Undo
restores the exact entries. A second topology endpoint or any open-span
endpoint still blocks cleanup [unit: test/plan-optimizer.test.mjs; auto:
smoke_optimize_micro_interval; mutation:
`optimizer-single-topology-island-blocked`].
- [ ] **Optimize reconciles only one exact coincident wall (#276)**: the
anonymized two-room fixture keeps its two 5 cm endpoint offsets while an
exact independent wall is removed, its hosted door becomes an ordinary
opening at the same centre/angle with all bindings and unknown fields,
and the wider centred thickness survives. Direction and room order do
not matter; three non-overlapping hosted door/window/gate records are
rehosted atomically, while a hosted-hosted overlap and all other
partial/extra/unknown/column/opening conflicts fail closed. Preview
writes nothing, Apply uses one WS transaction,
reload is idempotent, Undo restores the hosted form, and Boundary plus
Thickness target the resulting shared wall. Four targeted golden scenes
retain the 5 cm offset and show before, 10 cm, 30 cm and virtual results;
the paired large-house benchmark enforces p95 overhead ≤15% and ≤25 ms,
while source ownership plus an injected counter keep the helper out of
render/pointer paths [unit:
test/coincident-partitions.test.mjs + test/plan-optimizer.test.mjs;
auto: smoke_optimize_coincident_partition; mutations:
`optimizer-coincident-opening-rehost-disabled`,
`optimizer-coincident-partial-accepted`, existing
`optimize-preflight-bypassed`; performance:
`npm run benchmark:coincident-partitions`; golden:
`coincident-partition-{before,thin,thick,virtual}-dark`].
- [ ] **Optimize unlocks only proved zero-range Resize handles (#281)**: three
exact independent partitions over solid one-room outer boundaries block
the affected shared-wall Resize before maintenance. Optimize removes all
three, materializes both hosted windows without changing their fields,
passes independent backend proof and is idempotent. Afterwards the target
handle has a non-zero grid step in both directions and one production
pointer gesture changes exactly the two adjacent rooms. Partial, unknown
or opening-overlapped outer candidates remain untouched. Every handle
reported enabled on the anonymized `44.json` fixture has a non-zero
contiguous range; a zero-range handle stays visible/focusable but disabled
and captures no pointer [unit: test/resize-optimize.test.mjs; backend:
tests_backend/test_validation.py + tests_backend/test_ha_websocket.py;
auto: smoke_resize_outer_reconciliation].
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
rekey, cm↔inches; `walls` schema bounds
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
- [ ] **Thin-on-screen parity**: at the same card width a 1 cm wall suppresses
the hatch (solid fill stays) in both `houseplan-card` and
`houseplan-space-card`; a 20 cm wall restores the hatch in both
[auto: smoke_wall_thickness + test/wall-thickness.test.mjs]
- [ ] **Split through an open span**: split one side of a shared wall through
the middle of an existing open stretch. Both resulting pieces remain in
`open_spans`, and both new rooms link to the neighbour in `open_to`
[auto: smoke_merge_split + test/open-spans.test.mjs]
## Wall chains, partitions and columns
- [ ] **Persistence and joining**: every accepted wall-chain edge is immediately
one ordinary partition with its selected thickness and one history/save
boundary. Switching tools or reloading leaves those walls intact but does
not resume their chain. A current config never stores `room_drafts`; a v9
draft migrates edge-for-edge to partitions [auto: smoke_free_walls +
smoke_unified_wall_tool + frontend/backend wall-segment-model tests].
- [ ] **Creation limits and validation**: 1/100 cm partitions and 1/150 cm
columns save exactly. Zero, NaN and 101/151 cm block the final click with
a range toast and create no history entry. Client caps match backend:
2000 partitions and 500 columns [auto: backend validation +
smoke_free_walls].
- [ ] **Select gestures**: creation tools click through existing physical
bodies. Select gives at least a 24 px target, cycles overlaps on repeated
clicks and moves a partition rigidly on the grid. Escape/pointercancel
restores the pre-drag state. A square column rotates in 5° steps (Shift
free); a circle has no rotation handle [auto: smoke_free_walls; manual].
- [ ] **Delete contract**: every accepted open-chain edge is an ordinary
independently selectable partition. Delete and properties therefore use
the standard partition confirmation/dialog; there is no whole-outline or
segment-of-draft action [auto: smoke_free_walls].
- [ ] **Area and light**: overlapping bodies are subtracted once from clean
area; closed partition rings keep the enclosed floor; bodies outside all
rooms create no paper. Glow does not cross a long nearby partition and a
source inside masonry lights nothing. Window rays are blocked by the same
bodies. `show_borders: false` changes paint only [auto:
physical-geometry.test; manual visual].
- [ ] **Seamless junctions**: partition L corners (right, acute and
obtuse), unequal thickness, endpoint-on-line T and a branch touching a
room wall use one bounded joined body; near-miss, X crossing, malformed
segments and flat free caps keep their documented semantics. The active
rubber-band has the same contour before and after commit, target records
are not split, Plan/View/static/hidden-iso paths agree, clean floor and
light use the joined corner, and preview/render never writes config
[auto: wall-thickness.test, physical-geometry.test,
smoke_wall_junctions, wall-junctions golden scenarios; manual golden
artifact review].
- [ ] **Lifecycle/performance**: an external config revision cancels live
move/rotate state before replacing geometry. Drag preview performs no
polygon boolean work; clean floor and Glow clips are reused until the
config/space/source changes [auto: editor/preloader smokes; performance
profile for a dense plan].
## Вписывание выбранной комнаты (#152)
- [ ] В View чистый mouse click и одиночный touch tap по полу простой,
вогнутой и вложенной комнаты центрируют именно browser-target и помещают
её видимый пол вместе с ограничивающими стенами в центральные 80% stage.
- [ ] В Flat и Iso ограничивающая ось даёт поля `10% ± 1 CSS px`; устройства,
badges, room label, Glow, солнце, подложка и декор bounds не расширяют.
- [ ] Device/capsule, opening/lock/action, vacuum и HA Area link выполняют только
своё действие; pan выше click threshold, pinch и long press не запускают
room fit, а движение ниже threshold остаётся tap.
- [ ] В kiosk два tap по комнате не запускают Fit all и не меняют background
double-tap sequence; два tap по свободному фону сохраняют прежний reset.
- [ ] Быстрые клики разных комнат retarget-ят один camera controller; повторный
fit уже вписанной комнаты не создаёт RAF и не пишет `LS_ZOOM`.
- [ ] Stable resize атомарно пересчитывает активную комнату. Wheel, zoom buttons,
Fit all/home, pan/pinch, mode/space/projection, hidden/disconnect и новая
structural snapshot снимают intent.
- [ ] Видимая подпись имеет один `role=button`/tab stop, локализованное имя и
`:focus-visible`; Enter/Space вызывают тот же fit и не двигают focus.
Скрытая/безымянная подпись не создаёт невидимый tab stop.
- [ ] Pure proofs: `node --test test/room-fit.test.mjs`; полный cycle:
`npm run typecheck`, `npm test`, `npm run build`. Browser matrix и
мутации room ownership и session-only zoom persistence выполняются перед
бетой.