From 5f481d80786131b3748a919d3ce7c90a1658f852 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 30 Mar 2026 00:04:58 +0100 Subject: [PATCH] fix: gamepad nav dead ends on Apps page, orange glass active sidebar style - Nav-tab-active now uses orange glass (bg, border, glow, gradient) - Sidebar focus-visible uses matching orange tint - Enter on containers skips uninstall button, finds primary action - Down/Right from grid edges falls back to all focusable elements - Global fallback for standalone buttons in empty/error states - Full gamepad nav map for all onboarding screens + login modes Co-Authored-By: Claude Opus 4.6 (1M context) --- neode-ui/docs/GAMEPAD-NAV-MAP.md | 283 +++++++++++++++++-- neode-ui/src/composables/useControllerNav.ts | 51 +++- neode-ui/src/style.css | 25 +- 3 files changed, 326 insertions(+), 33 deletions(-) diff --git a/neode-ui/docs/GAMEPAD-NAV-MAP.md b/neode-ui/docs/GAMEPAD-NAV-MAP.md index 7a770bdf..c37f6d46 100644 --- a/neode-ui/docs/GAMEPAD-NAV-MAP.md +++ b/neode-ui/docs/GAMEPAD-NAV-MAP.md @@ -96,15 +96,15 @@ 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 | +| Position | Up | Down | Left | Right | Enter | +|----------------|------------------|-----------|-----------|----------|-----------------| +| App1 (row 1) | [N] bar My Apps | App4 | Sidebar | App2 | Launch app | +| App2 (row 1) | [N] bar My Apps | App5 | App1 | App3 | Launch app | +| App3 (row 1) | [N] bar My Apps | 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) @@ -249,19 +249,270 @@ Row 5: [C] Danger Zone ## LOGIN `/login` -No sidebar, no grid. Simple form. +No sidebar, no grid. Three modes on the same route. +`[B]` = Button `[I]` = Input field `[L]` = Link -| Position | Up/Down | Enter | -|------------|----------------|----------| -| Password | Login button | Submit | -| Login | Password | Submit | +### Set Password (first visit after onboarding) + +Auto-focus: `[I] Password` + +``` +[I] Password +[I] Confirm Password +[B] Set Password +[L] Replay Intro [L] Restart Onboarding +``` + +| Position | Up | Down | Left | Right | Enter | +|-----------------------|---------------------|---------------------|-------------------|---------------------|--------------------| +| [I] Password | nothing | [I] Confirm | nothing | nothing | Type / Down | +| [I] Confirm | [I] Password | [B] Set Password | nothing | nothing | Type / Down | +| [B] Set Password | [I] Confirm | [L] Replay Intro | nothing | nothing | Submit | +| [L] Replay Intro | [B] Set Password | nothing | nothing | [L] Restart | Replay intro | +| [L] Restart | [B] Set Password | nothing | [L] Replay Intro | nothing | Restart onboarding | + +### Normal Login + +Auto-focus: `[I] Password` + +``` +[I] Password +[B] Login +[L] Replay Intro [L] Restart Onboarding +``` + +| Position | Up | Down | Left | Right | Enter | +|-----------------------|------------------|------------------|-------------------|---------------------|---------------| +| [I] Password | nothing | [B] Login | nothing | nothing | Type / Down | +| [B] Login | [I] Password | [L] Replay Intro | nothing | nothing | Submit | +| [L] Replay Intro | [B] Login | nothing | nothing | [L] Restart | Replay intro | +| [L] Restart | [B] Login | nothing | [L] Replay Intro | nothing | Restart | + +### TOTP Verification (after password accepted) + +Auto-focus: `[I] TOTP Code` + +``` +[I] TOTP Code +[B] Verify +[L] Use Backup Code +``` + +| Position | Up | Down | Left | Right | Enter | +|-----------------------|------------------|------------------|---------|---------|--------------------| +| [I] TOTP Code | nothing | [B] Verify | nothing | nothing | Type / Down | +| [B] Verify | [I] TOTP Code | [L] Backup Code | nothing | nothing | Submit | +| [L] Use Backup Code | [B] Verify | nothing | nothing | nothing | Toggle backup mode | --- ## ONBOARDING `/onboarding/*` -No sidebar, no grid. Sequential screens. -Button auto-focused. Enter advances. +No sidebar, no grid. Sequential wizard screens. +`[B]` = Button `[I]` = Input field `[C]` = Selectable card `[L]` = Link + +**Global onboarding rules:** +- No sidebar or nav bar on any onboarding screen. +- First interactive element auto-focused on each screen (inputs when present, otherwise primary button). +- B button (Escape) = go back to previous onboarding step (where applicable). +- D-pad Up/Down **always** moves between focusable elements — inputs are never trapping. Up/Down exits a focused input to the adjacent element. +- Enter on an input = submit if it's the last field, otherwise move to next field. +- Enter activates the focused element. + +--- + +### INTRO `/onboarding/intro` + +Default focus: `[B] Unlock` + +``` +[B] Unlock your sovereignty +[L] Restore from backup +``` + +| Position | Up | Down | Left | Right | Enter | +|-------------------|-----------------|-----------------|---------|---------|------------------------------| +| [B] Unlock | nothing | [L] Restore | nothing | nothing | → /onboarding/path | +| [L] Restore | [B] Unlock | nothing | nothing | nothing | Show restore panel | + +#### Restore Panel `[Y]` (shown after activating Restore link) + +``` +[I] File picker +[I] Passphrase +[B] Cancel [B] Restore +``` + +| Position | Up | Down | Left | Right | Enter | Escape | +|-------------------|-----------------|-----------------|------------|------------|--------------------|----------------| +| [I] File picker | nothing | [I] Passphrase | nothing | nothing | Open file dialog | Close panel | +| [I] Passphrase | [I] File picker | [B] Cancel | nothing | nothing | Type / Down | Close panel | +| [B] Cancel | [I] Passphrase | nothing | nothing | [B] Restore| Close panel | Close panel | +| [B] Restore | [I] Passphrase | nothing | [B] Cancel | nothing | Submit restore | Close panel | + +--- + +### PATH `/onboarding/path` + +Default focus: `[C] Fresh Start` + +``` +[C] Fresh Start [C] Restore (disabled) [C] Connect (disabled) +[B] Continue +``` + +| Position | Up | Down | Left | Right | Enter | +|---------------------|-----------------|---------------|-------------------|-------------------|------------------------| +| [C] Fresh Start | nothing | [B] Continue | nothing | [C] Restore | Select option | +| [C] Restore | nothing | [B] Continue | [C] Fresh Start | [C] Connect | nothing (disabled) | +| [C] Connect | nothing | [B] Continue | [C] Restore | nothing | nothing (disabled) | +| [B] Continue | [C] Fresh Start | nothing | nothing | nothing | → /login (complete) | + +--- + +### OPTIONS `/onboarding/options` + +Default focus: `[C] Sovereignty` + +``` +Row 1: [C] Sovereignty [C] Commerce [C] Projects +Row 2: [C] Transmitter [C] Hoster [C] AI +[B] Continue +``` + +| Position | Up | Down | Left | Right | Enter | +|---------------------|------------------|------------------|------------------|------------------|--------------------| +| [C] Sovereignty | nothing | [C] Transmitter | nothing | [C] Commerce | nothing (display) | +| [C] Commerce | nothing | [C] Hoster | [C] Sovereignty | [C] Projects | nothing (display) | +| [C] Projects | nothing | [C] AI | [C] Commerce | nothing | nothing (display) | +| [C] Transmitter | [C] Sovereignty | [B] Continue | nothing | [C] Hoster | nothing (display) | +| [C] Hoster | [C] Commerce | [B] Continue | [C] Transmitter | [C] AI | nothing (display) | +| [C] AI | [C] Projects | [B] Continue | [C] Hoster | nothing | nothing (display) | +| [B] Continue | [C] Transmitter | nothing | nothing | nothing | → /onboarding/did | + +--- + +### DID `/onboarding/did` + +**Loading state:** No interactive elements. Auto-advances when generation completes. + +**After generation:** + +Default focus: `[B] Continue` + +``` +[B] Copy DID +[B] Copy Nostr (if available) +[B] Continue +``` + +| Position | Up | Down | Left | Right | Enter | +|---------------------|------------------|------------------|---------|---------|-----------------------------| +| [B] Copy DID | nothing | [B] Copy Nostr | nothing | nothing | Copy to clipboard | +| [B] Copy Nostr | [B] Copy DID | [B] Continue | nothing | nothing | Copy to clipboard | +| [B] Continue | [B] Copy Nostr | nothing | nothing | nothing | → /onboarding/identity | + +If no Nostr ID: `[B] Copy DID` → Down → `[B] Continue` directly. + +--- + +### IDENTITY `/onboarding/identity` + +Auto-focus: `[I] Name` + +``` +[I] Identity Name +[C] Personal [C] Business [C] Anonymous +[B] Continue +``` + +| Position | Up | Down | Left | Right | Enter | +|---------------------|------------------|------------------|-----------------|-----------------|-----------------------------| +| [I] Name | nothing | [C] Personal | nothing | nothing | Type / Down | +| [C] Personal | [I] Name | [B] Continue | nothing | [C] Business | Select purpose | +| [C] Business | [I] Name | [B] Continue | [C] Personal | [C] Anonymous | Select purpose | +| [C] Anonymous | [I] Name | [B] Continue | [C] Business | nothing | Select purpose | +| [B] Continue | [C] Personal | nothing | nothing | nothing | → /onboarding/backup | + +--- + +### BACKUP `/onboarding/backup` + +Auto-focus: `[I] Passphrase` + +``` +[I] Passphrase +[B] Download Backup +[B] Continue (disabled until downloaded) +``` + +| Position | Up | Down | Left | Right | Enter | +|---------------------|------------------|------------------|---------|---------|-----------------------------| +| [I] Passphrase | nothing | [B] Download | nothing | nothing | Type / Down | +| [B] Download | [I] Passphrase | [B] Continue | nothing | nothing | Create & download backup | +| [B] Continue | [B] Download | nothing | nothing | nothing | → /onboarding/verify | + +`[B] Continue` disabled (skip focus) until backup downloaded. + +--- + +### VERIFY `/onboarding/verify` + +**Phase 1 — Signing:** + +Default focus: `[B] Sign Challenge` + +``` +[B] Sign Challenge +``` + +| Position | Up | Down | Left | Right | Enter | +|----------------------|---------|---------|---------|---------|------------------------| +| [B] Sign Challenge | nothing | nothing | nothing | nothing | Sign crypto challenge | + +**Phase 2 — After verification:** + +Default focus: `[B] Finish` + +``` +[B] Finish +``` + +| Position | Up | Down | Left | Right | Enter | +|-------------|---------|---------|---------|---------|------------------------------| +| [B] Finish | nothing | nothing | nothing | nothing | → /onboarding/done | + +--- + +### DONE `/onboarding/done` + +Default focus: `[B] Set Password` + +``` +[C] Identity [C] Backup [C] Ready +[B] Set Password +``` + +| Position | Up | Down | Left | Right | Enter | +|---------------------|--------------|------------------|---------------|---------------|----------------------| +| [C] Identity | nothing | [B] Set Password | nothing | [C] Backup | nothing (display) | +| [C] Backup | nothing | [B] Set Password | [C] Identity | [C] Ready | nothing (display) | +| [C] Ready | nothing | [B] Set Password | [C] Backup | nothing | nothing (display) | +| [B] Set Password | [C] Identity | nothing | nothing | nothing | → /login | + +--- + +## Onboarding & Login Rules + +1. No sidebar or nav bar — linear wizard flow. +2. First interactive element auto-focused (input fields when present, otherwise primary button). +3. D-pad Up/Down **always** moves between focusable elements — inputs are never trapping. You can always D-pad out of a focused field. +4. Left/Right for horizontal card rows only. +5. Disabled elements are skipped in focus order. +6. B button (Escape) navigates back one onboarding step. +7. Enter on input: submits if last field, otherwise advances to next field. +8. No wrap — edges are dead stops. +9. No dead ends — every screen has a forward action. --- diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index 169f27b8..b1297b6f 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -301,28 +301,38 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { e.preventDefault() if (isContainer(activeEl)) { - // Container has a primary action link (the > chevron)? - const primaryLink = activeEl.querySelector('a[href]') + // Prioritised action: install button if (activeEl.hasAttribute('data-controller-install')) { const btn = activeEl.querySelector('[data-controller-install-btn]:not([disabled])') if (btn) { playNavSound('action'); btn.click(); return } } + // Prioritised action: launch button if (activeEl.hasAttribute('data-controller-launch')) { const btn = activeEl.querySelector('[data-controller-launch-btn]:not([disabled])') if (btn) { playNavSound('action'); btn.click(); return } } - // Default: click the primary link to navigate to that section + // Primary link (e.g. dashboard cards with a[href]) + const primaryLink = activeEl.querySelector('a[href]') if (primaryLink) { playNavSound('action') primaryLink.click() return } - // No primary link — drill into inner controls + // Fallback: first non-disabled action button (skip uninstall/delete buttons) const inner = getInnerFocusables(activeEl) - if (inner[0]) { - focusEl(inner[0], 'action') + const actionBtn = inner.find(el => + (el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && + !el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') && + !el.closest('[class*="absolute top"]') + ) ?? inner[0] + if (actionBtn) { + focusEl(actionBtn, 'action') return } + // Last resort: click the container itself (triggers goToApp on AppCard) + playNavSound('action') + activeEl.click() + return } // Regular element: click it @@ -462,9 +472,36 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { return } - // At grid edges (down/right with no target): do nothing + // At grid edges: try all focusable elements in main zone as fallback + // (prevents dead ends when spatial nav between containers fails) + if (dir === 'down' || dir === 'right') { + const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null + if (zone) { + const allFocusable = getFocusableElements(zone) + const fallback = findNearestInDirection(activeEl, allFocusable, dir) + if (fallback) { + rememberFocus('main', fallback) + focusEl(fallback) + } + } + } return } + + // ── FALLBACK: unhandled focusable element ─────────────── + // Covers standalone buttons/links in empty/error states, modals, etc. + // that aren't inside a recognized zone or container. + if (dir === 'left') { + const sidebar = getSidebarElements() + const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]') + const activeTab = sidebarZone?.querySelector('.nav-tab-active') + const target = activeTab ?? sidebar[0] + if (target) { rememberFocus('main', activeEl); focusEl(target) } + } else { + const all = getFocusableElements() + const next = findNearestInDirection(activeEl, all, dir) + if (next) focusEl(next) + } } // ─── Gamepad Detection ────────────────────────────────────── diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 4d65ba3c..057512f8 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -967,15 +967,17 @@ input[type="radio"]:active + * { transform: translateY(1px); } - /* Active Navigation Tab Style - matches hover container */ + /* Active Navigation Tab Style — orange glass */ .nav-tab-active { position: relative; - background: rgba(0, 0, 0, 0.35) !important; + background: rgba(251, 146, 60, 0.15) !important; box-shadow: - 0 6px 16px rgba(0, 0, 0, 0.6), - inset 0 1px 0 rgba(255, 255, 255, 0.25) !important; + 0 6px 16px rgba(0, 0, 0, 0.5), + 0 0 12px rgba(251, 146, 60, 0.15), + inset 0 1px 0 rgba(251, 146, 60, 0.3) !important; color: rgba(255, 255, 255, 1) !important; font-weight: 600 !important; + border: 1px solid rgba(251, 146, 60, 0.3) !important; } .nav-tab-active::before { @@ -984,23 +986,26 @@ input[type="radio"]:active + * { inset: 0; border-radius: inherit; padding: 2px; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, + background: linear-gradient(135deg, rgba(251, 146, 60, 0.4), rgba(251, 146, 60, 0.05)); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; } - /* Sidebar nav items: grow + glow on gamepad focus (same as containers) */ + /* Sidebar nav items: grow + glow on gamepad focus */ .sidebar-nav-item { - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease; } .sidebar-nav-item:focus-visible { outline: none !important; - background: rgba(255, 255, 255, 0.1) !important; + background: rgba(251, 146, 60, 0.12) !important; color: white !important; + box-shadow: + 0 0 0 1px rgba(251, 146, 60, 0.3), + 0 0 12px rgba(251, 146, 60, 0.15) !important; } }