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:
parent
1444bcb0c4
commit
aada19754d
114
.claude/skills/gamepad-nav/SKILL.md
Normal file
114
.claude/skills/gamepad-nav/SKILL.md
Normal 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)
|
||||||
277
neode-ui/docs/GAMEPAD-NAV-MAP.md
Normal file
277
neode-ui/docs/GAMEPAD-NAV-MAP.md
Normal 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
@ -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 top↔bottom, auto-navigates
|
||||||
|
* Right = jump to first container in main content
|
||||||
|
* Left = does nothing
|
||||||
|
*
|
||||||
|
* MAIN CONTENT (container tile grid):
|
||||||
|
* Arrows = move between containers spatially (the red tile grid)
|
||||||
|
* Enter = trigger container's primary action (navigate link / launch)
|
||||||
|
* Escape = back to sidebar
|
||||||
|
* Left from leftmost container = back to sidebar
|
||||||
|
*
|
||||||
|
* INSIDE CONTAINER (yellow inner controls — entered via second Enter):
|
||||||
|
* Arrows = move between inner controls spatially
|
||||||
|
* Escape = exit back to the container tile
|
||||||
|
* Cannot move to other containers without exiting first
|
||||||
|
*
|
||||||
|
* TEXT INPUTS:
|
||||||
|
* Up/Down = exit field, navigate to nearest element
|
||||||
|
* Enter = submit (click next button)
|
||||||
|
* Left/Right = cursor movement (stay in field)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 sidebar→main 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()
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">📡</div>
|
<div class="mesh-chat-empty-icon">📡</div>
|
||||||
<p>Select a peer or channel to chat</p>
|
<p>Select a peer or channel to chat</p>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -104,8 +104,7 @@ export default defineConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
type: 'module'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user