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.
* - Left: Go to side menu only when on leftmost main content
* - Right: Go to main content (from side menu)
* - Main: spatial/grid navigation (up/down/left/right like a game)
* - Enter enters container's inner actions; actions get celebratory sound
* Controller / gamepad navigation for Archipelago.
*
* Navigation model (from the design spec):
*
* 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'
@ -14,6 +33,8 @@ 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])',
@ -25,9 +46,9 @@ const FOCUSABLE_SELECTOR = [
'[data-controller-container]',
].join(', ')
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) =>
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') &&
@ -35,10 +56,44 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
)
}
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
if (!container) return []
return getFocusableElements(container)
/** 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 {
@ -46,87 +101,92 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
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 {
if (!el) return false
const container = el.closest('[data-controller-container]')
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(
from: HTMLElement,
candidates: HTMLElement[],
direction: 'up' | 'down' | 'left' | 'right'
): HTMLElement | null {
const fromRect = from.getBoundingClientRect()
const fromCenterX = fromRect.left + fromRect.width / 2
const fromCenterY = fromRect.top + fromRect.height / 2
const threshold = 50 // px overlap allowed
const fromCX = fromRect.left + fromRect.width / 2
const fromCY = fromRect.top + fromRect.height / 2
const threshold = 50
const filtered = candidates.filter((el) => {
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
default:
return false
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 === 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 centerX = r.left + r.width / 2
const centerY = r.top + r.height / 2
let overlap: number
let dist: number
switch (direction) {
case 'left':
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
}
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 for up/down: prefer leftmost element in grid layouts
// Tiebreaker: prefer leftmost for up/down
if (direction === 'up' || direction === 'down') {
const aLeft = a.el.getBoundingClientRect().left
const bLeft = b.el.getBoundingClientRect().left
return aLeft - bLeft
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()
@ -138,110 +198,89 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
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
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
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') {
e.preventDefault()
const root = containerRef?.value ?? document
const all = getFocusableElements(root)
const idx = all.indexOf(target as HTMLElement)
const next = idx >= 0 ? all[idx + 1] : undefined
if (next) {
if (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button') {
next.focus()
next.click()
} else {
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
return
}
// Up/Down arrows: exit field and navigate to element above/below
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
;(target as HTMLElement).blur()
// Fall through to arrow key handling below
} else if (e.key !== 'Escape') {
return
}
}
const root = containerRef?.value ?? document
const focusable = getFocusableElements(root)
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
const activeEl = document.activeElement as HTMLElement
// --- ESCAPE ---
if (e.key === 'Escape') {
if (useAppLauncherStore().isOpen) {
useAppLauncherStore().close()
// ── 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()
e.stopPropagation()
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 (useSpotlightStore().isOpen) {
useSpotlightStore().close()
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// Up/Down: exit field, navigate spatially
e.preventDefault()
e.stopPropagation()
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
}
if (useCLIStore().isOpen) {
useCLIStore().close()
e.preventDefault()
e.stopPropagation()
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) {
playNavSound('back')
container.focus()
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
focusEl(container, 'back')
e.preventDefault()
return
}
}
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
if (isDetailPage) {
playNavSound('back')
window.history.back()
// 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
}
const sidebarEls = getElementsInZone('sidebar')
const firstSidebar = sidebarEls[0]
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'))) {
// Detail pages: go back
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
playNavSound('back')
window.history.back()
e.preventDefault()
@ -249,288 +288,236 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return
}
// --- ENTER ---
// ── ENTER ───────────────────────────────────────────────
if (e.key === 'Enter') {
if (currentIndex >= 0 && focusable[currentIndex]) {
const el = focusable[currentIndex] as HTMLElement
e.preventDefault()
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) {
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')
installBtn.click()
e.preventDefault()
primaryLink.click()
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()
// 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')
el.click()
activeEl.click()
}
e.preventDefault()
return
}
// --- ARROWS ---
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
// ── 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)
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
// Tab roving: Left/Right on role="tab" switches to sibling tab and activates it
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && activeEl?.getAttribute('role') === 'tab') {
const tablist = activeEl.closest('[role="tablist"]') ?? activeEl.parentElement
if (tablist) {
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])'))
const idx = tabs.indexOf(activeEl)
if (idx >= 0) {
const nextIdx = e.key === 'ArrowRight'
? (idx + 1) % tabs.length
: (idx - 1 + tabs.length) % tabs.length
const next = tabs[nextIdx]
if (next) {
playNavSound('move')
next.focus()
next.click()
e.preventDefault()
return
}
}
}
}
const dir = e.key === 'ArrowLeft' ? 'left' as const
: e.key === 'ArrowRight' ? 'right' as const
: e.key === 'ArrowUp' ? 'up' as const
: 'down' as const
const sidebarEls = getElementsInZone('sidebar')
const mainEls = getElementsInZone('main')
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
// ── SIDEBAR ─────────────────────────────────────────────
if (isInZone(activeEl, 'sidebar')) {
const items = getSidebarElements()
const idx = items.indexOf(activeEl)
// 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()
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
}
// Main zone: spatial navigation (game-style grid)
if (hasZones && isInZone(activeEl, 'main')) {
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
const next = findNearestInDirection(activeEl, mainEls, dir)
if (next) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
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
}
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
if (e.key === 'ArrowLeft' && dir === 'left') {
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
const activeNavTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
const target = activeNavTab ?? sidebarEls[0]
if (target) {
playNavSound('move')
target.focus()
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
// Left from sidebar: does nothing
return
}
}
}
// Inside container: spatial nav among inner elements
// ── INSIDE CONTAINER (inner controls) ───────────────────
if (isInsideContainer(activeEl)) {
const container = activeEl.closest('[data-controller-container]') as HTMLElement
if (container) {
const inner = getInnerFocusables(container)
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
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) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
e.preventDefault()
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
}
}
}
// Sidebar: linear up/down with wrap (Home+Up→Logout, Logout+Down→Home)
if (isInZone(activeEl, 'sidebar')) {
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
let nextIndex = currentIndex
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
if (focusable.length === 0) return
if (currentIndex < 0) {
nextIndex = isForward ? 0 : focusable.length - 1
} 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) {
playNavSound('move')
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
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()
}
}
}
function handleGamepadInput() {
checkGamepads()
}
// ─── Gamepad Detection ──────────────────────────────────────
function handleGamepadConnected() {
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
}
function handleGamepadDisconnected() {
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
}
/** Find nearest scrollable ancestor (overflow-y auto/scroll) */
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
}
// ─── Scroll Support ────────────────────────────────────────
/** Ensure wheel scrolls the scrollable area containing the focused element */
function handleWheel(e: WheelEvent) {
const active = document.activeElement as HTMLElement | null
if (!active) return
const scrollable = getScrollableAncestor(active)
if (!scrollable) return
if (e.deltaY !== 0) {
scrollable.scrollTop += e.deltaY
e.preventDefault()
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
}
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
scrollable.scrollLeft += e.deltaX
e.preventDefault()
p = p.parentElement
}
}
/** Auto-focus first main container on route change (game-style: always have something selected) */
// ─── Auto-Focus on Route Change ────────────────────────────
function autoFocusMain() {
// Don't steal focus from inputs or modals
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
if (document.querySelector('[role="dialog"]')) return
requestAnimationFrame(() => {
const mainZone = document.querySelector('[data-controller-zone="main"]')
if (!mainZone) return
const firstContainer = mainZone.querySelector<HTMLElement>('[data-controller-container]')
if (firstContainer) {
firstContainer.focus({ preventScroll: true })
}
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, () => {
// Small delay to let Vue render the new route's DOM
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(handleGamepadInput, 500)
// Initial auto-focus after mount
pollIntervalId = setInterval(() => checkGamepads(), 500)
setTimeout(autoFocusMain, 300)
})
@ -543,8 +530,5 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
if (keyNavTimeout) clearTimeout(keyNavTimeout)
})
return {
isControllerActive,
gamepadCount,
}
return { isControllerActive, gamepadCount }
}

View File

@ -27,7 +27,8 @@
overflow: hidden;
z-index: 9999;
}
.skip-to-content:focus {
.skip-to-content:focus,
.skip-to-content:focus-visible {
position: fixed;
top: 12px;
left: 50%;
@ -44,14 +45,32 @@
font-weight: 500;
text-decoration: none;
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 {
outline: none;
border-color: rgba(251, 146, 60, 0.8) !important;
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
box-shadow:
0 0 0 1px rgba(251, 146, 60, 0.4),
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 */
@ -99,13 +118,15 @@ input[type="radio"]:active + * {
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 {
transform: scale(1.02);
outline: none;
transform: scale(1.01) translateZ(0);
box-shadow:
0 0 0 2px rgba(251, 146, 60, 0.7),
0 0 24px rgba(251, 146, 60, 0.2),
inset 0 0 24px rgba(255, 255, 255, 0.03);
0 0 0 1px rgba(251, 146, 60, 0.35),
0 4px 20px rgba(251, 146, 60, 0.12),
0 0 40px rgba(251, 146, 60, 0.06),
inset 0 1px 0 rgba(251, 146, 60, 0.1);
}
/* Global glassmorphism utilities */
@ -977,11 +998,9 @@ input[type="radio"]:active + * {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.sidebar-nav-item:focus-visible {
transform: scale(1.02) !important;
box-shadow:
0 0 0 2px rgba(251, 146, 60, 0.7),
0 0 24px rgba(251, 146, 60, 0.2),
inset 0 0 24px rgba(255, 255, 255, 0.03) !important;
outline: none !important;
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
}
}
@ -1303,7 +1322,8 @@ html:has(body.video-background-active)::before {
background: rgba(255, 255, 255, 0.1);
}
.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 {
@ -1481,7 +1501,8 @@ html:has(body.video-background-active)::before {
transform: translateY(0);
}
.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 {

View File

@ -1,7 +1,7 @@
<template>
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- 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 -->
<div class="bg-perspective-container">
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
@ -126,7 +126,6 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
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 '@/views/dashboard/dashboard-styles.css'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
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]">
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
</div>
</div>
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
</div>
</template>
<script setup lang="ts">

View File

@ -346,7 +346,7 @@ function truncatePubkey(hex: string | null): string {
<!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- 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-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
<h2 class="mesh-section-title">Device</h2>
@ -429,7 +429,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- 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>
<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>
<!-- 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 class="mesh-chat-empty-icon">&#x1F4E1;</div>
<p>Select a peer or channel to chat</p>

View File

@ -1,6 +1,6 @@
<template>
<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-2xl font-bold text-white">{{ nodeCount }}</p>
<p class="text-xs text-white/40">
@ -8,22 +8,22 @@
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
</p>
</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-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p>
</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-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</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-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</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-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>

View File

@ -114,7 +114,7 @@ init()
</div>
<!-- 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) -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
<div class="flex items-center gap-3 mb-2">

View File

@ -48,7 +48,7 @@ async function performFactoryReset() {
<template>
<!-- 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>
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
@ -64,7 +64,7 @@ async function performFactoryReset() {
</div>
<!-- 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>
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
@ -109,7 +109,7 @@ async function performFactoryReset() {
</Teleport>
<!-- 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>
<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.

View File

@ -128,7 +128,7 @@ loadTotpStatus()
<template>
<!-- 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 gap-3">
<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: {
enabled: true,
type: 'module'
enabled: false,
}
})
],