diff --git a/docs/specs/154-touch-hover-reset.md b/docs/specs/154-touch-hover-reset.md new file mode 100644 index 00000000..0cce97d2 --- /dev/null +++ b/docs/specs/154-touch-hover-reset.md @@ -0,0 +1,299 @@ +# Issue #154 — transient hover не залипает после touch + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/154 +- **Статус документа:** готово к будущей реализации; issue остаётся на `S3-spec` +- **Приоритет:** P1 +- **Тип:** bug/polish, обычный трек +- **Пользовательское изменение:** да + +## 1. Проблема и требуемый результат + +Мобильный браузер после tap может синтезировать mouse-события и сохранять CSS +`:hover`. Одновременно room hover в View управляется `mouseenter`/`mouseleave`, +а touch sequence не гарантирует `mouseleave`. Текущий `_notePointer()` закрывает +`_tip`, но не очищает `_hoverRoom` и не нейтрализует CSS hover. + +После исправления touch/pen показывает только краткий pressed feedback на время +жеста. После его завершения transient hover отсутствует. Настоящие mouse и +trackpad сохраняют обычный hover, а на гибридном устройстве последующий mouse +input восстанавливает его после touch без reload. + +## 2. Термины и границы состояния + +**Transient hover** существует только из-за текущего hover-capable pointer: + +- `_hoverRoom`, room fill/outline и room tooltip; +- device marker lift/shadow/brightness; +- opening, room label и control hover styles; +- dialog close/control hover; +- аналогичные чисто визуальные CSS `:hover` состояния общих View-компонентов. + +Не являются transient hover и не очищаются этой задачей: + +- `active`, `pressed` во время незавершённого pointer sequence; +- selected/checked/current состояния редакторов; +- working, unavailable, alarm, warning, Glow/pulse и иное HA-derived состояние; +- keyboard focus и `:focus-visible`; +- открытый по click/tap toggle-popover `hp-help`; +- открытый dialog либо выполненное action. + +## 3. Scope + +Обязательный охват основного View: + +- room floor/fill/outline/label/tooltip; +- device marker, opening и vacuum marker; +- controls поверх плана и close/action controls диалога; +- tap комнаты из будущей #152; +- long press, pinch, multi-touch и pointer capture cancellation; +- touch → mouse/trackpad на гибридном устройстве; +- смена режима, visibility и lifecycle компонента. + +Общие компоненты редакторов получают исправление, если используют тот же +pointer-modality gate без специальной адаптации. Полная поддержка touch-editing +остаётся вне scope согласно `docs/TOUCH-SUPPORT.md`. + +## 4. Не входит в задачу + +- изменение semantic device/room state; +- новый visual design hover/pressed/focus; +- изменение действий tap, long press или click; +- превращение `hp-help` в transient tooltip; +- исправление touch settings из #149; +- полная переработка gesture arbiter #152; +- глобальный `blur()`, снятие DOM focus либо synthetic click suppression, + способный отменить пользовательское action. + +## 5. Единый pointer-modality authority + +Houseplan вводит один session-local источник фактической modality: + +```text +unknown | mouse | touch | pen +``` + +Начальное значение — `unknown`; pointer-only hover при нём выключен. Переходы: + +- trusted `PointerEvent` с `pointerType === 'mouse'` включает `mouse`; +- trusted `touch` включает `touch` и немедленно очищает transient hover; +- trusted `pen` включает `pen` и следует touch policy; +- следующий настоящий mouse/trackpad pointer event переводит touch/pen → mouse; +- смена mode/space, `visibilitychange` в hidden и disconnect очищают transient + hover, не подменяя semantic state. + +Media queries не являются authority: браузер может ошибочно сообщать +`(hover: hover)` после touch. Для включения CSS hover одновременно нужны: + +1. последняя фактическая modality `mouse`; +2. hover/fine-pointer capability среды. + +Компонент применяет единый class/data-attribute gate к View tree; дочерние +shadow components получают ту же modality явным property/attribute contract. +Независимые локальные детекторы и глобальный mutable singleton запрещены. + +Modality не сохраняется в localStorage/config и не синхронизируется между +карточками. Она не обязана быть reactive product state, если один безопасный +DOM gate и явная очистка обновляются без лишнего full render. + +## 6. Synthetic mouse policy + +Touch-generated compatibility `MouseEvent` не может включить mouse modality. +Authority дают только Pointer Events с фактическим `pointerType`; существующие +`mouseenter`/`mouseleave` не меняют modality. + +JS room hover переводится на `pointerenter`/`pointerleave` либо общий delegated +pointer path и устанавливается только для разрешённого mouse modality. Touch и +pen никогда не записывают `_hoverRoom`/`_tip` через hover path. + +Не использовать произвольный таймаут «после touch игнорировать mouse N ms» как +основной механизм: он ломает быстрый touch → mouse сценарий и зависит от +браузера. Если конкретный браузер отправляет compatibility event как +`PointerEvent(pointerType='mouse')`, допускается только детерминированная +проверка provenance/capabilities этого же input sequence с unit/browser +доказательством; wall-clock suppression без идентичности sequence запрещён. + +## 7. Очистка transient hover + +Единый idempotent helper очищает только transient hover state. Он вызывается: + +- в начале каждого touch/pen `pointerdown`; +- на соответствующих `pointerup` и `pointercancel`; +- при `lostpointercapture`; +- после terminal click/action path до следующего painted frame; +- при начале multi-touch/pinch и после его завершения/cancel; +- при mode/space change; +- при `document.visibilityState === 'hidden'`; +- при disconnect/remount boundary. + +Повторный вызов — no-op и не должен создавать render loop. Pointer capture +снимается только владельцем gesture по текущему contract; hover cleanup не +отменяет service call, dialog open или room fit. + +После touch terminal event pressed feedback очищается обычным gesture owner. +Transient hover не остаётся дольше одного animation frame и не используется для +имитации pressed. + +## 8. CSS contract + +Все пользовательски заметные View `:hover` selectors получают общий modality +gate. Как минимум это: + +- room overlay/yard/styled fill; +- device normal/alarm lift, shadow и brightness; +- opening outline; +- room-label controls; +- stage controls/options; +- dialog close/action controls и общие card controls, видимые в View. + +Один selector не должен случайно смешивать hover с semantic state. Если текущая +rule объединяет `:hover` и `:focus-visible`, её разделяют: mouse hover получает +modality gate, focus-visible остаётся без него. `:active`/explicit pressed styles +также остаются независимы. + +Selectors редакторских handles можно оставить вне обязательного охвата, если +они не используются в View/shared component; решение фиксируется inventory в +review. Naked View `:hover` после изменения считается source-contract ошибкой. + +## 9. JS hover и tooltip contract + +- `_hoverRoom` и `_tip` не устанавливаются touch/pen событиями. +- `pointerleave` реальной мыши очищает принадлежащее target состояние. +- touch `pointerdown` очищает старый mouse hover до выполнения tap action. +- открытие/закрытие dialog не восстанавливает старый hover snapshot. +- tooltip, открытый keyboard focus либо explicit click contract, не должен + ошибочно классифицироваться как hover; owner хранится явно. +- следующий mouse enter/move заново вычисляет current hit и показывает hover, + а не восстанавливает устаревший room/device id. + +Room fit #152 использует canonical hit resolver непосредственно из gesture +sequence и не зависит от `_hoverRoom`; очистка hover не должна терять room tap. + +## 10. Touch, gestures и lifecycle + +- Второй pointer немедленно исключает single-tap activation по действующему + gesture contract и очищает hover. +- Pinch/long press могут выполнить своё существующее действие, но после terminal + event не оставляют hover. +- `pointercancel`/lost capture всегда безопасны, даже если target удалён renderом. +- Tap → dialog → close не возвращает marker lift/shadow из пред-dialog frame. +- Pen tap следует touch policy; hover stylus не входит в обязательный контракт. +- Kiosk, light/dark theme, Flat/Isometric используют одинаковую modality policy. + +Listener `visibilitychange` регистрируется/удаляется симметрично lifecycle и не +создаёт утечку при повторных mount/unmount. + +## 11. Accessibility + +- DOM focus не снимается touch cleanup helper. +- `:focus-visible` и keyboard tooltip/action продолжают работать независимо от + последней pointer modality. +- Touch cleanup не меняет `aria-expanded`, `aria-pressed`, selection либо dialog + focus trap. +- Mouse hover не является единственным способом получить обязательную + информацию или действие. +- Reduced motion не влияет на state machine; pressed feedback #22 остаётся + кратким и не превращается в hover. + +## 12. Acceptance criteria + +1. После touch tap на каждом обязательном View target transient hover исчезает + не позднее следующего frame после завершения pressed feedback. +2. `_hoverRoom`/hover-owned `_tip` равны null после touch terminal event. +3. Device lift/shadow и CSS hover controls отсутствуют после tap/dialog close. +4. Working/alarm/unavailable/Glow/pulse/selected state не изменяется. +5. Pinch, long press, multi-touch, cancel и lost capture не оставляют hover. +6. Браузер с touch и ложным `(hover: hover)` не показывает sticky hover. +7. Настоящий desktop mouse hover визуально и функционально не изменён. +8. На hybrid device touch → mouse восстанавливает hover первым настоящим mouse + pointer event без reload и без произвольной задержки. +9. Keyboard focus и `:focus-visible` сохраняются. +10. Cleanup не отменяет click action, dialog, #152 room fit или help popover. +11. Mode/space/visibility/disconnect очищают transient hover без listener leak. +12. В View/shared CSS не остаётся naked hover selector из inventory. + +## 13. План тестирования + +### Unit/source contract + +- transitions `unknown → mouse/touch/pen → mouse`; +- compatibility MouseEvent не включает mouse modality; +- touch/pen down/up/cancel и lost capture вызывают idempotent cleanup; +- mode/space/visibility/disconnect lifecycle; +- cleanup очищает только hover-owned state; +- room hover gate и #152 hit независимы; +- CSS inventory: View hover selectors требуют modality gate, focus-visible — нет. + +### Browser smoke + +- реальный touch context/CDP touch input: room, device, opening, label, control; +- tap → dialog → close и tap при активном semantic device state; +- browser context с touch и `(any-hover: hover)`; +- two-finger pinch, long press, pointercancel и lost capture; +- touch room click-to-fit #152 без sticky room highlight; +- touch → hardware/simulated mouse pointer: hover снова появляется; +- desktop mouse enter/leave и keyboard Tab/Enter regression; +- Flat/Isometric, kiosk, light/dark и visibility round-trip. + +Тест проверяет computed styles и JS state после painted frame, а не только +отсутствие class. `dispatchEvent(new MouseEvent(...))` не заменяет настоящий +touch browser scenario. + +### Golden + +- touch post-tap screenshot без hover, но с прежним semantic state; +- desktop mouse-hover и keyboard-focus screenshots; +- visual diff не переакцептует unrelated colors/shadows. + +### Performance + +- pointermove не вызывает full Lit update на каждый пиксель; +- modality gate не добавляет unbounded listeners/observers; +- canonical pan/pinch smoke сохраняет frame responsiveness; +- performance profile перед beta подтверждает отсутствие новых long tasks. + +## 14. План реализации + +1. Провести inventory JS state и View/shared CSS hover selectors. +2. Ввести component-local pointer modality authority и lifecycle cleanup. +3. Перевести room hover на pointer events с mouse gate. +4. Ввести общий CSS modality gate, разделив hover/focus/semantic rules. +5. Передать modality в обязательные shadow child components. +6. Добавить unit/source-contract и real-touch/hybrid browser tests. +7. Прогнать typecheck, unit и build; перед beta — smoke, golden, performance. + +## 15. Документация и release-артефакты + +Поскольку bug виден пользователю, implementation commit обязан иметь +`User-Visible: yes` и в том же коммите обновить: + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`; +- `docs/TOUCH-SUPPORT.md` — pointer modality и отсутствие sticky hover; +- `docs/CANVAS.md` — hover/pressed/semantic ownership; +- `docs/TESTING.md` — real-touch и hybrid smoke contract. + +Нужны reviewed touch post-tap, desktop-hover и keyboard-focus golden artifacts, +а также browser smoke report. Новых пользовательских строк не ожидается; если +появятся, обе локали и parity test обязательны. + +## 16. Риски и откат + +| Риск | Мера | +| --- | --- | +| Touch cleanup отменяет action | state-only helper, gesture smoke | +| Mouse hover пропадает на hybrid | event-derived transition back to mouse | +| Focus styling попадает под gate | отдельные selectors/source contract | +| Semantic state очищается как hover | явный inventory и state ownership tests | +| Pointermove вызывает rerender storm | DOM gate без full render per move | +| Lifecycle listener течёт | symmetric connect/disconnect test | + +Откат возвращает прежние JS/CSS hover paths. Config, storage и backend data не +меняются; миграция и data rollback не нужны. + +## 17. Принятые предположения + +- `touch` и `pen` следуют одинаковой no-hover policy; +- initial `unknown` не включает pointer-only hover до фактической мыши; +- mouse modality определяется trusted PointerEvent, не compatibility MouseEvent; +- media query используется только вторым gate, а не источником modality; +- #152 room activation не должна читать `_hoverRoom` как selected hit; +- `hp-help`, focus-visible и semantic states не относятся к transient hover. diff --git a/docs/specs/README.md b/docs/specs/README.md index 14e8aaaa..84fbf0a4 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -48,6 +48,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) | | [#138](https://github.com/Matysh/houseplan-card/issues/138) Автозамыкание комнаты по существующей стене | [138-adjacent-room-autoclose.md](138-adjacent-room-autoclose.md) | | [#146](https://github.com/Matysh/houseplan-card/issues/146) Четырёхфазный фон «Следует за Солнцем» | [146-four-phase-sun-background.md](146-four-phase-sun-background.md) | +| [#154](https://github.com/Matysh/houseplan-card/issues/154) Touch: hover-состояние залипает после tap | [154-touch-hover-reset.md](154-touch-hover-reset.md) | | [#156](https://github.com/Matysh/houseplan-card/issues/156) Регрессии Full Performance перед v1.64.0 stable | [156-full-performance-regressions.md](156-full-performance-regressions.md) | ## P2