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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-30 00:04:58 +01:00
parent 967af7d96f
commit 5f481d8078
3 changed files with 326 additions and 33 deletions

View File

@ -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.
---

View File

@ -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<HTMLElement>('a[href]')
// Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLElement>('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<HTMLElement>('.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 ──────────────────────────────────────

View File

@ -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;
}
}