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:
parent
967af7d96f
commit
5f481d8078
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 ──────────────────────────────────────
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user