feat: gamepad navigation rewrite, focus styling, container grid system

- Rewrite useControllerNav.ts with clean console-style navigation:
  Sidebar (up/down wrap, right→containers, left→nothing),
  Container tile grid (spatial nav, no wrap at edges),
  Nav bar support (up from containers, down to grid),
  Inner controls (enter drills in, escape exits, trapped arrows)
- Add data-controller-container to Mesh, Fleet, Settings pages
- Fix Home.vue fragment (modals outside root div) causing Vue warnings
- Remove skip-to-content link (handled by controller nav)
- Orange ambient glow focus styling matching glass aesthetic
- Disable PWA service worker in dev mode (fixes HMR caching)
- Add gamepad-nav skill and GAMEPAD-NAV-MAP.md spec document
- 39 tests covering all navigation patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-28 17:01:17 +00:00
co-authored by Claude Opus 4.6
parent 1444bcb0c4
commit aada19754d
13 changed files with 1327 additions and 772 deletions
+1 -4
View File
@@ -1,7 +1,7 @@
<template>
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
<!-- Skip to main content link for keyboard users -->
<a href="#main-content" class="skip-to-content">{{ t('common.skipToContent') }}</a>
<!-- Skip-to-content handled by controller nav sidebarmain transition -->
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
<div class="bg-perspective-container">
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
@@ -126,7 +126,6 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import AppSession from '@/views/AppSession.vue'
@@ -140,8 +139,6 @@ import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
import '@/views/dashboard/dashboard-styles.css'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
+5 -5
View File
@@ -216,12 +216,12 @@
<div v-if="uiMode.isChat" class="flex flex-col items-center justify-center min-h-[40vh]">
<RouterLink to="/dashboard/chat" class="glass-button rounded-lg px-8 py-4 text-lg font-medium">{{ t('home.openAI') }}</RouterLink>
</div>
</div>
<!-- Wallet Modals -->
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
<!-- 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">
+3 -3
View File
@@ -346,7 +346,7 @@ function truncatePubkey(hex: string | null): string {
<!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- Device Status -->
<div class="glass-card mesh-status-card">
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
<div class="mesh-status-header">
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
<h2 class="mesh-section-title">Device</h2>
@@ -429,7 +429,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- Peers list -->
<div class="glass-card mesh-peers-card">
<div data-controller-container tabindex="0" class="glass-card mesh-peers-card">
<h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
@@ -512,7 +512,7 @@ function truncatePubkey(hex: string | null): string {
</div>
<!-- Chat Panel -->
<div v-if="showChatPanel" class="glass-card mesh-chat-card">
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
<div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div>
<p>Select a peer or channel to chat</p>
@@ -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">