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

17 KiB
Raw Permalink Blame History

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 одновременно нужны:

  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.