diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index d667627d..d1d02ee8 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -199,6 +199,22 @@ function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } +// ─── Nav-Ring Modality ────────────────────────────────────────── +// The orange focus ring must only render while the user is actually +// navigating directionally (arrows / gamepad). Ring visibility is gated in +// CSS behind html.controller-nav; directional input turns it on, any +// pointer input (tap, click) turns it off. Without this gate the ring +// painted on plain :focus — every tap on a tabindex card, and every +// programmatic route-change autofocus, lit it up with no controller in +// sight (worst on mobile). +function setNavRing(on: boolean) { + document.documentElement.classList.toggle('controller-nav', on) +} + +function navRingActive(): boolean { + return document.documentElement.classList.contains('controller-nav') +} + // ─── Main Composable ──────────────────────────────────────────── export function useControllerNav(containerRef?: { value: HTMLElement | null }) { @@ -396,9 +412,18 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { // Mark controller as active isControllerActive.value = true + setNavRing(true) if (keyNavTimeout) clearTimeout(keyNavTimeout) keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000) + // Nothing focused yet (fresh page, or focus was lost) — enter nav mode + // on the first arrow press by focusing the first container, instead of + // spatially navigating from . + if (!activeEl || activeEl === document.body || activeEl === document.documentElement) { + autoFocusMain() + return + } + const dir = e.key === 'ArrowLeft' ? 'left' as const : e.key === 'ArrowRight' ? 'right' as const : e.key === 'ArrowUp' ? 'up' as const @@ -611,6 +636,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const gamepads = navigator.getGamepads?.() gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1 isControllerActive.value = true + setNavRing(true) } function handleGamepadDisconnected() { @@ -644,6 +670,10 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { // ─── Auto-Focus on Route Change ──────────────────────────── function autoFocusMain() { + // Only when the user is actually in directional-nav mode. For pointer + // and touch users this programmatic focus painted the first card's + // focus ring on every route change with zero controller input. + if (!navRingActive()) return const active = document.activeElement as HTMLElement | null // Don't steal focus from inputs, modals, or sidebar if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return @@ -668,9 +698,16 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { // ─── Lifecycle ───────────────────────────────────────────── + // Any pointer interaction ends directional-nav mode — the ring should + // never linger once the user reaches for mouse or touch. + function handlePointerDown() { + setNavRing(false) + } + onMounted(() => { checkGamepads() window.addEventListener('keydown', handleKeyDown, true) + window.addEventListener('pointerdown', handlePointerDown, { capture: true, passive: true }) window.addEventListener('wheel', handleWheel, { passive: false }) window.addEventListener('gamepadconnected', handleGamepadConnected) window.addEventListener('gamepaddisconnected', handleGamepadDisconnected) @@ -680,11 +717,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { onBeforeUnmount(() => { window.removeEventListener('keydown', handleKeyDown, true) + window.removeEventListener('pointerdown', handlePointerDown, { capture: true } as EventListenerOptions) window.removeEventListener('wheel', handleWheel) window.removeEventListener('gamepadconnected', handleGamepadConnected) window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected) if (pollIntervalId) clearInterval(pollIntervalId) if (keyNavTimeout) clearTimeout(keyNavTimeout) + setNavRing(false) }) return { isControllerActive, gamepadCount } diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 8dc4c3b9..493c6338 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -270,9 +270,13 @@ input[type="radio"]:active + * { /* Containers: console-style focus — lift + ambient orange glow. Pure glow approach — no border-color or outline changes, avoids - Chromium compositor bugs with border-radius on translateZ(0) layers. */ -[data-controller-container]:focus-visible, -[data-controller-container]:focus { + Chromium compositor bugs with border-radius on translateZ(0) layers. + Gated behind html.controller-nav (set by useControllerNav only while + the user navigates with arrows/gamepad; cleared on any pointer input). + Ungated, the plain :focus selector painted the ring on every tap of a + tabindex card — the "gamepad selection with no gamepad" bug on mobile. */ +html.controller-nav [data-controller-container]:focus-visible, +html.controller-nav [data-controller-container]:focus { outline: none; transform: translateY(-4px) scale(1.01); box-shadow: