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.
|
||||
* - 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 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'
|
||||
@ -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
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||
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') {
|
||||
// Enter in input: click next button (submit pattern)
|
||||
e.preventDefault()
|
||||
const root = containerRef?.value ?? document
|
||||
const all = getFocusableElements(root)
|
||||
const idx = all.indexOf(target as HTMLElement)
|
||||
const all = getFocusableElements(containerRef?.value ?? document)
|
||||
const idx = all.indexOf(target)
|
||||
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' })
|
||||
}
|
||||
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
|
||||
next.focus()
|
||||
next.click()
|
||||
} else if (next) {
|
||||
next.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Up/Down arrows: exit field and navigate to element above/below
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
// Up/Down: exit field, navigate spatially
|
||||
e.preventDefault()
|
||||
;(target as HTMLElement).blur()
|
||||
// Fall through to arrow key handling below
|
||||
} else if (e.key !== 'Escape') {
|
||||
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
|
||||
const candidates = getFocusableElements(containerRef?.value ?? document).filter(el => el !== target)
|
||||
const nearest = findNearestInDirection(target, candidates, dir)
|
||||
if (nearest) focusEl(nearest)
|
||||
return
|
||||
}
|
||||
// Left/Right: stay in field (cursor movement). Escape: handled below.
|
||||
if (e.key !== 'Escape') return
|
||||
}
|
||||
|
||||
const root = containerRef?.value ?? document
|
||||
const focusable = getFocusableElements(root)
|
||||
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// --- ESCAPE ---
|
||||
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
|
||||
if (e.key === 'Escape') {
|
||||
if (useAppLauncherStore().isOpen) {
|
||||
useAppLauncherStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useSpotlightStore().isOpen) {
|
||||
useSpotlightStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (useCLIStore().isOpen) {
|
||||
useCLIStore().close()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
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()
|
||||
e.preventDefault()
|
||||
// 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
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// --- ARROWS ---
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => {
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}, 3000)
|
||||
// ── ARROW KEYS ──────────────────────────────────────────
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
|
||||
e.preventDefault()
|
||||
|
||||
// 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
|
||||
}
|
||||
// Mark controller as active
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
|
||||
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' as const
|
||||
: e.key === 'ArrowRight' ? 'right' as const
|
||||
: e.key === 'ArrowUp' ? 'up' as const
|
||||
: 'down' as const
|
||||
|
||||
// ── SIDEBAR ─────────────────────────────────────────────
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const items = getSidebarElements()
|
||||
const idx = items.indexOf(activeEl)
|
||||
|
||||
if (dir === 'up' || dir === 'down') {
|
||||
// Linear wrap
|
||||
if (idx < 0) return
|
||||
const nextIdx = dir === 'down'
|
||||
? (idx >= items.length - 1 ? 0 : idx + 1)
|
||||
: (idx <= 0 ? items.length - 1 : idx - 1)
|
||||
const next = items[nextIdx]
|
||||
if (next && next !== activeEl) {
|
||||
focusEl(next)
|
||||
// Auto-navigate sidebar links
|
||||
if (next.tagName === 'A') {
|
||||
const href = (next as HTMLAnchorElement).getAttribute('href')
|
||||
if (href?.startsWith('/')) router.push(href).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 (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
|
||||
}
|
||||
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// Left from sidebar: does nothing
|
||||
return
|
||||
}
|
||||
|
||||
// No element in that direction: Left from leftmost → sidebar (focus active tab, not logout)
|
||||
if (e.key === 'ArrowLeft' && dir === 'left') {
|
||||
// ── INSIDE CONTAINER (inner controls) ───────────────────
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||
const inner = getInnerFocusables(container)
|
||||
const next = findNearestInDirection(activeEl, inner, dir)
|
||||
if (next) focusEl(next)
|
||||
// Can't leave container via arrows — must use Escape
|
||||
return
|
||||
}
|
||||
|
||||
// ── NAV BAR [N] — secondary controls above the grid ────
|
||||
if (isNavBarItem(activeEl)) {
|
||||
const navItems = getNavBarItems()
|
||||
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
// Spatial nav between nav bar items
|
||||
const next = findNearestInDirection(activeEl, navItems, dir)
|
||||
if (next) { focusEl(next); return }
|
||||
// Left from leftmost nav item → sidebar
|
||||
if (dir === 'left') {
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const 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()
|
||||
return
|
||||
}
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeTab ?? getSidebarElements()[0]
|
||||
if (target) focusEl(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Inside container: spatial nav among inner elements
|
||||
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) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
// Up from nav bar → nothing
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: linear navigation
|
||||
let nextIndex = currentIndex
|
||||
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
||||
if (focusable.length === 0) return
|
||||
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
|
||||
if (isInZone(activeEl, 'main')) {
|
||||
const containers = getContainers()
|
||||
|
||||
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]
|
||||
// Try spatial nav to another container
|
||||
const next = findNearestInDirection(activeEl, containers, dir)
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
if (e.deltaX !== 0 && scrollable.scrollWidth > scrollable.clientWidth) {
|
||||
scrollable.scrollLeft += e.deltaX
|
||||
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
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 sidebar→main 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()
|
||||
|
||||
@ -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" />
|
||||
<!-- 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">
|
||||
|
||||
@ -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">📡</div>
|
||||
<p>Select a peer or channel to chat</p>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -104,8 +104,7 @@ export default defineConfig({
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
enabled: false,
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user