Files
houseplan-card/docs/specs/154-touch-hover-reset.md
2026-08-15 03:55:30 +03:00

300 lines
17 KiB
Markdown
Raw Permalink 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.
# 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.