diff --git a/docs/specs/152-room-click-fit.md b/docs/specs/152-room-click-fit.md index 81fbd446..fb036d2b 100644 --- a/docs/specs/152-room-click-fit.md +++ b/docs/specs/152-room-click-fit.md @@ -1,270 +1,505 @@ -# Issue #152 — click/tap по комнате вписывает её в View +# ТЗ #152 — Click/tap по комнате вписывает её в View -- **Issue:** https://github.com/Matysh/houseplan-card/issues/152 -- **Статус документа:** готово к будущей реализации; issue остаётся на `S3-spec` -- **Приоритет:** P2 -- **Тип:** feature, обычный трек -- **Пользовательское изменение:** да +- 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 (визуальная непрерывность) -## 1. Проблема и результат для пользователя +## Сценарий и пользовательская ценность -На большом плане пользователь вынужден вручную совмещать pan и zoom, чтобы -рассмотреть конкретную комнату. После реализации чистый primary click мышью или -одиночный tap по комнате центрирует её и выбирает максимальный допустимый -масштаб, при котором комната целиком видна с безопасными полями не менее 10%. +Житель либо home admin открывает большой план на телефоне, настенной панели или +desktop и хочет рассмотреть конкретную комнату. Вместо ручной комбинации zoom и +pan он нажимает на комнату: House Plan плавно центрирует её и показывает целиком +с безопасными полями. Действие одинаково предсказуемо в Flat и скрытом +Isometric View. -Поведение действует только в основном View, включая kiosk. Редакторы сохраняют -семантику активного инструмента. +## Что человек увидит до и после -## 2. Scope +**До:** одиночный 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 hit для мыши, touch и keyboard activation; -- расчёт финального screen-space bounds комнаты в Flat и Isometric View; -- чистая функция fit-to-room с 10% полями и существующими zoom limits; -- управление room-focus intent, resize и отменой при ручном управлении камерой; -- разрешение конфликтов с pan, pinch, long press, double tap и интерактивными - дочерними объектами; -- доступное действие через существующий room label/card target; -- тесты геометрии, событий, камеры и обеих проекций; -- пользовательская и техническая документация. +- 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 и документация. -## 3. Не входит в задачу +## Не-скоуп -- открытие карточки/диалога комнаты тем же primary click; -- новая кнопка «назад» или история камеры; -- автоматическая смена пространства/этажа; -- изменение room/wall model либо backend API; -- включение устройств, Glow, decor или вынесенной подписи в fit bounds; -- свободное вращение изометрической камеры; -- отдельный animator в обход #82; -- изменение существующего double-click/tap fit всего плана на свободном фоне. +- открытие карточки либо диалога комнаты по этому же жесту; +- кнопка «Назад» и история перемещений камеры; +- автоматическое переключение пространства или этажа; +- новая настройка, 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; +- свободное вращение изометрической камеры. -## 4. Геометрический контракт +## Геометрический контракт -Пусть фактическая видимая область stage после вычета панелей и системного -chrome равна `W × H` CSS-пикселей. Safe rectangle имеет отступ 10% с каждой -стороны и размер `0.8W × 0.8H`. +### Видимая область и safe rectangle -Room bounds — axis-aligned bounding box комнаты в финальных экранных -координатах. Он включает: +Пусть фактическая область stage, уже свободная от панелей и системного chrome, +равна `W × H` CSS px. Safe rectangle имеет отступ `10%` с каждой стороны и +размер `qW × qH`, где `q = 0.8`. -- пол и контур комнаты; -- видимое тело стен, образующих границу комнаты, с фактической толщиной; -- видимые участки границы около проёмов и стыков. +Room bounds `B` — AABB конечной видимой геометрии выбранной комнаты в системе +координат текущего camera view. Он включает: -Bounds не расширяют: +- видимый пол и контур комнаты; +- фактически оставшиеся тела всех стен, ограничивающих эту комнату, с локальной + толщиной, стыками и участками около проёмов; +- нулевую/виртуальную границу только в мере её реально видимого stroke. -- устройства, их icons/badges и live markers; -- Glow/spill и солнечные лучи; -- vacuum, decor и backdrop; -- tooltip и вручную вынесенная подпись комнаты. +Bounds не расширяют устройства и badges, Glow/spill, солнечные лучи, vacuum, +decor, backdrop, room hover/tooltip и HTML-подпись комнаты. Символ проёма и дуга +двери не расширяют bounds; сохранившиеся по сторонам проёма участки кладки +входят в bounds. -Камера сохраняет пропорции, совмещает визуальный центр room bounds с центром -stage и выбирает максимальный zoom, при котором bounds помещается в safe -rectangle. В Flat для bounds `Rw × Rh` базовая формула: +Источник 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: ```text -scale = min(0.8W / Rw, 0.8H / Rh) +requiredViewWidth = max(B.w / q, (B.h / q) * A) ``` -Результат ограничивается существующими `ZOOM_MIN`/`ZOOM_MAX`. При конфликте -приоритеты: не обрезать комнату → центрировать → максимально приблизить. На -ограничивающей оси поля равны 10% ± 1 CSS px, на другой — не меньше 10%. +Пусть `F = fitView(baseBounds, A)` — тот же базовый fit, относительно которого +текущая камера выражает zoom. Тогда: -Для stage 2000 × 1000 px и квадратной комнаты результат равен 800 × 800 px по -центру: вертикальные поля по 100 px, горизонтальные — по 600 px. +```text +zoom = clamp(F.w / requiredViewWidth, ZOOM_MIN, ZOOM_MAX) +viewWidth = F.w / zoom +viewHeight = viewWidth / A +center = center(B) +``` -## 5. Flat и Isometric bounds +Конечный camera target центрируется на `B`. Текущий pan clamp не вправе после +этого сдвинуть выбранную комнату за safe rectangle. Для detached/outlier room +clamp использует reference bounds, включающие `B`, либо эквивалентное локальное +ограничение; `_showFar`, глобальный content frame и данные пространства не +меняются. -Flat использует каноническую геометрию пола и граничных стен. В Isometric -сначала применяется текущая canonical scene/projection, затем строится AABB -проецированного видимого тела комнаты. Нельзя вписывать только исходный -plan-space bbox, если после projection стена обрезается или поле становится -меньше 10%. +При достижении `ZOOM_MIN` или `ZOOM_MAX` поля могут стать больше 10%, но комната +не обрезается. Приоритеты: целиком видимая комната → её центр в центре stage → +максимальный допустимый zoom. На ограничивающей оси без clamp поля равны +`10% ± 1 CSS px`, на другой — не меньше 10%. -Расчёт не должен создавать вторую модель стены либо расходиться с render path. -Допускается чистый geometry helper или измерение уже рассчитанных projected -primitives; чтение DOM layout в горячем pointer path допускается только если -профилирование докажет отсутствие заметного forced layout. +Контрольный пример: stage `2000 × 1000 px` и квадратные room bounds дают +`800 × 800 px` по центру: сверху/снизу по 100 px, слева/справа по 600 px. -При вырожденной/нечисловой геометрии либо stage без валидного размера viewport -не меняется. Действие может быть один раз отложено до первого валидного resize, -если ownership исходного intent ещё актуален; бесконечной очереди не создаётся. -В dev diagnostic фиксируется причина без production console noise. +Вырожденные, пустые или нечисловые bounds и stage с `W <= 0` либо `H <= 0` не +меняют viewport. Одно намерение разрешено отложить до первого валидного stable +resize; при потере ownership оно удаляется и не образует очередь. -## 6. Ownership pointer-жеста +## Pointer/touch ownership -Room fit выполняется только если: +### Канонический room target -1. `pointerdown` и `pointerup` принадлежат одной канонически выбранной комнате; -2. движение не превысило действующий click threshold; -3. sequence не стал pan, pinch, drag или long press; -4. target не принадлежит интерактивному дочернему объекту; -5. одиночный tap не является первой половиной room double-tap sequence. +На `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. -Для вложенных и пересекающихся hit areas используется тот же resolver и тот же -порядок, что у текущего room hover. Второй resolver запрещён. Неинтерактивный -текст/фон подписи комнаты относится к этой комнате. Glow и другие декоративные -слои не перехватывают room hit. +Click/tap принимается, когда одновременно выполнено: -Device, opening, vacuum marker, HA-link, button, explicit action element и иной -интерактивный потомок полностью владеют жестом: их действие не проваливается в -room fit. `stopPropagation` не должен быть единственным доказательством — общий -gesture arbiter обязан распознавать owner до выполнения действия. +1. режим — View либо kiosk, не редактор; +2. primary pointer; down/up принадлежат одной комнате текущего пространства; +3. движение не превысило действующий click threshold; +4. sequence не стал pan, pinch, object drag или long press; +5. initial composed path не содержит интерактивного владельца. -## 7. Double tap и свободный фон +Интерактивными владельцами считаются device marker/capsule, opening и его +lock/action, vacuum marker, HA-link, button, input/control, явный action target +и любой элемент, уже распознаваемый приложением как самостоятельное действие. +Они выполняют только своё действие. Это доказывает общий owner guard, а не +случайный `stopPropagation` отдельных элементов. -- Double click/tap, начатый на room hit-area, принадлежит комнате и не вызывает - существующий reset/fit всего плана. -- Room fit выполняется один раз по завершённому single-tap contract, а не - немедленно на `pointerdown`. -- Double click/tap по свободному фону сохраняет существующий fit/reset всего - плана без изменений. -- Pan, pinch, drag и long press никогда не завершаются ложным room fit. +Неинтерактивная часть `.roomlabel` принадлежит своей комнате. Ссылка HA Area в +ней остаётся отдельным интерактивным владельцем. Glow и остальные декоративные +слои не участвуют в hit ownership и не мешают нажатию на floor. -Распознавание использует текущие thresholds/timeouts приложения. Новые -независимые константы без общей причины не вводятся. +### Double-tap без задержки single tap -## 8. Viewport controller и lifecycle +Room tap принимается сразу на завершившемся clean `pointerup`; ожидания 350 мс +для различения single/double tap нет. Повторный tap той же комнаты retarget-ит +её либо становится no-op. Любой release, которым владела комната, не обновляет +kiosk `_lastTap` и не запускает `_resetZoom('double-tap')`, поэтому второй tap +по комнате не сбрасывает план. -Конечный viewport вычисляет одна чистая функция из room bounds, stage bounds и -zoom limits. Room-focus intent хранит стабильный `spaceId`/`roomId`, а не ссылку -на transient DOM/model object. +Только в kiosk два clean tap по свободному фону сохраняют текущий reset всего +плана и текущий timeout. В обычном View такой double-tap остаётся no-op. Начало +pan/pinch/long press сбрасывает кандидат комнаты и не оставляет ложного tap. -- повторный fit уже вписанной комнаты с теми же входами — no-op; -- resize пересчитывает fit выбранной комнаты по новым `W/H`; -- ручной pan, wheel/pinch zoom или явный fit/reset всего плана снимает intent; -- выбор другой комнаты заменяет intent, последний выбор побеждает; -- смена space, mode, projection eligibility, visibility или remount отменяет - intent и незавершённый transition; -- room fit не меняет модель/backend и не записывает отдельный zoom без центра; -- общий существующий контракт восстановления View после reload сохраняется. +## Camera transition, intent и persistence -Если к моменту реализации #82 уже есть, переход использует единственный -`ViewportAnimator`, включая retarget/cancel от реально показанного кадра. До -#82 конечное состояние применяется атомарно. При `prefers-reduced-motion: -reduce` оно всегда применяется без tween. +### Один controller -Viewport, SVG и HTML overlays получают один и тот же camera state на каждом -кадре; промежуточное рассогласование hit targets недопустимо. +`CameraTransitionReason` получает причину `room` либо эквивалентную явную +completion policy. Room fit запускается через существующий +`CameraTransitionController` #82 с действующей длительностью fit +`CAMERA_FIT_MS`; новый RAF owner запрещён. -## 9. Accessibility +- новая комната 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. -- Существующая доступная подпись либо room-card target получает action Enter и - Space с тем же fit result. -- Для каждой floor polygon не создаётся отдельная невидимая сетка tab stops. -- Accessible name локализован и содержит действие и имя комнаты, например - «Вписать комнату Гостиная» / `Fit room Living room`. -- Клавиатурная активация не запускает дочернее HA action. -- Перемещение камеры не переносит DOM focus и не закрывает уже открытый dialog - или tooltip другого объекта. -- Focus-visible не зависит от pointer modality и остаётся различимым. +Завершение/cancel transition с reason `room` не вызывает zoom-only `_saveZoom()`. +Обычные wheel/button/fit/home/double-tap команды сохраняют действующий контракт +без изменений. -## 10. Конфликт с #28 +### Room-focus intent -Продуктовое решение: primary click/tap по полу комнаты принадлежит fit-to-room. -#28 не может тем же жестом открывать room card. Перед реализацией #28 карточке -нужен отдельный явный trigger — например action у подписи комнаты — и отдельное -согласование. Автоматически открывать карточку после fit запрещено. +После принятого действия хранится session-only intent со стабильными +`spaceId`/`roomId`, но не DOM/model reference. Он не сериализуется в config, +`LS_ZOOM`, warm memo или backend как новый пользовательский preference. -## 11. Acceptance criteria +На stable ResizeObserver update активный intent заново получает primitives, +пересчитывает bounds и атомарно применяет новый room fit по контракту +структурного resize #82. Resize не запускает новый tween. -1. Single click/tap по комнате целиком вписывает и центрирует её. -2. Screen-space поля равны 10% ± 1 px на ограничивающей оси и ≥10% на другой. -3. Fixture 2000 × 1000 с квадратной комнатой даёт 800 × 800 ± 1 px. -4. Room bounds включает видимое тело граничных стен и исключает live/decor/label. -5. Flat и Isometric проверяются по итоговой экранной геометрии. -6. Вложенную комнату выбирает существующий canonical hover/hit resolver. -7. Interactive child выполняет только своё действие; Glow не блокирует комнату. -8. Pan/pinch/drag/long press и double tap не создают ложный fit/reset. -9. Повторный fit с теми же входами не меняет viewport/render/storage. -10. Resize поддерживает focus до первого ручного изменения камеры. -11. Invalid geometry/stage не создаёт NaN, прыжок или вечную очередь. -12. Enter/Space на доступном room target дают тот же результат. -13. Reduced motion применяет конечный viewport атомарно. -14. Room fit не меняет config/backend и не регрессирует reload/remount. +Intent отменяют: -## 12. План тестирования +- ручной pan, pinch или wheel; +- zoom buttons, «Вписать всё», home и kiosk free-background double-tap; +- переход в/из редактора, смена space или projection; +- потеря visibility, disconnect/remount и внешнее adoption/reset camera state; +- удаление комнаты, invalid geometry после разрешённой однократной отсрочки. -### Unit +Click другой комнаты заменяет intent; последний принятый выбор побеждает. Само +движение камеры не меняет room/device state, не закрывает открытый dialog и не +переносит DOM focus. -- fit helper: landscape/portrait/square stage и room bounds; -- width/height-limited cases, `ZOOM_MIN/MAX`, degenerate и NaN inputs; -- exact 10% padding и центрирование; -- Flat/projected Iso bounds с толстыми стенами и opening; -- intent lifecycle: fit, no-op, resize, manual cancel, space/mode/remount cancel; -- nested rooms и interactive-child priority; -- click threshold, long press, pinch и double-tap arbitration; -- accessible name и keyboard activation. +## Клавиатура и доступность -### Browser smoke +Это узкое исключение из общего non-scope accessibility плана, необходимое для +keyboard parity нового pointer action; полная навигация по плану остаётся #31. -- точная fixture 2000 × 1000 → 800 × 800 ± 1 px; -- вогнутая/узкая комната и разные толщины стен полностью видны; -- tap пола с Glow, затем tap device/opening/HA-link; -- pan/pinch выше threshold и long press не выполняют room fit; -- double tap комнаты не reset-ит, free-background double tap сохраняется; -- быстрый выбор двух комнат, resize и затем manual wheel/pan; -- keyboard, touch, kiosk и reduced motion; -- visibility/mode/space transition во время fit без пустого кадра. +- В 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. -### Golden +## Touch, темы и режимы -- Flat и Isometric до/после fit в light/dark theme; -- overlays остаются синхронны, комната не обрезана; -- новые baselines проходят обычный visual review и не маскируют unrelated diff. +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 существующей подписи. -### Performance +## Данные, миграция, compatibility и i18n -- no-op не создаёт update/storage write; -- room fit не вызывает structural geometry rebuild на каждом кадре; -- не появляются новые long tasks на canonical large-house fixture; -- при animated path сохраняется действующий zoom/pan frame budget #82. +- `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 переиспользуется. -## 13. План реализации +## Производительность -1. Выделить canonical room final-screen bounds helper из render geometry. -2. Добавить чистый fit calculation и unit coverage. -3. Подключить room gesture ownership к существующему resolver/arbiter. -4. Добавить room-focus intent и его lifecycle в viewport controller. -5. Переиспользовать #82 animator либо применить результат атомарно. -6. Подключить существующий доступный room target и локализацию. -7. Выполнить typecheck, unit и build; перед beta — smoke, golden и performance. +- 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 недопустим. -## 14. Документация и release-артефакты +## Ошибки и крайние случаи -Поскольку изменение видно пользователю, implementation commit обязан иметь -`User-Visible: yes` и в том же коммите обновить: +| Случай | Ожидаемое поведение | +|---|---| +| вогнутая/Г-образная комната | 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 безопасно отменены | -- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`; -- `docs/USER-GUIDE.ru.md` — click/tap, keyboard, fit-all и конфликт действий; -- `docs/CANVAS.md` — bounds, gesture ownership и viewport intent; -- `docs/ISOMETRIC.md` — projected room bounds; -- `docs/TESTING.md` — новые smoke/golden сценарии. +## Acceptance criteria и доказательства -Нужны reviewed Flat/Isometric golden artifacts и browser smoke report. Новые -строки accessible name добавляются в обе локали проекта с parity-проверкой. +Исполнитель реализации и автотестов — **разработчик (Codex)**. Независимую +проверку доказательств и кода выполняет **ревьюер (Claude)**. -## 15. Риски и откат +### 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 | Риск | Мера | -| --- | --- | -| Click конфликтует с pan/child action | единый gesture owner и smoke matrix | -| Iso room визуально обрезана | финальный projected screen-space bounds | -| Камера прыгает при resize/tween | стабильный intent и single controller | -| #28 занимает тот же жест | primary click закреплён за fit | -| Forced layout замедляет tap | geometry helper и performance trace | +|---|---| +| 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 | -Откат удаляет room action и intent, возвращая ручную навигацию. Модель, -storage schema и backend не мигрируют, поэтому data rollback не требуется. +Rollback — revert frontend/tests/docs/bundle: single tap снова ничего не делает, +а существующие camera actions остаются на контроллере #82. Data rollback и +обратная миграция не нужны, потому что схема и persistence format не меняются. -## 16. Принятые предположения +## Release-артефакты -- 10% измеряются от фактической видимой stage в CSS-пикселях; -- room boundary wall body входит в bounds, остальные visual layers — нет; -- существующий room hover/hit resolver является единственным authority; -- до #82 переход атомарный, после #82 использует общий animator; -- существующий double-click/tap fit всего плана остаётся только на свободном фоне; -- отдельный публичный trigger room card относится к #28, не к этой задаче. +- 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.