Files
houseplan-card/docs/specs/152-room-click-fit.md
Sergey Matyunin f672de674f
Проверка (CI) / Классификация изменённых файлов (push) Successful in 49s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 49s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 5m3s
docs: revise room click fit specification
Issue: #152
User-Visible: no
2026-09-04 09:37:44 +03:00

35 KiB
Raw Permalink Blame History

ТЗ #152 — Click/tap по комнате вписывает её в View

  • Issue: https://github.com/Matysh/houseplan-card/issues/152
  • Приоритет: P2, feature
  • Маршрут: full; новый pointer/touch/keyboard UX-контракт View и kiosk
  • Связанные задачи: #82 (единый camera transition, реализован), #28 (карточка комнаты закрыта как not planned), #73 и #101 (визуальная непрерывность)

Сценарий и пользовательская ценность

Житель либо home admin открывает большой план на телефоне, настенной панели или desktop и хочет рассмотреть конкретную комнату. Вместо ручной комбинации zoom и pan он нажимает на комнату: House Plan плавно центрирует её и показывает целиком с безопасными полями. Действие одинаково предсказуемо в Flat и скрытом Isometric View.

Что человек увидит до и после

До: одиночный click/tap по полу комнаты управляет только hover/touch подсказкой. Чтобы приблизить комнату, пользователь вручную масштабирует и перемещает весь план.

После: чистый primary click мышью или одиночный tap по комнате центрирует и максимально приближает её без обрезания, оставляя не менее 10% видимой области с каждой стороны. Кнопка «Вписать всё» возвращает весь план. В kiosk двойной tap по свободному фону по-прежнему вписывает весь план; в обычном View свободный фон по-прежнему ничего не делает.

Подтверждённое текущее состояние

  1. Room floor уже рендерится как SVG-элемент data-hp="room" с data-id, а browser target и порядок SVG-слоёв определяют тот же room hit, что текущий hover. Отдельного канонического point-in-room resolver в View нет.
  2. .roomlabel имеет room identity, но сейчас не является клавиатурной кнопкой: у неё нет role, tabindex, aria-label и room-action keydown.
  3. Общий CameraTransitionController из #82 уже обслуживает zoom, wheel, fit/home и kiosk double-tap, умеет retarget/cancel от показанного кадра и уважает reduced motion.
  4. Kiosk распознаёт double-tap reset через _lastTap в _stagePointerUp. Обычный View такого действия на свободном фоне не имеет.
  5. LS_ZOOM сохраняет только zoom, а не центр камеры. Поэтому временный room-fit не может проходить через обычную zoom-only запись при завершении.

Скоуп

В задачу входят:

  • room-owned click/tap в основном View и kiosk;
  • точные Flat и Isometric bounds пола и видимого тела граничных стен;
  • чистый расчёт camera target с полями 10% и текущими zoom limits;
  • подключение к единственному camera transition controller #82;
  • room-focus intent, resize-refit и все причины его отмены;
  • арбитраж с pan, pinch, long press, kiosk double-tap и интерактивными объектами;
  • один клавиатурный action target на каждую уже видимую подпись комнаты;
  • unit, browser smoke, golden, отрицательные witnesses и документация.

Не-скоуп

  • открытие карточки либо диалога комнаты по этому же жесту;
  • кнопка «Назад» и история перемещений камеры;
  • автоматическое переключение пространства или этажа;
  • новая настройка, Labs-флаг или изменение публичного config;
  • добавление devices, badges, Glow, солнца, vacuum, decor, backdrop, tooltip или вручную вынесенной подписи в room bounds;
  • изменение room/wall model, площади, backend API либо persistence schema;
  • второй animator или параллельная система camera state;
  • добавление fit-all double-tap в обычный non-kiosk View;
  • невидимый tab-stop на floor каждой комнаты и полная accessibility-система плана из #31;
  • свободное вращение изометрической камеры.

Геометрический контракт

Видимая область и safe rectangle

Пусть фактическая область stage, уже свободная от панелей и системного chrome, равна W × H CSS px. Safe rectangle имеет отступ 10% с каждой стороны и размер qW × qH, где q = 0.8.

Room bounds B — AABB конечной видимой геометрии выбранной комнаты в системе координат текущего camera view. Он включает:

  • видимый пол и контур комнаты;
  • фактически оставшиеся тела всех стен, ограничивающих эту комнату, с локальной толщиной, стыками и участками около проёмов;
  • нулевую/виртуальную границу только в мере её реально видимого stroke.

Bounds не расширяют устройства и badges, Glow/spill, солнечные лучи, vacuum, decor, backdrop, room hover/tooltip и HTML-подпись комнаты. Символ проёма и дуга двери не расширяют bounds; сохранившиеся по сторонам проёма участки кладки входят в bounds.

Источник bounds — те же рассчитанные floor/wall primitives, которые получает render path, с сохранённой room provenance. Запрещено строить вторую модель стены, мерить глобальный untagged wall union либо использовать DOM getBoundingClientRect() в pointer path.

Flat и Isometric

В Flat AABB строится по конечным вершинам floor/outline и тел граничных стен. В Isometric каждая входящая floor/wall vertex сначала проецируется текущими iso-projection helpers с фактической высотой/depth, и только затем по всем проецированным точкам строится AABB. Проекция plan-space прямоугольника целиком вместо точных primitives запрещена: она может включить пустые углы вогнутой комнаты или пропустить выступ стены.

Camera target

Для stage aspect A = W / H и bounds B.w × B.h минимальная ширина viewBox, помещающая комнату в safe rectangle:

requiredViewWidth = max(B.w / q, (B.h / q) * A)

Пусть F = fitView(baseBounds, A) — тот же базовый fit, относительно которого текущая камера выражает zoom. Тогда:

zoom = clamp(F.w / requiredViewWidth, ZOOM_MIN, ZOOM_MAX)
viewWidth = F.w / zoom
viewHeight = viewWidth / A
center = center(B)

Конечный camera target центрируется на B. Текущий pan clamp не вправе после этого сдвинуть выбранную комнату за safe rectangle. Для detached/outlier room clamp использует reference bounds, включающие B, либо эквивалентное локальное ограничение; _showFar, глобальный content frame и данные пространства не меняются.

При достижении ZOOM_MIN или ZOOM_MAX поля могут стать больше 10%, но комната не обрезается. Приоритеты: целиком видимая комната → её центр в центре stage → максимальный допустимый zoom. На ограничивающей оси без clamp поля равны 10% ± 1 CSS px, на другой — не меньше 10%.

Контрольный пример: stage 2000 × 1000 px и квадратные room bounds дают 800 × 800 px по центру: сверху/снизу по 100 px, слева/справа по 600 px.

Вырожденные, пустые или нечисловые bounds и stage с W <= 0 либо H <= 0 не меняют viewport. Одно намерение разрешено отложить до первого валидного stable resize; при потере ownership оно удаляется и не образует очередь.

Pointer/touch ownership

Канонический room target

На pointerdown запоминается spaceId и ближайший rendered SVG target [data-hp="room"][data-id] из фактического event path. Неинтерактивная часть HTML .roomlabel[data-id] явно отображается в тот же room id; это единственное исключение, потому что подпись находится вне SVG. На pointerup room fit принимается только если тот же canonical room id всё ещё владеет жестом. Тем самым floor-вложенность и z-order совпадают с текущим hover без нового pointInRoom resolver.

Click/tap принимается, когда одновременно выполнено:

  1. режим — View либо kiosk, не редактор;
  2. primary pointer; down/up принадлежат одной комнате текущего пространства;
  3. движение не превысило действующий click threshold;
  4. sequence не стал pan, pinch, object drag или long press;
  5. initial composed path не содержит интерактивного владельца.

Интерактивными владельцами считаются device marker/capsule, opening и его lock/action, vacuum marker, HA-link, button, input/control, явный action target и любой элемент, уже распознаваемый приложением как самостоятельное действие. Они выполняют только своё действие. Это доказывает общий owner guard, а не случайный stopPropagation отдельных элементов.

Неинтерактивная часть .roomlabel принадлежит своей комнате. Ссылка HA Area в ней остаётся отдельным интерактивным владельцем. Glow и остальные декоративные слои не участвуют в hit ownership и не мешают нажатию на floor.

Double-tap без задержки single tap

Room tap принимается сразу на завершившемся clean pointerup; ожидания 350 мс для различения single/double tap нет. Повторный tap той же комнаты retarget-ит её либо становится no-op. Любой release, которым владела комната, не обновляет kiosk _lastTap и не запускает _resetZoom('double-tap'), поэтому второй tap по комнате не сбрасывает план.

Только в kiosk два clean tap по свободному фону сохраняют текущий reset всего плана и текущий timeout. В обычном View такой double-tap остаётся no-op. Начало pan/pinch/long press сбрасывает кандидат комнаты и не оставляет ложного tap.

Camera transition, intent и persistence

Один controller

CameraTransitionReason получает причину room либо эквивалентную явную completion policy. Room fit запускается через существующий CameraTransitionController #82 с действующей длительностью fit CAMERA_FIT_MS; новый RAF owner запрещён.

  • новая комната retarget-ит transition от фактически показанного кадра;
  • pointer pan/pinch отменяет transition с freeze показанного кадра;
  • идентичный конечный target — no-op без RAF, render и storage write;
  • prefers-reduced-motion: reduce применяет финальный target атомарно;
  • SVG scene и HTML overlays читают один camera state на каждом tween frame.

Завершение/cancel transition с reason room не вызывает zoom-only _saveZoom(). Обычные wheel/button/fit/home/double-tap команды сохраняют действующий контракт без изменений.

Room-focus intent

После принятого действия хранится session-only intent со стабильными spaceId/roomId, но не DOM/model reference. Он не сериализуется в config, LS_ZOOM, warm memo или backend как новый пользовательский preference.

На stable ResizeObserver update активный intent заново получает primitives, пересчитывает bounds и атомарно применяет новый room fit по контракту структурного resize #82. Resize не запускает новый tween.

Intent отменяют:

  • ручной pan, pinch или wheel;
  • zoom buttons, «Вписать всё», home и kiosk free-background double-tap;
  • переход в/из редактора, смена space или projection;
  • потеря visibility, disconnect/remount и внешнее adoption/reset camera state;
  • удаление комнаты, invalid geometry после разрешённой однократной отсрочки.

Click другой комнаты заменяет intent; последний принятый выбор побеждает. Само движение камеры не меняет room/device state, не закрывает открытый dialog и не переносит DOM focus.

Клавиатура и доступность

Это узкое исключение из общего non-scope accessibility плана, необходимое для keyboard parity нового pointer action; полная навигация по плану остаётся #31.

  • В View/kiosk каждая уже видимая .roomlabel получает один action target на неинтерактивной поверхности: role="button", tabindex="0", локализованный aria-label и различимый :focus-visible.
  • Порядок tab следует текущему DOM/render order space.rooms; новых скрытых floor targets нет. Если подпись комнаты скрыта настройкой или не рендерится, невидимый tab-stop для комнаты не создаётся.
  • Enter и Space вызывают тот же room command; Space предотвращает scroll.
  • Вложенная HA Area link остаётся отдельной ссылкой, не запускает room fit и не получает второй room action.
  • Accessible name: Вписать комнату {name} / Fit room {name}. Пустое имя в View уже не рендерит подпись, поэтому отдельный безымянный target не создаётся.
  • Focus остаётся на активированной подписи на протяжении transition/resize.

Touch, темы и режимы

Touch в View и kiosk — блокирующая поверхность: tap не должен конкурировать с pan/pinch/long press. Mouse и pen используют тот же ownership contract. Light и dark theme не меняют математику или focus target; focus-visible остаётся различимым в обеих темах. В Plan/Backdrop editors room action полностью отсутствует, включая keyboard activation существующей подписи.

Данные, миграция, compatibility и i18n

  • HousePlanConfig, версия схемы, room/wall/decor records и backend API не меняются; миграции и compatibility-поля не нужны.
  • Room-focus intent и geometry cache — transient runtime state одного card instance. Reload/remount начинает с действующего общего camera restore без восстановления выбранной комнаты.
  • LS_ZOOM и формат warm camera memo не меняются. Room transition не записывает zoom-only значение; остальные camera commands продолжают сохраняться как до задачи.
  • Добавляются два parity-ключа en/ru для accessible action, если существующий formatter не подходит: room.fit_action = Fit room {name} / Вписать комнату {name}. Ключ fallback room name переиспользуется.

Производительность

  • Room primitives/bounds вычисляются один раз на accepted action и один раз на stable resize с активным intent, не на каждый pointermove и не на каждый RAF.
  • Tween меняет только camera state; structural geometry не перестраивается на каждом кадре.
  • Новый helper остаётся pure и не читает DOM layout. Допускается epoch-cache с инвалидированием при смене geometry/projection.
  • Асимптотика одного fit — O(V) по вершинам пола и граничных wall primitives выбранной комнаты. Не допускается обход всех devices/decor.
  • bundle:budget и действующий frame budget #82 остаются зелёными; новый long task на canonical large-house fixture недопустим.

Ошибки и крайние случаи

Случай Ожидаемое поведение
вогнутая/Г-образная комната fit по точным primitives, без bbox пустых углов до projection
толстые стены и проёмы видимое тело кладки целиком внутри safe rectangle
виртуальная граница входит только реальный stroke, не получает ложную толщину
вложенные комнаты выигрывает фактическая верхняя SVG-room target, как при hover
device/HA-link/opening поверх пола только дочернее действие, room fit отсутствует
detached room у края frame комната центрируется, pan clamp её не обрезает
далёкий decor/backdrop не влияет на room fit и _showFar
нулевой stage один defer либо no-op без NaN/вечной очереди
invalid room geometry no-op и dev diagnostic без production console noise
быстрые taps разных комнат последний target побеждает, очередь RAF не растёт
второй tap комнаты в kiosk no-op/retarget комнаты, не fit-all reset
double-tap свободного фона в kiosk прежний fit-all reset
double-tap свободного фона в обычном View прежний no-op
resize во время room focus атомарный refit без tween до ручной camera команды
space/mode/projection/visibility/remount transition и intent безопасно отменены

Acceptance criteria и доказательства

Исполнитель реализации и автотестов — разработчик (Codex). Независимую проверку доказательств и кода выполняет ревьюер (Claude).

AC1. Clean room click/tap центрирует и вписывает комнату

unit + smoke, Codex: mouse/touch/pen clean release одной room target создаёт camera target, в котором room bounds целиком видны и центрированы. Smoke работает на production bundle. Mutation, удаляющая room command на pointerup, краснит smoke.

AC2. Поля и zoom limits соответствуют точной математике

unit, Codex: landscape/portrait/square fixtures проверяют обе ограничивающие оси, ZOOM_MIN/MAX, центр и отсутствие crop. Stage 2000 × 1000 с квадратом даёт 800 × 800 ± 1 CSS px. Mutation q: 0.8 → 1 либо удаление clamp краснит числовые assertions.

AC3. Bounds включает только каноническое видимое тело комнаты

unit + golden, Codex: вогнутая комната с неодинаковой толщиной, проёмом, стыком и виртуальной границей включает floor/outline/оставшуюся кладку, но не device, Glow, солнце, vacuum, decor, backdrop, label и tooltip. Mutation, добавляющая device/label extent либо убирающая wall body, краснит unit bounds и reviewed golden.

AC4. Flat и Isometric используют конечные точные primitives

unit + golden, Codex: Flat и Isometric fixtures проверяют AABB точных отрисованных вершин; вогнутый пример отличает результат от projection общего plan bbox. Mutation, проецирующая только прямоугольник floor bounds либо игнорирующая wall height, краснит unit и Iso golden.

AC5. Nested room выбирается тем же browser target, что hover

smoke, Codex: на перекрывающихся room hit areas click верхней [data-hp="room"] выбирает её data-id, а изменение SVG z-order меняет hover и click одинаково. Mutation, заменяющая target ownership на независимый pointInRoom, краснит smoke.

AC6. Интерактивный ребёнок полностью подавляет room fit

smoke, Codex: device capsule, opening/lock, vacuum marker, HA Area link и button выполняют своё действие один раз и не меняют camera target; Glow не блокирует floor click. Mutation, снимающая общий interactive-owner guard, регистрируется в scripts/mutation-gate.mjs и краснит smoke.

AC7. Pan, pinch, drag и long press не завершаются room fit

smoke, Codex: движение выше текущего click threshold, второй pointer и kiosk long press отменяют room candidate; ниже threshold tap принимается. Mutation, игнорирующая movement/gesture ownership guard, регистрируется в mutation-gate и краснит smoke.

AC8. Room tap и kiosk free-background double-tap не конфликтуют

smoke, Codex: два taps комнаты выполняют room fit/no-op без reset и не обновляют background tap sequence; два taps свободного фона reset-ят только kiosk, а в обычном View остаются no-op. Mutation, учитывающая room release в _lastTap, регистрируется в mutation-gate и краснит kiosk smoke.

AC9. Переход переиспользует controller #82 и корректно retarget/cancel

unit + smoke, Codex: новый room target во время tween стартует от presented frame; pan/pinch freeze-ит тот же frame; один RAF owner и одна очередь. Mutation, создающая отдельный RAF либо retarget от старого target, краснит controller unit или smooth-camera smoke.

AC10. SVG и HTML hit targets синхронны на промежуточных кадрах

smoke, Codex: на нескольких управляемых timestamp в середине tween SVG-room, device, opening и HTML room label совпадают с единым camera state; click по их фактическому intermediate положению попадает в ожидаемый target. Проверяется не только конечный кадр. Mutation, задерживающая overlay camera update на один frame, зарегистрирована в mutation-gate и краснит smoke.

AC11. Repeated fit является полным no-op

unit + smoke, Codex: повторная команда для тех же bounds/stage/projection не создаёт RAF, render update, history или storage write. Mutation, удаляющая equal-target guard, краснит spy assertions.

AC12. Resize сохраняет focus только до ручной camera команды

unit + smoke, Codex: stable resize атомарно пересчитывает fit активной комнаты; pan, pinch, wheel, zoom button, fit/home и kiosk background reset снимают intent, после чего resize сохраняет обычный viewport. Mutation, не снимающая intent на одной из ручных команд, краснит lifecycle matrix.

AC13. Структурные переходы и invalid geometry fail safe

unit + smoke, Codex: space/mode/projection/visibility/remount/delete-room отменяют intent/transition; zero stage допускает не более одного defer; NaN или degenerate geometry не меняет viewport и не оставляет очередь. Mutation, пропускающая lifecycle guard, краснит state assertions и smoke переходов.

AC14. Keyboard activation доступна только на видимой room label

smoke + ревью кода, Codex/Claude: видимая .roomlabel в View/kiosk имеет один role=button, tabindex=0, локализованный name и focus-visible; Enter/Space дают тот же camera target и сохраняют focus. Hidden/unrendered label не создаёт tab-stop, HA-link выполняет только навигацию, editors не получают room action.

AC15. Room focus не меняет данные и не пишет zoom-only state

unit + smoke + ревью кода, Codex/Claude: camera room completion/cancel не вызывает LS_ZOOM write, config/backend/history неизменны, reload/remount не восстанавливает room intent; обычные camera reasons продолжают сохраняться. Mutation, направляющая reason room в _saveZoom, краснит storage spy.

AC16. Производительность и bundle budget не регрессируют

unit + performance + ревью кода, Codex/Claude: bounds строится один раз на accepted action/stable resize, DOM layout не читается, tween не перестраивает geometry; bundle:budget и canonical #82 frame budget зелёные.

План тестирования

  • Новый src/room-fit.ts покрыть pure unit fixtures: target math, invalid input, exact padding, limits, Flat/Iso primitive AABB и detached clamp reference.
  • Расширить test/viewport-transition.test.mjs: reason room, same-target no-op, presented-frame retarget/cancel и completion persistence policy.
  • Добавить unit lifecycle room-focus intent: resize и полная cancel matrix.
  • Добавить demo/smoke_room_fit.mjs для production-bundle mouse/touch/pen, nested target, interactive owners, kiosk/non-kiosk double tap, keyboard, resize, rapid retarget и invalid geometry.
  • Расширить demo/smoke_smooth_zoom.mjs промежуточными tween timestamps и фактическим hit-test SVG/HTML overlays.
  • Добавить AC6/AC7/AC8/AC10 в scripts/mutation-gate.mjs; для дешёвых pure unit AC2/AC3/AC4/AC11/AC12/AC15 ревью фиксирует локальную снятую защиту и красный вывод соответствующего теста.
  • Добавить Flat/Isometric room-fit golden в light/dark. Golden проверяет crop, wall body и визуальную синхронность, а не точный event ownership.
  • В реализации гонять только typecheck, unit, build и выбранные быстрые проверки. Golden, полный smoke и performance — перед beta по процессу.
  • Перед S7 выполнить node scripts/smoke-select.mjs --base origin/dev --head HEAD и все выбранные task-specific smokes; результаты и таблица «чем краснеет» входят в документ code review.

Карта реализации

  • src/room-fit.ts — pure bounds/fit math и валидность входов;
  • src/viewport-transition.ts — reason/completion policy room без нового RAF;
  • src/houseplan-card.ts — room ownership, camera command, intent lifecycle, resize/cancel/persistence orchestration и label keyboard action;
  • src/styles/plan.styles.ts — focus-visible room label без визуального изменения обычного состояния;
  • текущие wall/iso render helpers — только выдача/переиспользование точных primitives с room provenance, без второй geometry model;
  • en/ru locale modules — accessible action key с parity;
  • test/room-fit.test.mjs, test/viewport-transition.test.mjs;
  • demo/smoke_room_fit.mjs, demo/smoke_smooth_zoom.mjs;
  • scripts/mutation-gate.mjs;
  • пользовательская и техническая документация из release-артефактов.

Точная раскладка helper допускает изменение при code review, если один источник render primitives, AC и запрет DOM layout сохраняются.

Риски и rollback

Риск Мера
room click конфликтует с pan/child action один gesture owner, smoke matrix и мутанты guards
Iso room визуально обрезана AABB точных projected primitives и reviewed golden
pan clamp сдвигает detached room clamp reference включает selected bounds, отдельный fixture
camera прыгает при retarget/resize controller #82 и presented-frame tests
room transition портит restore zoom отдельная completion policy и storage spy
много room labels ухудшают tab order target только у уже видимой подписи, DOM room order, без floor grid

Rollback — revert frontend/tests/docs/bundle: single tap снова ничего не делает, а существующие camera actions остаются на контроллере #82. Data rollback и обратная миграция не нужны, потому что схема и persistence format не меняются.

Release-артефакты

  • User-visible implementation commit обновляет docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылкой на #152.
  • docs/USER-GUIDE.ru.md описывает click/tap/keyboard room fit, кнопку «Вписать всё» и kiosk-only background double-tap.
  • docs/CANVAS.md фиксирует bounds, gesture ownership, intent и storage policy; docs/ISOMETRIC.md — projected primitive bounds.
  • docs/TESTING.md получает room-fit smoke/golden и negative-witness matrix.
  • Любой src/** diff обновляет canonical Docs screenshots fingerprint через workflow Docs screenshots и npm run docs:accept -- --reviewed.
  • Reviewed Flat/Isometric light/dark golden artifacts и browser smoke report обязательны перед beta; performance report переиспользует canonical gate #82.
  • Security/backend artifact не нужен: новых внешних входов, сетевых запросов и backend изменений нет.

Принятые предположения

  • 10% считаются от фактической stage в CSS-пикселях после layout, не от окна браузера или всего card element.
  • Wall body входит только с room provenance; глобальная кладка другой комнаты не расширяет bounds.
  • Browser SVG target/z-order — фактический authority текущего room hover; новый point-in-polygon resolver не вводится.
  • Room single tap выполняется сразу на pointerup; второй tap не отменяет первый, а повторяет/no-op и никогда не превращается в kiosk reset.
  • Keyboard target создаётся только на уже видимой room label. Комната без подписи остаётся доступной pointer/touch, без невидимого tab-stop.
  • Camera fit длится текущие CAMERA_FIT_MS; reduced motion остаётся атомарным.
  • Room-focus intent session-only и не записывается даже в zoom-only storage.
  • Закрытый #28 не резервирует primary room gesture; будущая room card должна получить отдельный явный action.