Files
houseplan-card/docs/specs/449-double-fit-all.md
T
2026-09-04 18:18:14 +03:00

417 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #449 — двойной клик/тап по свободному фону вписывает весь план
- Issue: https://github.com/Matysh/houseplan-card/issues/449
- Приоритет: P2
- Тип: feature
- Трек: полный — новый UX-контракт и изменение гарантированного touch-контракта
- Связанные задачи: #82, #152, #183
- Решения владельца: Q1 Default, Q2 Default
- **Touch editor: not exposed** — жест живёт только во View и kiosk; редакторы
Плана, Устройств и Подложки (в ней редактируется декор) его не получают, их
touch-поведение и редакторский double-click не меняются (§6 «Режимы», AC7).
Правило `docs/TOUCH-SUPPORT.md` → «Documentation rule».
## Сценарий
Пользователь House Plan в обычном View или kiosk приблизил либо сдвинул план и
хочет быстро вернуться к обзору всего пространства. Вместо поиска кнопки в
верхней панели он дважды нажимает мышью или дважды касается свободного места на
сцене.
Комнаты и самостоятельные элементы плана сохраняют свои текущие действия:
комната вписывается одним нажатием, устройство выполняет своё действие, ссылка
открывается, проём и его controls не становятся поверхностью нового жеста.
## Что человек увидит до и после
До изменения двойной тап по свободному фону вписывает план только в kiosk, а в
обычном View двойной клик и двойной тап ничего не делают. После изменения два
быстрых clean-нажатия по свободному фону в View или kiosk плавно дают ровно тот
же кадр, что кнопка **«Вписать всё»**.
## Проблема и подтверждённое текущее состояние
В `src/houseplan-card.ts` уже есть единая команда `_fitAll()`: она снимает
room-focus, возвращает полный content frame, включая ранее скрытые дальние
объекты, и передаёт exact target в `_resetZoom()` и camera transition #82.
Текущий double-tap не является общей командой View:
- `_lastTap` обновляется только внутри kiosk-ветки `_stagePointerUp()`;
- kiosk считает clean tap по сохранённому `_swipeStart`, использует окно 350 мс
и вызывает `_resetZoom('double-tap')`;
- stage не имеет общего `dblclick`-обработчика;
- #152 немедленно принимает clean room click/tap на `pointerup`; room-owned tap
намеренно не обновляет kiosk `_lastTap`;
- pan, pinch, kiosk swipe/long press, устройства, проёмы, room link и редакторы
уже имеют собственных владельцев указателя.
#183 ранее зафиксировала это расхождение как уточнение к #152, но не добавляла
жест в обычный View. Поэтому #449 — новая пользовательская возможность, а не
исправление документации и не дубликат room-fit.
## Скоуп
1. Двойной primary click мышью по свободной неинтерактивной области сцены в
обычном View вызывает fit-all.
2. Двойной primary tap touch/pen по той же области в обычном View вызывает
fit-all.
3. Действующий kiosk double-tap переводится на тот же общий gesture contract и
тот же fit-all command.
4. Оба способа используют существующие fit bounds, zoom limits, transition,
reduced-motion и camera persistence rules.
5. Вводится единый проверяемый арбитраж free-background tap для View и kiosk.
6. Добавляются unit, mutation и production-bundle browser smoke доказательства
положительных и отрицательных путей.
7. Обновляются канонические документы View/touch и оба changelog.
## Не-скоуп
- двойной жест по комнате, room label или HA Area link;
- задержка одиночного room-fit для распознавания второй половины double-tap;
- двойной жест по device marker/capsule, vacuum, opening или его controls;
- работа жеста в редакторах План, Устройства и Подложка;
- новый button, tooltip, настройка, key binding или пользовательский параметр;
- изменение геометрии content frame, outlier policy, zoom limits, длительности
или easing camera transition;
- изменение kiosk floor swipe, long press, pan/pinch и cycling;
- изменение click/double-click действий объектов редактора;
- миграция конфигурации, localStorage или backend API.
## Контракт поведения
### 1. Каноническое действие fit-all
Успешный двойной жест вызывает ту же семантическую команду, что кнопка
**«Вписать всё»**, а не отдельный расчёт камеры и не прямое присваивание zoom:
1. снимается session-only room-focus;
2. `_showFar` становится `true`, `_frame` инвалидируется;
3. берётся актуальный `_baseVb()` после восстановления полного content frame;
4. `fitView()` строит exact target для текущего aspect ratio;
5. target проходит существующий `CameraTransitionController` с reason
`double-tap` и действующим `CAMERA_FIT_MS`;
6. reduced motion немедленно применяет тот же exact target;
7. повторный вызов при уже совпадающем target остаётся no-op по действующему
контракту controller.
`_fitAll()` принимает reason `fit | home | double-tap` либо эквивалентным
способом передаёт существующий reason дальше. Второй controller, CSS transform и
альтернативная математика fit запрещены.
### 2. Что считается свободным фоном
Кандидат создаётся только для primary pointer с основной кнопкой в `view`, если
его исходный composed path принадлежит scene/stage и не содержит владельца
самостоятельного действия.
Не являются свободным фоном:
- `[data-hp="room"]`, `.roomlabel` и `.rlgo`;
- `.dev`, device capsule/badge/action, `.vacpuck`;
- `.opening`, `.op-hit`, `.oplock` и opening actions;
- `a`, `button`, `input`, `select`, `textarea`, editable content;
- элементы с `role=link`, `role=button` или `data-room-fit-block`;
- editor secondary/chrome, dialog/popover и любые действующие explicit action
targets.
Декоративные слои View, которые уже имеют `pointer-events: none`, не создают
нового owner и не мешают нажать на лежащий под ними свободный фон. Проверка
работает по browser `composedPath()`, а не по повторному геометрическому hit-test.
Список ownership должен иметь один источник для room-fit и нового background
gesture либо отдельный helper с явно доказанной совместимостью; две расходящиеся
копии selector list недопустимы.
### 3. Clean tap/click
Одно нажатие может войти в последовательность только если:
1. pointerdown и pointerup принадлежат одному pointer id и тому же space;
2. режим на всём жесте — `view`;
3. down и up разрешаются как свободный фон;
4. жест не стал pan, pinch, kiosk swipe, object drag или long press;
5. не было second-pointer/multitouch sequence, `pointercancel` или
`lostpointercapture`;
6. `_suppressClick` и существующие mode-transition/continuity guards не
запрещают действие.
Порог движения не дублируется новой константой: решение использует тот же
источник истины, по которому текущий stage отличает clean click от pan. Жест,
который хотя бы однажды был классифицирован как pan/swipe/pinch, не может снова
стать tap на release.
### 4. Последовательность двух нажатий
Первый принятый free-background tap только записывает transient candidate и не
двигает камеру. Второй последовательный принятый tap в пределах действующего
kiosk-окна 350 мс один раз вызывает fit-all и очищает sequence до вызова
команды.
Sequence хранит только transient данные, необходимые для арбитража: timestamp,
space и pointer modality. Pointer id между двумя touch taps совпадать не обязан.
Mouse, touch и pen не склеиваются друг с другом в одну пару.
Просроченный второй tap становится новым первым tap. Любой несовместимый жест,
смена space/mode/projection, начало editor transition, structural adoption,
`pointercancel`, hidden/disconnect или multitouch очищают sequence. Timer,
который сам вызывает действие по истечении окна, не нужен.
Для сохранения kiosk compatibility эта задача не вводит новый межтаповый
spatial threshold: два последовательных clean free-background tap следуют
действующему временному контракту. Это техническое допущение явно покрывается
тестами и может быть пересмотрено отдельной UX-задачей.
### 5. Арбитраж с комнатой и объектами
Решение владельца Q1 — free background only:
- одиночный room click/tap по #152 выполняется немедленно;
- второй room click остаётся вторым room-fit и не вызывает fit-all;
- room-owned жест не записывает первый tap и очищает незавершённую
free-background sequence, если достигает общего stage arbitration;
- интерактивный ребёнок выполняет только собственное действие и никогда не
становится первой или второй половиной fit-all;
- новый gesture handler не вызывает `preventDefault()`/`stopPropagation()` на
чужой поверхности ради распознавания.
Первая половина double gesture не задерживает ни room-fit, ни object action.
### 6. Режимы
| Режим | Двойной жест по свободному фону |
|---|---|
| View | Fit all |
| Kiosk | Fit all; прежние swipe/long-press/cycle правила сохранены |
| План | Нет нового действия |
| Устройства | Нет нового действия |
| Подложка | Нет нового действия |
Переход между режимами или пространствами всегда разрывает незавершённую пару.
Редакторские double-click properties и drawing click chain не меняются.
## UX и доступность
- Новый жест не имеет отдельной визуальной подсказки или состояния hover.
- Анимация визуально совпадает с кнопкой «Вписать всё» и kiosk reset.
- При `prefers-reduced-motion: reduce` применяется тот же конечный кадр без
промежуточной анимации.
- Кнопка «Вписать всё» остаётся видимым и keyboard-accessible способом выполнить
действие; новый скрытый shortcut не заменяет её.
- Клавиатурные room-label actions, focus и screen-reader semantics не меняются.
- На touch один tap по свободному фону остаётся no-op; никаких отложенных
эффектов после 350 мс нет.
## Модель данных, миграция, compatibility и i18n
- Новых полей config, localStorage, backend model и service calls нет.
- Gesture candidate живёт только в экземпляре карточки и не переживает
disconnect/remount.
- Формат конфигурации и downgrade не меняются.
- Новых строк интерфейса и ключей i18n нет.
- Несколько экземпляров карточки не разделяют sequence; каждый реагирует только
на жесты внутри собственной stage.
## Производительность и безопасность
- На pointerdown/up допускается только O(length of composed path) ownership
check и O(1) обновление transient state.
- Нового interval, глобального listener, дополнительного RAF-loop и render на
первом tap нет.
- Второй tap использует существующий camera transition; budget #82 не меняется.
- Событие не пишет config и не вызывает Home Assistant service/navigation.
- Exception в path resolution или отсутствующая stage/невалидный frame даёт
fail-safe no-op через действующие guards, без частичного camera state.
## Критерии приёмки и доказательства
### AC1. Mouse double-click в обычном View выполняет fit-all
После zoom/pan два clean primary mouse click по свободному фону за ≤350 мс дают
тот же exact конечный viewBox/zoom, `_showFar` и room-focus state, что кнопка
«Вписать всё».
**Доказательство:** unit recognizer + production-bundle browser smoke со
сравнением обоих путей.
### AC2. Touch/pen double-tap в обычном View выполняет fit-all
Два clean tap одной modality по свободному фону дают один fit-all; первый tap не
меняет камеру. Mouse/touch/pen не образуют смешанную пару.
**Доказательство:** unit matrix + browser smoke реальными PointerEvent.
### AC3. Kiosk использует общий contract без регрессии навигации
Kiosk double-tap по свободному фону по-прежнему вписывает план. Straight/bent
floor swipe, pan at any zoom, long press, cycling pause и motionless first tap
сохраняют текущий результат.
**Доказательство:** расширенные `smoke_kiosk.mjs` и
`smoke_kiosk_pan_lock.mjs`, mutation общего recognizer.
### AC4. Комната не участвует в fit-all sequence
Один и два room click/tap немедленно выполняют только room-fit #152. Ни первый,
ни второй room-owned release не вызывают fit-all и не оставляют скрытую половину
последовательности.
**Доказательство:** unit ownership + расширенный `smoke_room_fit.mjs` с spy на
fit-all и camera result.
### AC5. Интерактивные owners полностью подавляют новый жест
Double click/tap по device/capsule/action, vacuum, opening/lock, HA Area link,
button/form control выполняет только прежнее действие либо no-op этого owner.
Fit-all не вызывается и sequence не остаётся вооружённой.
**Доказательство:** unit composed-path table + targeted production-bundle smoke
для device, opening и room link.
### AC6. Навигационный жест нельзя завершить как double-tap
Pan, pinch, kiosk swipe, long press, pointercancel/lost capture и любое движение
за существующим click threshold дают zero fit-all. Следующий одиночный free tap
после них не считается второй половиной старой пары.
**Доказательство:** unit state-machine table + smoke pan/pinch/cancel/kiosk.
### AC7. Редакторы не меняются
В Plan, Devices и Background два нажатия по свободному фону не вызывают
fit-all. Double-click properties, drawing placement, select/move и decor
handlers сохраняют существующие результаты.
**Доказательство:** mode unit matrix + `smoke_editor_gestures.mjs` и целевой
decor smoke.
### AC8. Sequence изолирована и корректно инвалидируется
Смена space/mode/projection, structural adoption, hidden/disconnect и истечение
350 мс не позволяют второму tap завершить прежнюю пару. Два экземпляра карточки
не разделяют состояние.
**Доказательство:** unit с управляемым clock + browser smoke для mode/space и
двух instances.
### AC9. Camera transition остаётся единым
Double-fit retarget-ит активную camera animation из фактически показанного
кадра, reduced motion применяет exact target, а повторный fit к совпадающему
target не создаёт лишний RAF/render/storage write.
**Доказательство:** unit `viewport-transition` + расширенный smooth-zoom smoke и
write/render spies.
### AC10. Данные и внешние действия не меняются
Успешный и отклонённый жест не меняют server config/history и не вызывают HA
service/navigation. Используется только session camera state по действующим
правилам fit-all.
**Доказательство:** browser smoke со spies на save/history/hass.callService и
navigation.
### AC11. Производительность и bundle budget не регрессируют
Первый tap не запускает render/timer, ownership остаётся линейным по короткому
composed path, initial bundle проходит действующий потолок.
**Доказательство:** unit/render spy, `bundle:budget`, performance-smoke перед
бетой.
## План автотестов
1. Добавить pure unit-тесты для:
- free/room/interactive composed paths;
- clean/stale/cancelled/moved/multitouch candidates;
- mouse/touch/pen и запрета mixed modality;
- same/different space, mode, projection и instance;
- first/second/expired sequence, ровно одного trigger и очистки до callback.
2. Расширить `test/room-fit.test.mjs` отрицательными room-owned сценариями.
3. Добавить mutation witnesses как минимум для:
- удаления free-background owner guard;
- принятия room tap в sequence;
- пропуска pan/pinch cancellation;
- вызова `_resetZoom` вместо канонического `_fitAll`;
- включения жеста в editor mode.
4. Добавить/расширить production-bundle smoke:
- mouse double-click и touch double-tap в обычном View;
- kiosk parity;
- room/device/opening/link suppression;
- pan, pinch, cancel и editor non-regression;
- exact parity с кнопкой «Вписать всё» после zoom/pan и при far object.
5. Локальный цикл реализации: `npx tsc --noEmit`, `npm test`, `npm run build`,
`npm run bundle:sync`, targeted smoke и `no-new-any`.
6. Перед бетой: полный smoke, golden verify без ожидаемого baseline diff и
performance-smoke; канонический полный HA-harness — Linux CI.
## Карта реализации
- `src/houseplan-card.ts`: transient candidate/sequence lifecycle, общий вызов
fit-all, очистка на structural/mode lifecycle и удаление kiosk-only развилки.
- Новый либо существующий pure gesture helper: path ownership и state-machine,
без DOM state и side effects.
- `src/room-fit.ts` или общий ownership module: единый источник selector/owner
classification без расхождения с #152.
- `src/viewport-transition.ts`: только расширение типа reason при необходимости;
математика/controller не меняются.
- `test/**`, `demo/smoke_*.mjs`, `scripts/mutation-gate.mjs`: доказательства AC.
- `docs/CANVAS.md`, `docs/TOUCH-SUPPORT.md`, `docs/UX-MODES.md`,
`docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`: новый shortcut и его границы.
## Риски
1. **Room tap станет первой половиной fit-all.** Защита: единый path owner и
отрицательный smoke с двумя room taps.
2. **Pan/swipe завершится fit-all на release.** Защита: финальная gesture
classification и очистка sequence при первом переходе в navigation owner.
3. **Touch породит synthetic mouse double-click и два вызова.** Защита: один
pointer-based recognizer либо явное подавление compatibility event с тестом
«одна пара — один command».
4. **Редактор потеряет double-click properties.** Защита: mode guard до записи
candidate и smoke существующих editor handlers.
5. **Kiosk изменит навигацию.** Защита: сохранение окна 350 мс и полный набор
kiosk pan/swipe/long-press witnesses.
6. **Fit вызовет другой кадр, чем кнопка.** Защита: один command и численное
сравнение exact camera target, включая far-object frame.
## Откат
Откат удаляет общий free-background recognizer и его transient state, возвращает
прежнюю kiosk-only ветку `_lastTap`, убирает тесты/документацию #449. Форматы
данных не меняются, поэтому миграция назад не нужна. Кнопка «Вписать всё»,
room-fit #152 и camera transition #82 остаются работоспособны независимо от
отката.
## Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: одна пользовательская запись со
ссылкой на #449 без раскрытия внутренних имён state/handler.
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`: shortcut только для свободного
фона в View/kiosk и явное отличие от room-fit.
- `docs/CANVAS.md`, `docs/TOUCH-SUPPORT.md`, `docs/UX-MODES.md`: канонический
gesture/ownership contract.
- Новых UI screenshots не требуется: статический вид не меняется.
- Golden baseline не должен меняться; `golden:verify` остаётся предрелизным
доказательством отсутствия визуальной регрессии.
- Release notes беты получают краткое пользовательское описание без технических
деталей.
## Принятые предположения — можно менять на ревью без решения владельца
1. Общий recognizer реализуется на Pointer Events, а не параллельными
`dblclick` и touch-кодами, чтобы synthetic compatibility event не дублировал
команду.
2. Окно 350 мс сохраняется из kiosk; отдельный межтаповый spatial threshold в
#449 не вводится.
3. Sequence различает mouse/touch/pen и не требует одинакового pointer id между
двумя taps.
4. Первый tap не создаёт timer и render; просрочка определяется при следующем
входном событии.
5. Ownership selectors выносятся в общий pure helper, если это уменьшает
расхождение с #152; точное имя файла и типов свободно.
6. Double-fit передаёт reason `double-tap` для всех pointer modalities, включая
mouse, чтобы telemetry/tests имели один semantic reason.