docs: revise room click fit specification
Проверка (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

Issue: #152
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-04 09:37:44 +03:00
parent 68992d5582
commit f672de674f
+439 -204
View File
@@ -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.