From 92d221bbf1721e94064bd3fc795a6ba2f098dcc6 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 16 Jul 2026 15:38:46 -0400 Subject: [PATCH] fix(ui): show the controller focus ring only during actual arrow/gamepad navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orange "gamepad selection" lift+glow was plain CSS :focus styling on [data-controller-container] — any tap on a tabindex card lit it up (worst on mobile), and useControllerNav's autoFocusMain programmatically focused the first card on mount and every route change, painting the ring with no controller input at all. Gate it behind html.controller-nav, toggled by actual input modality: arrow-key navigation and gamepad connection turn it on, any pointerdown turns it off. autoFocusMain now no-ops outside directional-nav mode (a first arrow press focuses the first container instead of spatially navigating from ), so pointer users never get surprise rings while keyboard/gamepad users keep the exact same navigation. Co-Authored-By: Claude Fable 5 --- neode-ui/src/composables/useControllerNav.ts | 39 ++++++++++++++++++++ neode-ui/src/style.css | 10 +++-- 2 files changed, 46 insertions(+), 3 deletions(-) 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: