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>
This commit is contained in:
Dorian 2026-03-28 17:01:17 +00:00
parent 1444bcb0c4
commit aada19754d
13 changed files with 1327 additions and 772 deletions

View File

@ -0,0 +1,114 @@
---
name: gamepad-nav
description: Expert-level gamepad/controller navigation for Archipelago's console-style UI. Use when working on D-pad navigation, focus management, spatial navigation, controller support, or 10-foot UI design.
---
# Gamepad Navigation Expert
When working on gamepad/controller navigation in Archipelago, apply these console-derived patterns.
## Architecture
**File**: `neode-ui/src/composables/useControllerNav.ts`
**Styles**: `neode-ui/src/style.css` (focus-visible rules)
The system uses `data-` attributes for navigation zones:
- `data-controller-zone="sidebar"` / `"main"` — navigation zones
- `data-controller-container` — focusable card/group (Enter drills in, Escape exits)
- `data-controller-focusable` — marks element as focusable
- `data-controller-ignore` — excludes from navigation
- `data-controller-install` / `data-controller-launch` — app-specific actions
## Core Navigation Rules (Xbox/PS5/Switch consensus)
### D-pad Movement
- **4 directions only** — Up/Down/Left/Right, one element per press
- **Spatial navigation** — find nearest focusable in direction using bounding rect geometry
- **Distance formula**: `euclidean + displacement - alignment` with overlap scoring
- **Tiebreaker for up/down**: prefer leftmost element (visual consistency in grids)
### Wrapping
- **Linear lists (1D)**: WRAP (last to first, first to last) — sidebar menu, tab bars
- **Grids (2D)**: NO WRAP — stops at edges, prevents disorientation
### Zone Transitions
- **Right from sidebar** -> first focusable in main content (topmost)
- **Left from main's leftmost** -> sidebar's active tab (`.nav-tab-active`)
- **Focus memory**: remember last-focused element per zone, restore on re-entry
### Container Navigation
- **Enter/A**: drill into container (focus first inner element)
- **Escape/B**: exit container (focus the container itself)
- **D-pad inside container**: navigate among inner elements spatially
- **D-pad at container edge**: exit and navigate to adjacent container
### Text Input Handling
- **Up/Down arrows**: EXIT input, navigate to nearest element above/below
- **Left/Right arrows**: stay in input (cursor movement)
- **Enter**: if next focusable is a button, click it directly (submit)
- **Escape**: blur input, navigate out
### Button Mapping
| Action | Xbox | PlayStation | Switch | Keyboard |
|--------|------|------------|--------|----------|
| Confirm | A | Cross | A | Enter |
| Back | B | Circle | B | Escape |
| Navigate | D-pad | D-pad | D-pad | Arrow keys |
## Focus Visual Design
### Console standard (10-foot viewing distance)
- **Minimum 2px** border/outline (1px flickers on interlaced TVs)
- **3:1 contrast ratio** against adjacent colors (WCAG 2.4.7)
- **Smooth transitions**: 150-200ms ease-out
- **GPU compositing**: use `translateZ(0)` on animated elements
- **Never pure white** (#f1f1f1 prevents TV halo effects)
### Archipelago Focus Patterns
```css
/* Global — subtle outline that follows border-radius */
*:focus-visible {
outline: 2px solid rgba(251, 146, 60, 0.6);
outline-offset: 2px;
}
/* Containers — soft glow + slight scale */
[data-controller-container]:focus-visible {
outline: none;
transform: scale(1.01);
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5),
0 0 20px rgba(251, 146, 60, 0.15);
}
/* Sidebar items — background tint + thin ring */
.sidebar-nav-item:focus-visible {
outline: none;
background: rgba(251, 146, 60, 0.12);
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.45);
}
```
## Gamepad API Integration
### Polling
- Poll `navigator.getGamepads()` in `requestAnimationFrame` loop (cheap, returns snapshot)
- Apply deadzone: `Math.abs(axis) > 0.2` before registering input
- D-pad repeat: 400ms initial delay, 150ms interval (gamepads don't auto-repeat)
### Button indices (W3C Standard Mapping)
- 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 12=DUp, 13=DDown, 14=DLeft, 15=DRight
## When Investigating Issues
1. Check `useControllerNav.ts` for the `handleKeyDown` function
2. Check `data-controller-*` attributes in the view's template
3. Verify focusable elements are in the right `data-controller-zone`
4. Test with: arrow keys on keyboard (simulates D-pad)
5. Check `style.css` for `focus-visible` rules
## Key Sources
- [Xbox Accessibility Guideline 112](https://learn.microsoft.com/en-us/gaming/accessibility/xbox-accessibility-guidelines/112)
- [Microsoft: Gamepad and remote interactions](https://learn.microsoft.com/en-us/windows/apps/design/input/gamepad-and-remote-interactions)
- [W3C CSS Spatial Navigation](https://www.w3.org/TR/css-nav-1/)
- [W3C Gamepad Spec](https://w3c.github.io/gamepad/)
- [Norigin Spatial Navigation (React reference)](https://github.com/NoriginMedia/Norigin-Spatial-Navigation)

View File

@ -0,0 +1,277 @@
# Gamepad Navigation Map
Every arrow key, every position, every page.
`[C]` = Container (red tile, D-pad grid)
`[N]` = Nav bar item (secondary, reached via Up from top row)
`[Y]` = Inner control (entered via Enter on container, exited via Escape)
`[S]` = Sidebar item
---
## Sidebar (all pages)
Vertical list. Up/Down wrap. Right enters page. Left does nothing.
| Position | Up | Down | Right | Left |
|------------|------------|------------|----------------|---------|
| Home | Logout | Apps | First [C] | nothing |
| Apps | Home | Cloud | First [C] | nothing |
| Cloud | Apps | Mesh | First [C] | nothing |
| Mesh | Cloud | Network | First [C] | nothing |
| Network | Mesh | Web5 | First [C] | nothing |
| Web5 | Network | Fleet | First [C] | nothing |
| Fleet | Web5 | Settings | First [C] | nothing |
| Settings | Fleet | AIUI | First [C] | nothing |
| AIUI | Settings | Logout | First [C] | nothing |
| Logout | AIUI | Home | First [C] | nothing |
---
## HOME `/dashboard`
### Nav bar `[N]`
```
[N] Dashboard [N] Setup
```
### Grid `[C]`
```
Row 1: [C] My Apps [C] Cloud
Row 2: [C] Network [C] Wallet
Row 3: [C] System
Row 4: [C] Quick Start (full-width, if visible)
```
| Position | Up | Down | Left | Right | Enter |
|---------------|--------------|--------------|------------|----------|---------------------------|
| [N] Dashboard | nothing | My Apps | nothing | Setup | Switch tab |
| [N] Setup | nothing | My Apps | Dashboard | nothing | Switch tab |
| My Apps | [N] bar | Network | Sidebar | Cloud | /dashboard/apps |
| Cloud | [N] bar | Wallet | My Apps | nothing | /dashboard/cloud |
| Network | My Apps | System | Sidebar | Wallet | /dashboard/server |
| Wallet | Cloud | nothing | Network | nothing | /dashboard/web5 |
| System | Network | Quick Start | Sidebar | nothing | /dashboard/settings |
| Quick Start | System | nothing | Sidebar | nothing | Drill into [Y] |
### Quick Start `[Y]` inner controls
```
[Y] Open a Shop [Y] Accept Payments [Y] File Browser
```
| Position | Left | Right | Escape |
|------------------|------------------|------------------|----------------|
| Open a Shop | nothing | Accept Payments | Back to [C] |
| Accept Payments | Open a Shop | File Browser | Back to [C] |
| File Browser | Accept Payments | nothing | Back to [C] |
---
## APPS `/dashboard/apps`
### Nav bar `[N]`
```
[N] My Apps [N] App Store [N] Services | [N] All [N] Bitcoin [N] Social (etc) | [N] Search
```
Three groups: page tabs, category filters (dynamic), search input.
| Position | Up | Down | Left | Right | Enter |
|----------------|---------|---------|----------------|----------------|--------------------|
| [N] My Apps | nothing | App1 | nothing | App Store | Switch tab |
| [N] App Store | nothing | App1 | My Apps | Services | /dashboard/discover|
| [N] Services | nothing | App1 | App Store | All filter | Switch tab |
| [N] All | nothing | App1 | Services | Bitcoin (etc) | Filter |
| [N] Search | nothing | App1 | last filter | nothing | Type text |
### Grid `[C]` (3-col)
```
Row 1: [C] App1 [C] App2 [C] App3
Row 2: [C] App4 [C] App5 [C] App6
(etc)
```
| Position | Up | Down | Left | Right | Enter |
|----------------|-----------|-----------|-----------|----------|-----------------|
| App1 (row 1) | [N] bar | App4 | Sidebar | App2 | Launch app |
| App2 (row 1) | [N] bar | App5 | App1 | App3 | Launch app |
| App3 (row 1) | [N] bar | App6 | App2 | nothing | Launch app |
| App4 (row 2) | App1 | App7 | Sidebar | App5 | Launch app |
| App5 (row 2) | App2 | App8 | App4 | App6 | Launch app |
| App6 (row 2) | App3 | App9 | App5 | nothing | Launch app |
| (etc) | above | below | left/side | right | Launch app |
### App `[Y]` inner controls (if no launch action)
```
[Y] Stop [Y] Restart [Y] Uninstall
```
Escape exits back to [C] app card.
---
## CLOUD `/dashboard/cloud`
No nav bar.
### Grid `[C]` (3-col)
```
Row 1: [C] Photos [C] Music [C] Documents
Row 2: [C] Files [C] Peer1 [C] Peer2 (etc)
```
| Position | Up | Down | Left | Right | Enter |
|-------------|------------|-----------|-----------|------------|-------------------|
| Photos | nothing | Files | Sidebar | Music | Open section |
| Music | nothing | Peer1 | Photos | Documents | Open section |
| Documents | nothing | Peer2 | Music | nothing | Open section |
| Files | Photos | nothing | Sidebar | Peer1 | Open section |
| Peer1 | Music | nothing | Files | Peer2 | Open peer files |
| Peer2 | Documents | nothing | Peer1 | nothing | Open peer files |
---
## NETWORK `/dashboard/server`
No nav bar.
### Grid `[C]` (2-col)
```
Row 1: [C] Local Network [C] Web3
Row 2: [C] Quick Actions (etc)
```
| Position | Up | Down | Left | Right | Enter |
|----------------|-----------|---------------|-----------|-----------|------------------|
| Local Network | nothing | Quick Actions | Sidebar | Web3 | Drill into [Y] |
| Web3 | nothing | Quick Actions | Local Net | nothing | Drill into [Y] |
| Quick Actions | Local Net | nothing | Sidebar | nothing | Drill into [Y] |
---
## WEB5 `/dashboard/web5`
No nav bar. Containers from child components stacked vertically + side-by-side.
### Grid `[C]`
```
Row 1: [C] Action1 [C] Action2 [C] Action3 [C] Action4 [C] Action5 [C] Action6
Row 2: [C] Wallet [C] Domains
Row 3: [C] Nostr Relays [C] Node Visibility
Row 4: [C] Connected Nodes
```
Standard spatial grid nav. Left from leftmost = Sidebar. Enter = drill into [Y] controls.
---
## DISCOVER `/dashboard/discover`
### Nav bar `[N]`
```
[N] My Apps [N] App Store [N] Services | [N] Category filters (etc)
```
### Grid `[C]` (3-col)
```
Row 0: [C] Featured1 [C] Featured2 [C] Featured3
Row 1: [C] App1 [C] App2 [C] App3
(etc)
```
| Position | Up | Down | Left | Right | Enter |
|--------------|-------------|----------|-----------|------------|---------------|
| [N] tabs | nothing | Featured1| left tab | right tab | Switch/filter |
| Featured1 | [N] bar | App1 | Sidebar | Featured2 | View details |
| App1 | Featured1 | App4 | Sidebar | App2 | Install |
| (etc) | above | below | left/side | right | Install |
---
## MESH `/dashboard/mesh`
### Grid `[C]`
```
Row 1: [C] Device Status [C] Chat Panel
Row 2: [C] Peers List [C] Tab Panel (Bitcoin/Dead Man/Map)
```
Spatial grid nav. Enter = drill into controls.
---
## FLEET `/dashboard/fleet`
### Grid `[C]`
```
Row 1: [C] Nodes [C] Online [C] Offline [C] Health
Row 2: [C] Node1 [C] Node2 [C] Node3 (etc)
```
Spatial grid nav. Enter = view node details.
---
## SETTINGS `/dashboard/settings`
### Grid `[C]` (vertical stack)
```
Row 1: [C] Account Info
Row 2: [C] Change Password
Row 3: [C] Two-Factor Auth
Row 4: [C] System Info
Row 5: [C] Danger Zone
```
| Position | Up | Down | Left | Right | Enter |
|-------------------|-----------------|------------------|---------|---------|------------------|
| Account Info | nothing | Change Password | Sidebar | nothing | Drill into [Y] |
| Change Password | Account Info | Two-Factor | Sidebar | nothing | Drill into [Y] |
| Two-Factor | Change Password | System Info | Sidebar | nothing | Drill into [Y] |
| System Info | Two-Factor | Danger Zone | Sidebar | nothing | Drill into [Y] |
| Danger Zone | System Info | nothing | Sidebar | nothing | Drill into [Y] |
---
## LOGIN `/login`
No sidebar, no grid. Simple form.
| Position | Up/Down | Enter |
|------------|----------------|----------|
| Password | Login button | Submit |
| Login | Password | Submit |
---
## ONBOARDING `/onboarding/*`
No sidebar, no grid. Sequential screens.
Button auto-focused. Enter advances.
---
## Rules
1. Sidebar: Up/Down wrap. Right → first [C]. Left → nothing.
2. Grid: arrows move between [C] spatially. No wrap at edges.
3. Left from leftmost [C] → Sidebar active tab.
4. Up from top-row [C] → [N] nav bar (if page has one), else nothing.
5. Enter on [C]: has link → navigate. No link → drill into [Y].
6. Inside [Y]: arrows move between inner controls. Escape → back to [C].
7. Escape from [C] → Sidebar.
8. No dead ends.

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,28 @@
/** /**
* Xbox-style controller / gamepad navigation for Archipelago. * Controller / gamepad navigation for Archipelago.
* - Left: Go to side menu only when on leftmost main content *
* - Right: Go to main content (from side menu) * Navigation model (from the design spec):
* - Main: spatial/grid navigation (up/down/left/right like a game) *
* - Enter enters container's inner actions; actions get celebratory sound * SIDEBAR (vertical list):
* Up/Down = move between items, wraps topbottom, 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 { ref, onMounted, onBeforeUnmount, watch } from 'vue'
@ -14,6 +33,8 @@ import { useCLIStore } from '@/stores/cli'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useAppLauncherStore } from '@/stores/appLauncher'
import { playNavSound } from '@/composables/useNavSounds' import { playNavSound } from '@/composables/useNavSounds'
// ─── Element Queries ────────────────────────────────────────────
const FOCUSABLE_SELECTOR = [ const FOCUSABLE_SELECTOR = [
'a[href]', 'a[href]',
'button:not([disabled])', 'button:not([disabled])',
@ -25,9 +46,9 @@ const FOCUSABLE_SELECTOR = [
'[data-controller-container]', '[data-controller-container]',
].join(', ') ].join(', ')
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] { function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter( return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => el =>
!el.hasAttribute('disabled') && !el.hasAttribute('disabled') &&
el.offsetParent !== null && el.offsetParent !== null &&
!el.hasAttribute('data-controller-ignore') && !el.hasAttribute('data-controller-ignore') &&
@ -35,10 +56,44 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
) )
} }
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] { /** Sidebar items */
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null function getSidebarElements(): HTMLElement[] {
if (!container) return [] const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
return getFocusableElements(container) 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 { function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
@ -46,87 +101,92 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
return !!el.closest(`[data-controller-zone="${zone}"]`) return !!el.closest(`[data-controller-zone="${zone}"]`)
} }
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container'))
}
function isInsideContainer(el: HTMLElement | null): boolean { function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false if (!el) return false
const container = el.closest('[data-controller-container]') const container = el.closest('[data-controller-container]')
return !!container && container !== el return !!container && container !== el
} }
/** Spatial navigation: find nearest focusable in direction (game-style grid) */ function isContainer(el: HTMLElement | null): boolean {
return !!el?.hasAttribute('data-controller-container')
}
// ─── Spatial Navigation ─────────────────────────────────────────
function findNearestInDirection( function findNearestInDirection(
from: HTMLElement, from: HTMLElement,
candidates: HTMLElement[], candidates: HTMLElement[],
direction: 'up' | 'down' | 'left' | 'right' direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null { ): HTMLElement | null {
const fromRect = from.getBoundingClientRect() const fromRect = from.getBoundingClientRect()
const fromCenterX = fromRect.left + fromRect.width / 2 const fromCX = fromRect.left + fromRect.width / 2
const fromCenterY = fromRect.top + fromRect.height / 2 const fromCY = fromRect.top + fromRect.height / 2
const threshold = 50 // px overlap allowed const threshold = 50
const filtered = candidates.filter((el) => { const filtered = candidates.filter(el => {
if (el === from) return false if (el === from) return false
const r = el.getBoundingClientRect() const r = el.getBoundingClientRect()
switch (direction) { switch (direction) {
case 'left': case 'left': return r.right <= fromRect.left + threshold
return r.right <= fromRect.left + threshold case 'right': return r.left >= fromRect.right - threshold
case 'right': case 'up': return r.bottom <= fromRect.top + threshold
return r.left >= fromRect.right - threshold case 'down': return r.top >= fromRect.bottom - threshold
case 'up':
return r.bottom <= fromRect.top + threshold
case 'down':
return r.top >= fromRect.bottom - threshold
default:
return false
} }
}) })
if (filtered.length === 0) return null if (!filtered.length) return null
// Pick best: most overlap on perpendicular axis, then closest const scored = filtered.map(el => {
const scored = filtered.map((el) => {
const r = el.getBoundingClientRect() const r = el.getBoundingClientRect()
const centerX = r.left + r.width / 2 const cx = r.left + r.width / 2
const centerY = r.top + r.height / 2 const cy = r.top + r.height / 2
const isVertical = direction === 'up' || direction === 'down'
let overlap: number const overlap = isVertical
let dist: number ? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
switch (direction) { : Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
case 'left': const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
case 'right':
overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
dist = Math.abs(centerX - fromCenterX)
break
case 'up':
case 'down':
overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
dist = Math.abs(centerY - fromCenterY)
break
default:
overlap = 0
dist = Infinity
}
return { el, overlap, dist } return { el, overlap, dist }
}) })
scored.sort((a, b) => { scored.sort((a, b) => {
if (b.overlap !== a.overlap) return b.overlap - a.overlap if (b.overlap !== a.overlap) return b.overlap - a.overlap
if (a.dist !== b.dist) return a.dist - b.dist if (a.dist !== b.dist) return a.dist - b.dist
// Tiebreaker for up/down: prefer leftmost element in grid layouts // Tiebreaker: prefer leftmost for up/down
if (direction === 'up' || direction === 'down') { if (direction === 'up' || direction === 'down') {
const aLeft = a.el.getBoundingClientRect().left return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
const bLeft = b.el.getBoundingClientRect().left
return aLeft - bLeft
} }
return 0 return 0
}) })
return scored[0]?.el ?? null 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 }) { export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -138,110 +198,89 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
store.setActive(isControllerActive.value) store.setActive(isControllerActive.value)
store.setGamepadCount(gamepadCount.value) store.setGamepadCount(gamepadCount.value)
}, { immediate: true }) }, { immediate: true })
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
let pollIntervalId: ReturnType<typeof setInterval> | null = null let pollIntervalId: ReturnType<typeof setInterval> | null = null
function checkGamepads() { function checkGamepads() {
const gamepads = navigator.getGamepads?.() const gamepads = navigator.getGamepads?.()
const count = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0 const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
if (count !== gamepadCount.value) { if (count !== gamepadCount.value) {
gamepadCount.value = count gamepadCount.value = count
isControllerActive.value = count > 0 isControllerActive.value = count > 0
} }
} }
// ─── Keyboard Handler ───────────────────────────────────────
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'] const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
if (!navKeys.includes(e.key)) return if (!navKeys.includes(e.key)) return
const target = e.target as HTMLElement const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Enter in text field: find next focusable — if it's a button, click it directly (submit)
if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') { if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') {
// Enter in input: click next button (submit pattern)
e.preventDefault() e.preventDefault()
const root = containerRef?.value ?? document const all = getFocusableElements(containerRef?.value ?? document)
const all = getFocusableElements(root) const idx = all.indexOf(target)
const idx = all.indexOf(target as HTMLElement)
const next = idx >= 0 ? all[idx + 1] : undefined const next = idx >= 0 ? all[idx + 1] : undefined
if (next) { if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
if (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button') { next.focus()
next.focus() next.click()
next.click() } else if (next) {
} else { next.focus()
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
} }
return return
} }
// Up/Down arrows: exit field and navigate to element above/below
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Up/Down: exit field, navigate spatially
e.preventDefault() e.preventDefault()
;(target as HTMLElement).blur() const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
// Fall through to arrow key handling below const candidates = getFocusableElements(containerRef?.value ?? document).filter(el => el !== target)
} else if (e.key !== 'Escape') { const nearest = findNearestInDirection(target, candidates, dir)
if (nearest) focusEl(nearest)
return return
} }
// Left/Right: stay in field (cursor movement). Escape: handled below.
if (e.key !== 'Escape') return
} }
const root = containerRef?.value ?? document // ── CLOSE OVERLAYS (Escape) ─────────────────────────────
const focusable = getFocusableElements(root)
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
const activeEl = document.activeElement as HTMLElement
// --- ESCAPE ---
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (useAppLauncherStore().isOpen) { if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
useAppLauncherStore().close() if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
e.preventDefault() if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
e.stopPropagation()
return
}
if (useSpotlightStore().isOpen) {
useSpotlightStore().close()
e.preventDefault()
e.stopPropagation()
return
}
if (useCLIStore().isOpen) {
useCLIStore().close()
e.preventDefault()
e.stopPropagation()
return
}
// Inside container inner controls → exit to container
if (isInsideContainer(activeEl)) { if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
if (container && container.tabIndex >= 0) { if (container && container.tabIndex >= 0) {
playNavSound('back') focusEl(container, 'back')
container.focus()
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault() e.preventDefault()
return return
} }
} }
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path) // On a container or anywhere in main → go to sidebar
if (isDetailPage) { if (isInZone(activeEl, 'main')) {
playNavSound('back') const sidebar = getSidebarElements()
window.history.back() const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
e.preventDefault() const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeTab ?? sidebar[0]
if (target) {
rememberFocus('main', activeEl)
focusEl(target, 'back')
e.preventDefault()
}
return return
} }
const sidebarEls = getElementsInZone('sidebar') // Detail pages: go back
const firstSidebar = sidebarEls[0] if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
if (firstSidebar && isInZone(activeEl, 'main')) {
playNavSound('back')
firstSidebar.focus()
firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
// Don't navigate back from top-level pages — it leads to a dead end
const topLevel = ['/', '/dashboard', '/login', '/kiosk']
if (!topLevel.some(p => route.path === p || route.path.startsWith('/dashboard'))) {
playNavSound('back') playNavSound('back')
window.history.back() window.history.back()
e.preventDefault() e.preventDefault()
@ -249,288 +288,236 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return return
} }
// --- ENTER --- // ── ENTER ───────────────────────────────────────────────
if (e.key === 'Enter') { if (e.key === 'Enter') {
if (currentIndex >= 0 && focusable[currentIndex]) {
const el = focusable[currentIndex] as HTMLElement
if (el.hasAttribute('data-controller-container')) {
// Marketplace: Enter = install (click install button)
if (el.hasAttribute('data-controller-install')) {
const installBtn = el.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
if (installBtn) {
playNavSound('action')
installBtn.click()
e.preventDefault()
return
}
}
// My Apps: Enter = launch (click Launch button when app is runnable)
if (el.hasAttribute('data-controller-launch')) {
const launchBtn = el.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
if (launchBtn) {
playNavSound('action')
launchBtn.click()
e.preventDefault()
return
}
}
// My Apps, etc: Enter = focus first inner control
const inner = getInnerFocusables(el)
const firstInner = inner[0]
if (firstInner) {
playNavSound('action')
firstInner.focus()
firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
}
playNavSound('action')
el.click()
}
e.preventDefault() 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 return
} }
// --- ARROWS --- // ── ARROW KEYS ──────────────────────────────────────────
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
isControllerActive.value = true e.preventDefault()
if (keyNavTimeout) clearTimeout(keyNavTimeout)
keyNavTimeout = setTimeout(() => {
isControllerActive.value = gamepadCount.value > 0
}, 3000)
// Tab roving: Left/Right on role="tab" switches to sibling tab and activates it // Mark controller as active
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && activeEl?.getAttribute('role') === 'tab') { isControllerActive.value = true
const tablist = activeEl.closest('[role="tablist"]') ?? activeEl.parentElement if (keyNavTimeout) clearTimeout(keyNavTimeout)
if (tablist) { keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])'))
const idx = tabs.indexOf(activeEl) const dir = e.key === 'ArrowLeft' ? 'left' as const
if (idx >= 0) { : e.key === 'ArrowRight' ? 'right' as const
const nextIdx = e.key === 'ArrowRight' : e.key === 'ArrowUp' ? 'up' as const
? (idx + 1) % tabs.length : 'down' as const
: (idx - 1 + tabs.length) % tabs.length
const next = tabs[nextIdx] // ── SIDEBAR ─────────────────────────────────────────────
if (next) { if (isInZone(activeEl, 'sidebar')) {
playNavSound('move') const items = getSidebarElements()
next.focus() const idx = items.indexOf(activeEl)
next.click()
e.preventDefault() if (dir === 'up' || dir === 'down') {
return // 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(() => {})
} }
} }
}
const sidebarEls = getElementsInZone('sidebar')
const mainEls = getElementsInZone('main')
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
// Right: from sidebar → main
// - On Home: go to My Apps container
// - On Apps/Marketplace: go to first app container
// - On Cloud: go to first folder (Pictures)
// - On Network (server): go to Services container
// - On Web5: go to Networking Profits container
// - On Settings: go to Change Password container
// - Otherwise: go to top right (App Switcher)
const mainZone = document.querySelector('[data-controller-zone="main"]')
const isHome = /^\/dashboard(\/)?$/.test(route.path)
const isAppsOrMarketplace = /^\/dashboard\/(apps|marketplace)(\/|$)/.test(route.path)
const isCloud = /^\/dashboard\/cloud(\/|$)/.test(route.path)
const isNetwork = /^\/dashboard\/server(\/|$)/.test(route.path)
const isWeb5 = /^\/dashboard\/web5(\/|$)/.test(route.path)
const isSettings = /^\/dashboard\/settings(\/|$)/.test(route.path)
const firstAppContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
const topRightEntry = mainZone?.querySelector<HTMLElement>('[data-controller-main-entry]')
const firstFocusableInTopRight = topRightEntry ? getFocusableElements(topRightEntry)[0] : null
const firstMain = ((isHome || isAppsOrMarketplace || isCloud || isNetwork || isWeb5 || isSettings) && firstAppContainer)
? firstAppContainer
: (firstFocusableInTopRight ?? mainEls[0])
if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) {
playNavSound('move')
firstMain.focus()
firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return return
} }
// Main zone: spatial navigation (game-style grid) if (dir === 'right') {
if (hasZones && isInZone(activeEl, 'main')) { // Jump to first container in main
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down' rememberFocus('sidebar', activeEl)
const next = findNearestInDirection(activeEl, mainEls, dir) const remembered = recallFocus('main')
const containers = getContainers()
const target = remembered ?? containers[0]
if (target) focusEl(target)
return
}
if (next) { // Left from sidebar: does nothing
playNavSound('move') return
next.focus() }
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout) // ── INSIDE CONTAINER (inner controls) ───────────────────
if (e.key === 'ArrowLeft' && dir === 'left') { 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 sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active') const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeNavTab ?? sidebarEls[0] const target = activeTab ?? getSidebarElements()[0]
if (target) { if (target) focusEl(target)
playNavSound('move')
target.focus()
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
} }
return
} }
// Inside container: spatial nav among inner elements if (dir === 'down') {
if (isInsideContainer(activeEl)) { // Down from nav bar → first container
const container = activeEl.closest('[data-controller-container]') as HTMLElement const containers = getContainers()
if (container) { const nearest = findNearestInDirection(activeEl, containers, 'down')
const inner = getInnerFocusables(container) if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down' // Fallback: just focus first container
const next = findNearestInDirection(activeEl, inner, dir) if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]) }
if (next) { return
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
return
}
}
} }
// Sidebar: linear up/down with wrap (Home+Up→Logout, Logout+Down→Home) // Up from nav bar → nothing
if (isInZone(activeEl, 'sidebar')) { return
const idx = sidebarEls.indexOf(activeEl) }
if (idx >= 0) {
const isDown = e.key === 'ArrowDown'
let nextIdx: number
if (isDown) {
nextIdx = idx >= sidebarEls.length - 1 ? 0 : idx + 1
} else {
nextIdx = idx <= 0 ? sidebarEls.length - 1 : idx - 1
}
const next = sidebarEls[nextIdx]
if (next && next !== activeEl) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
if (next.tagName === 'A') {
const href = (next as HTMLAnchorElement).getAttribute?.('href')
if (href && href.startsWith('/')) router.push(href).catch(() => {})
}
e.preventDefault()
return
}
}
}
// Fallback: linear navigation // ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
let nextIndex = currentIndex if (isInZone(activeEl, 'main')) {
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight' const containers = getContainers()
if (focusable.length === 0) return
if (currentIndex < 0) { // Try spatial nav to another container
nextIndex = isForward ? 0 : focusable.length - 1 const next = findNearestInDirection(activeEl, containers, dir)
} else {
nextIndex = isForward ? currentIndex + 1 : currentIndex - 1
if (nextIndex < 0) nextIndex = focusable.length - 1
if (nextIndex >= focusable.length) nextIndex = 0
}
const next = focusable[nextIndex]
if (next) { if (next) {
playNavSound('move') rememberFocus('main', next)
next.focus() focusEl(next)
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) return
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
const href = (next as HTMLAnchorElement).getAttribute?.('href')
if (href && href.startsWith('/') && next.tagName === 'A') {
router.push(href).catch(() => {})
}
}
e.preventDefault()
} }
// 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
} }
} }
function handleGamepadInput() { // ─── Gamepad Detection ──────────────────────────────────────
checkGamepads()
}
function handleGamepadConnected() { function handleGamepadConnected() {
const gamepads = navigator.getGamepads?.() const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 1 gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
isControllerActive.value = true isControllerActive.value = true
} }
function handleGamepadDisconnected() { function handleGamepadDisconnected() {
const gamepads = navigator.getGamepads?.() const gamepads = navigator.getGamepads?.()
gamepadCount.value = gamepads ? Array.from(gamepads).filter((g) => g?.connected).length : 0 gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
isControllerActive.value = gamepadCount.value > 0 isControllerActive.value = gamepadCount.value > 0
} }
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */ // ─── Scroll Support ────────────────────────────────────────
function getScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
let p = el?.parentElement
while (p) {
const style = getComputedStyle(p)
const oy = style.overflowY
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p
p = p.parentElement
}
return null
}
/** Ensure wheel scrolls the scrollable area containing the focused element */
function handleWheel(e: WheelEvent) { function handleWheel(e: WheelEvent) {
const active = document.activeElement as HTMLElement | null const active = document.activeElement as HTMLElement | null
if (!active) return if (!active) return
const scrollable = getScrollableAncestor(active) let p = active.parentElement
if (!scrollable) return while (p) {
if (e.deltaY !== 0) { const style = getComputedStyle(p)
scrollable.scrollTop += e.deltaY if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
e.preventDefault() if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
} return
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) { }
scrollable.scrollLeft += e.deltaX p = p.parentElement
e.preventDefault()
} }
} }
/** Auto-focus first main container on route change (game-style: always have something selected) */ // ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() { function autoFocusMain() {
// Don't steal focus from inputs or modals
const active = document.activeElement as HTMLElement | null const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
if (document.querySelector('[role="dialog"]')) return if (document.querySelector('[role="dialog"]')) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
const mainZone = document.querySelector('[data-controller-zone="main"]') const remembered = recallFocus('main')
if (!mainZone) return if (remembered) { remembered.focus({ preventScroll: true }); return }
const firstContainer = mainZone.querySelector<HTMLElement>('[data-controller-container]') const containers = getContainers()
if (firstContainer) { if (containers[0]) containers[0].focus({ preventScroll: true })
firstContainer.focus({ preventScroll: true })
}
}) })
} }
watch(() => route.path, () => { watch(() => route.path, () => {
// Small delay to let Vue render the new route's DOM zoneFocusMemory.delete('main')
setTimeout(autoFocusMain, 150) setTimeout(autoFocusMain, 150)
}) })
// ─── Lifecycle ─────────────────────────────────────────────
onMounted(() => { onMounted(() => {
checkGamepads() checkGamepads()
window.addEventListener('keydown', handleKeyDown, true) window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('wheel', handleWheel, { passive: false }) window.addEventListener('wheel', handleWheel, { passive: false })
window.addEventListener('gamepadconnected', handleGamepadConnected) window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected) window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
pollIntervalId = setInterval(handleGamepadInput, 500) pollIntervalId = setInterval(() => checkGamepads(), 500)
// Initial auto-focus after mount
setTimeout(autoFocusMain, 300) setTimeout(autoFocusMain, 300)
}) })
@ -543,8 +530,5 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (keyNavTimeout) clearTimeout(keyNavTimeout) if (keyNavTimeout) clearTimeout(keyNavTimeout)
}) })
return { return { isControllerActive, gamepadCount }
isControllerActive,
gamepadCount,
}
} }

View File

@ -27,7 +27,8 @@
overflow: hidden; overflow: hidden;
z-index: 9999; z-index: 9999;
} }
.skip-to-content:focus { .skip-to-content:focus,
.skip-to-content:focus-visible {
position: fixed; position: fixed;
top: 12px; top: 12px;
left: 50%; left: 50%;
@ -44,14 +45,32 @@
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
outline: none;
} }
/* Controller / keyboard navigation - orange border (Archipelago brand) */ /* Controller / keyboard navigation only for elements without their own focus styles.
Elements with existing hover/active styles (glass-button, sidebar-nav-item, etc.) keep theirs. */
*:focus-visible { *:focus-visible {
outline: none; outline: none;
border-color: rgba(251, 146, 60, 0.8) !important; box-shadow:
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25); 0 0 0 1px rgba(251, 146, 60, 0.4),
transition: box-shadow 0.2s ease, border-color 0.2s ease; 0 0 12px rgba(251, 146, 60, 0.2),
0 0 24px rgba(251, 146, 60, 0.08);
transition: box-shadow 0.2s ease;
}
/* Elements with existing styles: suppress the global glow, let their own styles handle it */
.glass-button:focus-visible,
.glass-card:focus-visible,
.sidebar-nav-item:focus-visible,
.path-action-button:focus-visible,
.path-option-card:focus-visible,
.mode-switcher-btn:focus-visible,
.kiosk-app-tile:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
box-shadow: unset;
} }
/* Mobile touch targets — ensure tappable elements meet 44px minimum */ /* Mobile touch targets — ensure tappable elements meet 44px minimum */
@ -99,13 +118,15 @@ input[type="radio"]:active + * {
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
/* Containers get subtle grow + orange glow when focused (gamepad selection) */ /* Containers: console-style focus — subtle lift + ambient glow through glass */
[data-controller-container]:focus-visible { [data-controller-container]:focus-visible {
transform: scale(1.02); outline: none;
transform: scale(1.01) translateZ(0);
box-shadow: box-shadow:
0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 0 1px rgba(251, 146, 60, 0.35),
0 0 24px rgba(251, 146, 60, 0.2), 0 4px 20px rgba(251, 146, 60, 0.12),
inset 0 0 24px rgba(255, 255, 255, 0.03); 0 0 40px rgba(251, 146, 60, 0.06),
inset 0 1px 0 rgba(251, 146, 60, 0.1);
} }
/* Global glassmorphism utilities */ /* Global glassmorphism utilities */
@ -977,11 +998,9 @@ input[type="radio"]:active + * {
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.sidebar-nav-item:focus-visible { .sidebar-nav-item:focus-visible {
transform: scale(1.02) !important; outline: none !important;
box-shadow: background: rgba(255, 255, 255, 0.1) !important;
0 0 0 2px rgba(251, 146, 60, 0.7), color: white !important;
0 0 24px rgba(251, 146, 60, 0.2),
inset 0 0 24px rgba(255, 255, 255, 0.03) !important;
} }
} }
@ -1303,7 +1322,8 @@ html:has(body.video-background-active)::before {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
.cloud-file-item:focus-visible { .cloud-file-item:focus-visible {
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25); outline: none;
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5), 0 0 16px rgba(251, 146, 60, 0.12);
} }
.cloud-file-item-thumb { .cloud-file-item-thumb {
@ -1481,7 +1501,8 @@ html:has(body.video-background-active)::before {
transform: translateY(0); transform: translateY(0);
} }
.cloud-grid-card:focus-visible { .cloud-grid-card:focus-visible {
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25); outline: none;
box-shadow: 0 0 0 1.5px rgba(251, 146, 60, 0.5), 0 0 16px rgba(251, 146, 60, 0.12);
} }
.cloud-grid-card-cover { .cloud-grid-card-cover {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }"> <div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- Skip to main content link for keyboard users --> <!-- Skip to main content link for keyboard users -->
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a> <!-- Skip-to-content handled by controller nav sidebarmain transition -->
<!-- Background container with 3D perspective - full width to avoid letterboxing --> <!-- Background container with 3D perspective - full width to avoid letterboxing -->
<div class="bg-perspective-container"> <div class="bg-perspective-container">
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) --> <!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
@ -126,7 +126,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router' import { RouterView, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher' import { useAppLauncherStore } from '../stores/appLauncher'
import AppSession from '@/views/AppSession.vue' import AppSession from '@/views/AppSession.vue'
@ -140,8 +139,6 @@ import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions' import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import '@/views/dashboard/dashboard-styles.css' import '@/views/dashboard/dashboard-styles.css'
const { t } = useI18n()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAppStore() const store = useAppStore()

View File

@ -216,12 +216,12 @@
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]"> <div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink> <RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
</div> </div>
</div>
<!-- Wallet Modals --> <!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" /> <SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" /> <ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" /> <TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -346,7 +346,7 @@ function truncatePubkey(hex: string | null): string {
<!-- LEFT COLUMN: Status + Peers --> <!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }"> <div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- Device Status --> <!-- Device Status -->
<div class="glass-card mesh-status-card"> <div data-controller-container tabindex="0" class="glass-card mesh-status-card">
<div class="mesh-status-header"> <div class="mesh-status-header">
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" /> <div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
<h2 class="mesh-section-title">Device</h2> <h2 class="mesh-section-title">Device</h2>
@ -429,7 +429,7 @@ function truncatePubkey(hex: string | null): string {
</div> </div>
<!-- Peers list --> <!-- Peers list -->
<div class="glass-card mesh-peers-card"> <div data-controller-container tabindex="0" class="glass-card mesh-peers-card">
<h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2> <h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty"> <div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
@ -512,7 +512,7 @@ function truncatePubkey(hex: string | null): string {
</div> </div>
<!-- Chat Panel --> <!-- Chat Panel -->
<div v-if="showChatPanel" class="glass-card mesh-chat-card"> <div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
<div v-if="!hasActiveChat" class="mesh-chat-empty"> <div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div> <div class="mesh-chat-empty-icon">&#x1F4E1;</div>
<p>Select a peer or channel to chat</p> <p>Select a peer or channel to chat</p>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6"> <div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div class="monitoring-stat-card"> <div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Total Nodes</p> <p class="text-xs text-white/50 uppercase tracking-wide">Total Nodes</p>
<p class="text-2xl font-bold text-white">{{ nodeCount }}</p> <p class="text-2xl font-bold text-white">{{ nodeCount }}</p>
<p class="text-xs text-white/40"> <p class="text-xs text-white/40">
@ -8,22 +8,22 @@
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline <span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
</p> </p>
</div> </div>
<div class="monitoring-stat-card"> <div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p> <p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p> <p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p> <p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p>
</div> </div>
<div class="monitoring-stat-card"> <div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p> <p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p> <p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p> <p class="text-xs text-white/40">across fleet</p>
</div> </div>
<div class="monitoring-stat-card"> <div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p> <p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p> <p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p> <p class="text-xs text-white/40">across fleet</p>
</div> </div>
<div class="monitoring-stat-card"> <div data-controller-container tabindex="0" class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p> <p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p> <p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p> <p class="text-xs text-white/40">across fleet</p>

View File

@ -114,7 +114,7 @@ init()
</div> </div>
<!-- Info Grid --> <!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> <div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Server Name Card (editable) --> <!-- Server Name Card (editable) -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10"> <div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">

View File

@ -48,7 +48,7 @@ async function performFactoryReset() {
<template> <template>
<!-- Network Diagnostics Link --> <!-- Network Diagnostics Link -->
<div class="glass-card px-6 py-6 mb-6"> <div data-controller-container tabindex="0" class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2> <h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
@ -64,7 +64,7 @@ async function performFactoryReset() {
</div> </div>
<!-- Reboot Section --> <!-- Reboot Section -->
<div class="path-option-card px-6 py-6 mt-6"> <div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2> <h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
@ -109,7 +109,7 @@ async function performFactoryReset() {
</Teleport> </Teleport>
<!-- Factory Reset Section --> <!-- Factory Reset Section -->
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30"> <div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6 border-red-500/30">
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2> <h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
<p class="text-sm text-white/60 mb-4"> <p class="text-sm text-white/60 mb-4">
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen. Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.

View File

@ -128,7 +128,7 @@ loadTotpStatus()
<template> <template>
<!-- Two-Factor Authentication --> <!-- Two-Factor Authentication -->
<div class="mb-6"> <div data-controller-container tabindex="0" class="mb-6">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -104,8 +104,7 @@ export default defineConfig({
] ]
}, },
devOptions: { devOptions: {
enabled: true, enabled: false,
type: 'module'
} }
}) })
], ],