fix: gamepad nav dead ends on Apps page, orange glass active sidebar style

- Nav-tab-active now uses orange glass (bg, border, glow, gradient)
- Sidebar focus-visible uses matching orange tint
- Enter on containers skips uninstall button, finds primary action
- Down/Right from grid edges falls back to all focusable elements
- Global fallback for standalone buttons in empty/error states
- Full gamepad nav map for all onboarding screens + login modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-30 00:04:58 +01:00
parent 967af7d96f
commit 5f481d8078
3 changed files with 326 additions and 33 deletions

View File

@@ -301,28 +301,38 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
e.preventDefault()
if (isContainer(activeEl)) {
// Container has a primary action link (the > chevron)?
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
// Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (btn) { playNavSound('action'); btn.click(); return }
}
// Prioritised action: launch button
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
// Primary link (e.g. dashboard cards with a[href])
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
if (primaryLink) {
playNavSound('action')
primaryLink.click()
return
}
// No primary link — drill into inner controls
// Fallback: first non-disabled action button (skip uninstall/delete buttons)
const inner = getInnerFocusables(activeEl)
if (inner[0]) {
focusEl(inner[0], 'action')
const actionBtn = inner.find(el =>
(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') &&
!el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') &&
!el.closest('[class*="absolute top"]')
) ?? inner[0]
if (actionBtn) {
focusEl(actionBtn, 'action')
return
}
// Last resort: click the container itself (triggers goToApp on AppCard)
playNavSound('action')
activeEl.click()
return
}
// Regular element: click it
@@ -462,9 +472,36 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return
}
// At grid edges (down/right with no target): do nothing
// At grid edges: try all focusable elements in main zone as fallback
// (prevents dead ends when spatial nav between containers fails)
if (dir === 'down' || dir === 'right') {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone)
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
if (fallback) {
rememberFocus('main', fallback)
focusEl(fallback)
}
}
}
return
}
// ── FALLBACK: unhandled focusable element ───────────────
// Covers standalone buttons/links in empty/error states, modals, etc.
// that aren't inside a recognized zone or container.
if (dir === 'left') {
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) }
} else {
const all = getFocusableElements()
const next = findNearestInDirection(activeEl, all, dir)
if (next) focusEl(next)
}
}
// ─── Gamepad Detection ──────────────────────────────────────