archy/neode-ui/src/composables/useControllerNav.ts
Dorian 901b9f660f feat: gamepad navigation rewrite, focus styling, container grid system
- Rewrite useControllerNav.ts with clean console-style navigation:
  Sidebar (up/down wrap, right→containers, left→nothing),
  Container tile grid (spatial nav, no wrap at edges),
  Nav bar support (up from containers, down to grid),
  Inner controls (enter drills in, escape exits, trapped arrows)
- Add data-controller-container to Mesh, Fleet, Settings pages
- Fix Home.vue fragment (modals outside root div) causing Vue warnings
- Remove skip-to-content link (handled by controller nav)
- Orange ambient glow focus styling matching glass aesthetic
- Disable PWA service worker in dev mode (fixes HMR caching)
- Add gamepad-nav skill and GAMEPAD-NAV-MAP.md spec document
- 39 tests covering all navigation patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:01:17 +00:00

535 lines
20 KiB
TypeScript

/**
* Controller / gamepad navigation for Archipelago.
*
* Navigation model (from the design spec):
*
* SIDEBAR (vertical list):
* Up/Down = move between items, wraps top↔bottom, auto-navigates
* Right = jump to first container in main content
* Left = does nothing
*
* MAIN CONTENT (container tile grid):
* Arrows = move between containers spatially (the red tile grid)
* Enter = trigger container's primary action (navigate link / launch)
* Escape = back to sidebar
* Left from leftmost container = back to sidebar
*
* INSIDE CONTAINER (yellow inner controls — entered via second Enter):
* Arrows = move between inner controls spatially
* Escape = exit back to the container tile
* Cannot move to other containers without exiting first
*
* TEXT INPUTS:
* Up/Down = exit field, navigate to nearest element
* Enter = submit (click next button)
* Left/Right = cursor movement (stay in field)
*/
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useControllerStore } from '@/stores/controller'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { playNavSound } from '@/composables/useNavSounds'
// ─── Element Queries ────────────────────────────────────────────
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[data-controller-focus]',
'[data-controller-container]',
].join(', ')
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
el =>
!el.hasAttribute('disabled') &&
el.offsetParent !== null &&
!el.hasAttribute('data-controller-ignore') &&
!el.closest('[data-controller-ignore]')
)
}
/** Sidebar items */
function getSidebarElements(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
return zone ? getFocusableElements(zone) : []
}
/** Main zone containers only — the [C] tile grid */
function getContainers(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]')).filter(
el => el.offsetParent !== null
)
}
/** Nav bar items [N] — focusable elements in main zone that are NOT inside any container
* (mode-switcher buttons, tab buttons, search inputs above the grid) */
function getNavBarItems(): HTMLElement[] {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (!zone) return []
return getFocusableElements(zone).filter(el =>
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
)
}
function isNavBarItem(el: HTMLElement | null): boolean {
if (!el) return false
return isInZone(el, 'main') &&
!el.hasAttribute('data-controller-container') &&
!el.closest('[data-controller-container]')
}
/** Inner focusables within a container (buttons, links — not the container itself) */
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
return getFocusableElements(container).filter(
el => el !== container && !el.hasAttribute('data-controller-container')
)
}
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
if (!el) return false
return !!el.closest(`[data-controller-zone="${zone}"]`)
}
function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false
const container = el.closest('[data-controller-container]')
return !!container && container !== el
}
function isContainer(el: HTMLElement | null): boolean {
return !!el?.hasAttribute('data-controller-container')
}
// ─── Spatial Navigation ─────────────────────────────────────────
function findNearestInDirection(
from: HTMLElement,
candidates: HTMLElement[],
direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null {
const fromRect = from.getBoundingClientRect()
const fromCX = fromRect.left + fromRect.width / 2
const fromCY = fromRect.top + fromRect.height / 2
const threshold = 50
const filtered = candidates.filter(el => {
if (el === from) return false
const r = el.getBoundingClientRect()
switch (direction) {
case 'left': return r.right <= fromRect.left + threshold
case 'right': return r.left >= fromRect.right - threshold
case 'up': return r.bottom <= fromRect.top + threshold
case 'down': return r.top >= fromRect.bottom - threshold
}
})
if (!filtered.length) return null
const scored = filtered.map(el => {
const r = el.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
const isVertical = direction === 'up' || direction === 'down'
const overlap = isVertical
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
return { el, overlap, dist }
})
scored.sort((a, b) => {
if (b.overlap !== a.overlap) return b.overlap - a.overlap
if (a.dist !== b.dist) return a.dist - b.dist
// Tiebreaker: prefer leftmost for up/down
if (direction === 'up' || direction === 'down') {
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
}
return 0
})
return scored[0]?.el ?? null
}
// ─── Focus Memory ───────────────────────────────────────────────
const zoneFocusMemory = new Map<string, HTMLElement>()
function rememberFocus(zone: string, el: HTMLElement) {
zoneFocusMemory.set(zone, el)
}
function recallFocus(zone: string): HTMLElement | null {
const el = zoneFocusMemory.get(zone)
if (!el) return null
if (document.contains(el) && el.offsetParent !== null) return el
zoneFocusMemory.delete(zone)
return null
}
// ─── Focus Helper ───────────────────────────────────────────────
function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
playNavSound(sound)
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
// ─── Main Composable ────────────────────────────────────────────
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const route = useRoute()
const router = useRouter()
const store = useControllerStore()
const isControllerActive = ref(false)
const gamepadCount = ref(0)
watch([isControllerActive, gamepadCount], () => {
store.setActive(isControllerActive.value)
store.setGamepadCount(gamepadCount.value)
}, { immediate: true })
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
let pollIntervalId: ReturnType<typeof setInterval> | null = null
function checkGamepads() {
const gamepads = navigator.getGamepads?.()
const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
if (count !== gamepadCount.value) {
gamepadCount.value = count
isControllerActive.value = count > 0
}
}
// ─── Keyboard Handler ───────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
if (!navKeys.includes(e.key)) return
const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') {
// Enter in input: click next button (submit pattern)
e.preventDefault()
const all = getFocusableElements(containerRef?.value ?? document)
const idx = all.indexOf(target)
const next = idx >= 0 ? all[idx + 1] : undefined
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
next.focus()
next.click()
} else if (next) {
next.focus()
}
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Up/Down: exit field, navigate spatially
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
const candidates = getFocusableElements(containerRef?.value ?? document).filter(el => el !== target)
const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) focusEl(nearest)
return
}
// Left/Right: stay in field (cursor movement). Escape: handled below.
if (e.key !== 'Escape') return
}
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
if (e.key === 'Escape') {
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
// Inside container inner controls → exit to container
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
if (container && container.tabIndex >= 0) {
focusEl(container, 'back')
e.preventDefault()
return
}
}
// On a container or anywhere in main → go to sidebar
if (isInZone(activeEl, 'main')) {
const sidebar = getSidebarElements()
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) {
rememberFocus('main', activeEl)
focusEl(target, 'back')
e.preventDefault()
}
return
}
// Detail pages: go back
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
playNavSound('back')
window.history.back()
e.preventDefault()
}
return
}
// ── ENTER ───────────────────────────────────────────────
if (e.key === 'Enter') {
e.preventDefault()
if (isContainer(activeEl)) {
// Container has a primary action link (the > chevron)?
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
if (activeEl.hasAttribute('data-controller-launch')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Default: click the primary link to navigate to that section
if (primaryLink) {
playNavSound('action')
primaryLink.click()
return
}
// No primary link — drill into inner controls
const inner = getInnerFocusables(activeEl)
if (inner[0]) {
focusEl(inner[0], 'action')
return
}
}
// Regular element: click it
if (activeEl) {
playNavSound('action')
activeEl.click()
}
return
}
// ── ARROW KEYS ──────────────────────────────────────────
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
e.preventDefault()
// Mark controller as active
isControllerActive.value = true
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
const dir = e.key === 'ArrowLeft' ? 'left' as const
: e.key === 'ArrowRight' ? 'right' as const
: e.key === 'ArrowUp' ? 'up' as const
: 'down' as const
// ── SIDEBAR ─────────────────────────────────────────────
if (isInZone(activeEl, 'sidebar')) {
const items = getSidebarElements()
const idx = items.indexOf(activeEl)
if (dir === 'up' || dir === 'down') {
// Linear wrap
if (idx < 0) return
const nextIdx = dir === 'down'
? (idx >= items.length - 1 ? 0 : idx + 1)
: (idx <= 0 ? items.length - 1 : idx - 1)
const next = items[nextIdx]
if (next && next !== activeEl) {
focusEl(next)
// Auto-navigate sidebar links
if (next.tagName === 'A') {
const href = (next as HTMLAnchorElement).getAttribute('href')
if (href?.startsWith('/')) router.push(href).catch(() => {})
}
}
return
}
if (dir === 'right') {
// Jump to first container in main
rememberFocus('sidebar', activeEl)
const remembered = recallFocus('main')
const containers = getContainers()
const target = remembered ?? containers[0]
if (target) focusEl(target)
return
}
// Left from sidebar: does nothing
return
}
// ── INSIDE CONTAINER (inner controls) ───────────────────
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement
const inner = getInnerFocusables(container)
const next = findNearestInDirection(activeEl, inner, dir)
if (next) focusEl(next)
// Can't leave container via arrows — must use Escape
return
}
// ── NAV BAR [N] — secondary controls above the grid ────
if (isNavBarItem(activeEl)) {
const navItems = getNavBarItems()
if (dir === 'left' || dir === 'right') {
// Spatial nav between nav bar items
const next = findNearestInDirection(activeEl, navItems, dir)
if (next) { focusEl(next); return }
// Left from leftmost nav item → sidebar
if (dir === 'left') {
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
}
return
}
if (dir === 'down') {
// Down from nav bar → first container
const containers = getContainers()
const nearest = findNearestInDirection(activeEl, containers, 'down')
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
// Fallback: just focus first container
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]) }
return
}
// Up from nav bar → nothing
return
}
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
if (isInZone(activeEl, 'main')) {
const containers = getContainers()
// Try spatial nav to another container
const next = findNearestInDirection(activeEl, containers, dir)
if (next) {
rememberFocus('main', next)
focusEl(next)
return
}
// Up from top-row container → nav bar (if exists)
if (dir === 'up') {
const navItems = getNavBarItems()
if (navItems.length) {
const nearest = findNearestInDirection(activeEl, navItems, 'up')
if (nearest) { focusEl(nearest); return }
// Fallback: first nav bar item
const first = navItems[0]
if (first) focusEl(first)
}
return
}
// Left from leftmost container → sidebar
if (dir === 'left') {
rememberFocus('main', activeEl)
const remembered = recallFocus('sidebar')
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = remembered ?? activeTab ?? getSidebarElements()[0]
if (target) focusEl(target)
return
}
// At grid edges (down/right with no target): do nothing
return
}
}
// ─── Gamepad Detection ──────────────────────────────────────
function handleGamepadConnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
isControllerActive.value = true
}
function handleGamepadDisconnected() {
const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
isControllerActive.value = gamepadCount.value > 0
}
// ─── Scroll Support ────────────────────────────────────────
function handleWheel(e: WheelEvent) {
const active = document.activeElement as HTMLElement | null
if (!active) return
let p = active.parentElement
while (p) {
const style = getComputedStyle(p)
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
return
}
p = p.parentElement
}
}
// ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() {
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
if (document.querySelector('[role="dialog"]')) return
requestAnimationFrame(() => {
const remembered = recallFocus('main')
if (remembered) { remembered.focus({ preventScroll: true }); return }
const containers = getContainers()
if (containers[0]) containers[0].focus({ preventScroll: true })
})
}
watch(() => route.path, () => {
zoneFocusMemory.delete('main')
setTimeout(autoFocusMain, 150)
})
// ─── Lifecycle ─────────────────────────────────────────────
onMounted(() => {
checkGamepads()
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
pollIntervalId = setInterval(() => checkGamepads(), 500)
setTimeout(autoFocusMain, 300)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('wheel', handleWheel)
window.removeEventListener('gamepadconnected', handleGamepadConnected)
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
if (pollIntervalId) clearInterval(pollIntervalId)
if (keyNavTimeout) clearTimeout(keyNavTimeout)
})
return { isControllerActive, gamepadCount }
}