17 KiB
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:
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 одновременно нужны:
- последняя фактическая modality
mouse; - 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
- После touch tap на каждом обязательном View target transient hover исчезает не позднее следующего frame после завершения pressed feedback.
_hoverRoom/hover-owned_tipравны null после touch terminal event.- Device lift/shadow и CSS hover controls отсутствуют после tap/dialog close.
- Working/alarm/unavailable/Glow/pulse/selected state не изменяется.
- Pinch, long press, multi-touch, cancel и lost capture не оставляют hover.
- Браузер с touch и ложным
(hover: hover)не показывает sticky hover. - Настоящий desktop mouse hover визуально и функционально не изменён.
- На hybrid device touch → mouse восстанавливает hover первым настоящим mouse pointer event без reload и без произвольной задержки.
- Keyboard focus и
:focus-visibleсохраняются. - Cleanup не отменяет click action, dialog, #152 room fit или help popover.
- Mode/space/visibility/disconnect очищают transient hover без listener leak.
- В 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. План реализации
- Провести inventory JS state и View/shared CSS hover selectors.
- Ввести component-local pointer modality authority и lifecycle cleanup.
- Перевести room hover на pointer events с mouse gate.
- Ввести общий CSS modality gate, разделив hover/focus/semantic rules.
- Передать modality в обязательные shadow child components.
- Добавить unit/source-contract и real-touch/hybrid browser tests.
- Прогнать 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.