From 0f404e5e5331c1a16e1a2dd7645be9b8d1cd7dc1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 23 May 2026 18:58:53 +0000 Subject: [PATCH] Refine input modality to prevent hover/focus highlight conflicts --- frontend/src/components/CollageHero.vue | 18 +-- frontend/src/components/MediaCard.vue | 6 +- frontend/src/components/MediaDetail.vue | 12 +- .../src/components/ReleaseVersionCard.vue | 8 +- frontend/src/components/SeriesFullView.vue | 16 +-- frontend/src/composables/useInputModality.ts | 136 ++++++++++++++++++ frontend/src/main.ts | 2 + frontend/src/styles/main.css | 33 +++-- 8 files changed, 187 insertions(+), 44 deletions(-) create mode 100644 frontend/src/composables/useInputModality.ts diff --git a/frontend/src/components/CollageHero.vue b/frontend/src/components/CollageHero.vue index d2a79f1..538aeba 100644 --- a/frontend/src/components/CollageHero.vue +++ b/frontend/src/components/CollageHero.vue @@ -605,8 +605,8 @@ function handleItemClick(item: MediaItem, index: number) { pointer-events: none; } -.collage-item:hover, -.collage-item.nav-focused { +:global(html[data-mouse-active="true"]) .collage-item:hover, +:global(html[data-mouse-active="false"]) .collage-item.nav-focused { z-index: 10; } @@ -630,8 +630,8 @@ function handleItemClick(item: MediaItem, index: number) { } /* Show outline on hover and focus */ -.collage-item:hover .hex-focus-outline, -.collage-item.nav-focused .hex-focus-outline { +:global(html[data-mouse-active="true"]) .collage-item:hover .hex-focus-outline, +:global(html[data-mouse-active="false"]) .collage-item.nav-focused .hex-focus-outline { opacity: 1; } @@ -646,15 +646,15 @@ function handleItemClick(item: MediaItem, index: number) { } /* Brighter outline for keyboard focus */ -.collage-item.nav-focused .hex-focus-outline polygon { +:global(html[data-mouse-active="false"]) .collage-item.nav-focused .hex-focus-outline polygon { stroke: #ffffff; stroke-width: 5; filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); } /* Blinking effect on hover/focus */ -.collage-item:hover .hex-focus-outline, -.collage-item.nav-focused .hex-focus-outline { +:global(html[data-mouse-active="true"]) .collage-item:hover .hex-focus-outline, +:global(html[data-mouse-active="false"]) .collage-item.nav-focused .hex-focus-outline { animation: hex-outline-blink 1s ease-in-out infinite; } @@ -1035,8 +1035,8 @@ function handleItemClick(item: MediaItem, index: number) { white-space: nowrap; } -.collage-item:not(.collage-featured):hover .collage-item-hover, -.collage-item:not(.collage-featured).nav-focused .collage-item-hover { +:global(html[data-mouse-active="true"]) .collage-item:not(.collage-featured):hover .collage-item-hover, +:global(html[data-mouse-active="false"]) .collage-item:not(.collage-featured).nav-focused .collage-item-hover { opacity: 1; transform: translateX(-50%) translateY(0); } diff --git a/frontend/src/components/MediaCard.vue b/frontend/src/components/MediaCard.vue index 4a1135b..597a843 100644 --- a/frontend/src/components/MediaCard.vue +++ b/frontend/src/components/MediaCard.vue @@ -211,13 +211,13 @@ const matchedPeople = computed(() => { object-fit: cover; } -.media-card:hover .card-focus-outline, -.media-card.nav-focused .card-focus-outline { +:global(html[data-mouse-active="true"]) .media-card:hover .card-focus-outline, +:global(html[data-mouse-active="false"]) .media-card.nav-focused .card-focus-outline { opacity: 1; animation: card-outline-blink 1s ease-in-out infinite; } -.media-card.nav-focused .card-focus-outline rect { +:global(html[data-mouse-active="false"]) .media-card.nav-focused .card-focus-outline rect { stroke: #ffffff; stroke-width: 5; filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index c643abc..0ca00af 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -813,12 +813,12 @@ function handleCastSelect(castName: string) { } .cast-card:focus-visible, -.cast-card.nav-focused { +:global(html[data-mouse-active="false"]) .cast-card.nav-focused { outline: none; } .cast-card:focus-visible::after, -.cast-card.nav-focused::after { +:global(html[data-mouse-active="false"]) .cast-card.nav-focused::after { border-width: 2px; } @@ -943,7 +943,7 @@ function handleCastSelect(castName: string) { cursor: pointer; } -.showreel-image:hover { +:global(html[data-mouse-active="true"]) .showreel-image:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); } @@ -978,7 +978,7 @@ function handleCastSelect(castName: string) { transition: background 0.2s, border-color 0.2s; } -.version-item:hover { +:global(html[data-mouse-active="true"]) .version-item:hover { background: rgba(255, 255, 255, 0.08); } @@ -1176,7 +1176,7 @@ function handleCastSelect(castName: string) { overflow: hidden; } -.episode-item:hover { +:global(html[data-mouse-active="true"]) .episode-item:hover { background: rgba(255, 255, 255, 0.08); } @@ -1291,7 +1291,7 @@ function handleCastSelect(castName: string) { border: 1px solid transparent; } -.release-item:hover { +:global(html[data-mouse-active="true"]) .release-item:hover { background: rgba(255, 255, 255, 0.06); } diff --git a/frontend/src/components/ReleaseVersionCard.vue b/frontend/src/components/ReleaseVersionCard.vue index d61923b..45ec1cf 100644 --- a/frontend/src/components/ReleaseVersionCard.vue +++ b/frontend/src/components/ReleaseVersionCard.vue @@ -312,7 +312,7 @@ function handleActivate(event: MouseEvent | KeyboardEvent) { grid-template-columns: minmax(0, 1fr) max-content max-content; } -.version-row:hover { +:global(html[data-mouse-active="true"]) .version-row:hover { background: rgba(10, 14, 22, 0.28); border-color: rgba(255, 255, 255, 0.2); } @@ -322,7 +322,7 @@ function handleActivate(event: MouseEvent | KeyboardEvent) { background: rgba(10, 14, 22, 0.2); } -.version-row.version-best:hover { +:global(html[data-mouse-active="true"]) .version-row.version-best:hover { background: rgba(10, 14, 22, 0.28); border-color: rgba(255, 255, 255, 0.2); } @@ -533,8 +533,8 @@ function handleActivate(event: MouseEvent | KeyboardEvent) { cursor: pointer; } -.ctx-btn:hover:not(:disabled), -.ctx-btn.nav-focused:not(:disabled), +:global(html[data-mouse-active="true"]) .ctx-btn:hover:not(:disabled), +:global(html[data-mouse-active="false"]) .ctx-btn.nav-focused:not(:disabled), .ctx-btn:focus-visible:not(:disabled) { background: rgba(255, 255, 255, 0.16); border-color: rgba(255, 255, 255, 0.35); diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 44dc658..332589c 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -806,8 +806,8 @@ onUnmounted(() => { outline: none; } -.episode-tile:hover, -.episode-tile.nav-focused { +:global(html[data-mouse-active="true"]) .episode-tile:hover, +:global(html[data-mouse-active="false"]) .episode-tile.nav-focused { z-index: 20; transition: z-index 0s; } @@ -850,14 +850,14 @@ onUnmounted(() => { } /* Show outline on hover and focus */ -.episode-tile:hover .tile-focus-outline, -.episode-tile.nav-focused .tile-focus-outline { +:global(html[data-mouse-active="true"]) .episode-tile:hover .tile-focus-outline, +:global(html[data-mouse-active="false"]) .episode-tile.nav-focused .tile-focus-outline { opacity: 1; animation: tile-outline-blink 1s ease-in-out infinite; } /* Brighter outline for keyboard focus */ -.episode-tile.nav-focused .tile-focus-outline rect { +:global(html[data-mouse-active="false"]) .episode-tile.nav-focused .tile-focus-outline rect { stroke: #ffffff; stroke-width: 5; filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); @@ -884,7 +884,7 @@ onUnmounted(() => { display: none; } -.episode-tile:hover .tile-overlay { +:global(html[data-mouse-active="true"]) .episode-tile:hover .tile-overlay { opacity: 0.4; } @@ -945,7 +945,7 @@ onUnmounted(() => { text-shadow: 0 4px 20px rgba(0, 0, 0, 0.8); } -.episode-tile:hover .tile-play { +:global(html[data-mouse-active="true"]) .episode-tile:hover .tile-play { opacity: 1; transform: translate(-50%, -50%) scale(1); } @@ -1081,7 +1081,7 @@ onUnmounted(() => { cursor: pointer; } -.version-action-item:hover:not(:disabled), +:global(html[data-mouse-active="true"]) .version-action-item:hover:not(:disabled), .version-action-item:focus-visible:not(:disabled) { background: rgba(255, 255, 255, 0.12); outline: none; diff --git a/frontend/src/composables/useInputModality.ts b/frontend/src/composables/useInputModality.ts new file mode 100644 index 0000000..479cb83 --- /dev/null +++ b/frontend/src/composables/useInputModality.ts @@ -0,0 +1,136 @@ +type InputModality = 'mouse' | 'keyboard' | 'gamepad'; + +const MOUSE_IDLE_MS = 1400; +const MOUSE_INTENT_DISTANCE_PX = 28; +const MOUSE_INTENT_WINDOW_MS = 700; +const MOUSE_INTENT_SELECTOR = [ + '[data-nav-focusable]','button','a[href]','input','select','textarea','[role="button"]', + '.media-card','.collage-item','.episode-tile','.version-row','.ctx-btn','.header-nav-item', +].join(','); + +let installed = false; +let modality: InputModality = 'mouse'; +let mouseIdleTimer: number | null = null; +let pointerVisible = false; +let mouseTravelPx = 0; +let lastMouseMoveAt = 0; + +function clearMouseIdleTimer() { + if (mouseIdleTimer !== null) { + window.clearTimeout(mouseIdleTimer); + mouseIdleTimer = null; + } +} + +function applyInputState(mouseActive: boolean) { + const root = document.documentElement; + root.dataset.inputModality = modality; + root.dataset.mouseActive = mouseActive ? 'true' : 'false'; + root.dataset.pointerVisible = pointerVisible ? 'true' : 'false'; +} + +function scheduleMouseIdle() { + clearMouseIdleTimer(); + mouseIdleTimer = window.setTimeout(() => { + pointerVisible = false; + applyInputState(false); + }, MOUSE_IDLE_MS); +} + +function activateMouseInput() { + modality = 'mouse'; + pointerVisible = true; + applyInputState(true); + scheduleMouseIdle(); +} + +function activateNonMouseInput(next: InputModality) { + modality = next; + pointerVisible = false; + mouseTravelPx = 0; + clearMouseIdleTimer(); + applyInputState(false); +} + +function showPointerFromMotion() { + pointerVisible = true; + applyInputState(modality === 'mouse'); + scheduleMouseIdle(); +} + +function isMouseIntentTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return Boolean(target.closest(MOUSE_INTENT_SELECTOR)); +} + +function registerMouseIntentTravel(event: MouseEvent): boolean { + const now = performance.now(); + if (now - lastMouseMoveAt > MOUSE_INTENT_WINDOW_MS) { + mouseTravelPx = 0; + } + lastMouseMoveAt = now; + + const step = Math.hypot(event.movementX || 0, event.movementY || 0); + mouseTravelPx += step; + if (mouseTravelPx >= MOUSE_INTENT_DISTANCE_PX) { + mouseTravelPx = 0; + return true; + } + return false; +} + +function handleMouseMove(event: MouseEvent) { + showPointerFromMotion(); + + if (modality === 'mouse') { + applyInputState(true); + return; + } + + if (!isMouseIntentTarget(event.target)) return; + if (registerMouseIntentTravel(event)) { + activateMouseInput(); + } +} + +function handleMouseOver(event: MouseEvent) { + if (modality === 'mouse') return; + if (!isMouseIntentTarget(event.target)) return; + + // Entering an interactive target indicates likely mouse intent. + activateMouseInput(); +} + +function handleMouseIntentAction(event: MouseEvent | WheelEvent) { + pointerVisible = true; + if (isMouseIntentTarget(event.target)) { + activateMouseInput(); + return; + } + + applyInputState(modality === 'mouse'); + scheduleMouseIdle(); +} + +function handleKeyboardActivity(event: KeyboardEvent) { + if (event.metaKey || event.ctrlKey || event.altKey) return; + activateNonMouseInput('keyboard'); +} + +function handleGamepadActivity() { + activateNonMouseInput('gamepad'); +} + +export function installInputModalityTracking() { + if (installed) return; + installed = true; + + applyInputState(false); + + window.addEventListener('mousemove', handleMouseMove, { passive: true }); + window.addEventListener('mouseover', handleMouseOver, { passive: true }); + window.addEventListener('mousedown', handleMouseIntentAction, { passive: true }); + window.addEventListener('wheel', handleMouseIntentAction, { passive: true }); + window.addEventListener('keydown', handleKeyboardActivity, { passive: true }); + window.addEventListener('mediahive:gamepad-action', handleGamepadActivity as EventListener); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e5e0140..bc98070 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,8 +4,10 @@ import router from './router' import './styles/main.css' import { installKeyboardNavigation } from './composables/useKeyboardNavigation' import { installGamepadNavigation } from './composables/useGamepadNavigation' +import { installInputModalityTracking } from './composables/useInputModality' // Install global keyboard navigation handlers immediately +installInputModalityTracking() installKeyboardNavigation() installGamepadNavigation() diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 94bdc48..67289dc 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -40,6 +40,11 @@ html, body { -ms-hyphens: auto; } +html[data-pointer-visible="false"], +html[data-pointer-visible="false"] * { + cursor: none !important; +} + #app { min-height: 100vh; position: relative; @@ -60,7 +65,7 @@ html, body { border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { +html[data-mouse-active="true"] ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } @@ -170,13 +175,13 @@ html, body { color: rgba(255, 255, 255, 0.78); } -.header-nav-item:hover, +html[data-mouse-active="true"] .header-nav-item:hover, .header-nav-item.active { color: var(--text-primary); } .header-nav-item:focus, -.header-nav-item.nav-focused { +html[data-mouse-active="false"] .header-nav-item.nav-focused { color: var(--text-primary); outline: none; text-decoration: underline; @@ -207,7 +212,7 @@ html, body { transition: color var(--transition-fast); } -.header-settings-btn:hover { +html[data-mouse-active="true"] .header-settings-btn:hover { color: var(--text-primary); } @@ -355,8 +360,8 @@ html, body { color: var(--bg-primary); } -.btn-primary:hover, -.btn-primary.nav-focused { +html[data-mouse-active="true"] .btn-primary:hover, +html[data-mouse-active="false"] .btn-primary.nav-focused { background: rgba(255, 255, 255, 0.85); animation: btn-outline-blink 1s ease-in-out infinite; } @@ -366,8 +371,8 @@ html, body { color: var(--text-primary); } -.btn-secondary:hover, -.btn-secondary.nav-focused { +html[data-mouse-active="true"] .btn-secondary:hover, +html[data-mouse-active="false"] .btn-secondary.nav-focused { background: rgba(109, 109, 110, 0.5); animation: btn-outline-blink 1s ease-in-out infinite; } @@ -449,8 +454,8 @@ html, body { outline: none; } -.media-card:hover, -.media-card.nav-focused { +html[data-mouse-active="true"] .media-card:hover, +html[data-mouse-active="false"] .media-card.nav-focused { z-index: 10; } @@ -472,7 +477,7 @@ html, body { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); } -.media-card:hover .media-card-poster { +html[data-mouse-active="true"] .media-card:hover .media-card-poster { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); } @@ -518,7 +523,7 @@ html, body { transition: opacity var(--transition-fast); } -.media-card:hover .media-card-info { +html[data-mouse-active="true"] .media-card:hover .media-card-info { opacity: 1; } @@ -622,7 +627,7 @@ html, body { transition: background var(--transition-fast); } -.modal-close:hover { +html[data-mouse-active="true"] .modal-close:hover { background: var(--bg-card); } @@ -718,7 +723,7 @@ html, body { transition: background var(--transition-fast); } -.release-item:hover { +html[data-mouse-active="true"] .release-item:hover { background: var(--bg-card-hover); }