# D-Pad Focus Audit Methodology (Full-Direction)

## Scope

Trace every `focusProperties.{up/down/left/right}`, `onPreviewKeyEvent`, `onKeyEvent`, `focusGroup`, `focusRestorer`, and `FocusRequester` across ALL screens and infrastructure files.

## Audit Steps

### 1. Discover all focus files
```bash
find /path/to/project -name "*.kt" | xargs grep -l "focusProperties\|focusable\|onKeyEvent\|onPreviewKeyEvent\|FocusRequester\|focusGroup\|focusRestorer" | sort
```

### 2. Read core infrastructure
- `TvFocusController.kt` — zone state machine
- `TvFocusState.kt` — Saver, scroll positions  
- `TvFocusZone.kt` — all 11 zones
- `TvFocusModifiers.kt` — `tvFocusableCard`, `blockContentFocusWhenInactive`
- `FocusUtils.kt` — `rememberTvFocusState`
- `FocusLinks.kt` — helper data class (often unused — check if dead)
- `FocusRegistry.kt` / `FocusGraph.kt` — cross-screen restoration
- `FocusVelocityTracker.kt` — fast-scroll detection
- `AppFocusMemory.kt` / `HomeScreenFocusState.kt` — persisted state

### 3. Build directional matrix for each screen
For every focusable element, answer:

| Element | UP | DOWN | LEFT | RIGHT |
|---|---|---|---|---|
| `Element[i]` | target | target | target | target |

Mark: `target` = FocusRequester name, `Cancel` = hard stop, `—` = 2D spatial search fallback.

### 4. Trace dispatch order
For each element, check ALL THREE layers:
1. `focusProperties.{dir}` — focus system (resolves BEFORE key events)
2. `onPreviewKeyEvent` — fires root→leaf (parent→child)
3. `onKeyEvent` — fires leaf→root (child→parent)

⚠️ **If onPreviewKeyEvent ALSO calls requestFocus() for the same direction as focusProperties, it's redundant but harmless.** The focus system already moved focus in step 1.

### 5. Check zone transitions
For each D-pad move that crosses a TvFocusZone boundary:
- Does the controller's `contentFocusAllowed()` change?
- Is there a `blockContentFocusWhenInactive` in the path?
- Does `onFocusChanged` on the content Box correctly close overlays?

### 6. Focus restoration
- `rememberSaveable` with custom Saver (TvFocusState)
- Per-zone `lastFocusedKeyPerZone` + `lastScrollPerZone`
- `requestFocusAfterFrames()` pattern with frame wait + retry

## Key Pitfalls (from real bugs)

### P1: Parent focusProperties on non-focusable ancestor do NOT cascade
```kotlin
// WRONG — Row is NOT focusable, this does nothing for children
Row(modifier = Modifier.focusProperties { up = sidebarRequester }) {
    Button(modifier = Modifier.focusProperties { down = target }) { }  // No UP here!
}

// CORRECT — each focusable child needs its own directional targets
Row {
    Button(modifier = Modifier.focusProperties { up = sidebarRequester; down = target }) { }
}
```

### P2: Dual UP/DOWN handling (focusProperties + onPreviewKeyEvent.requestFocus)
```kotlin
// PremiumContentCard.kt pattern:
.focusProperties {
    links.up?.let { up = it }         // Focus system handles UP
}
.onPreviewKeyEvent {
    if (DirectionUp) {
        links.up?.requestFocus()      // Redundant — focus already moved
        return@onPreviewKeyEvent links.up != null
    }
}
```
Focus system processes before key events. The `onPreviewKeyEvent` handler is dead code for UP/DOWN. Harmless but misleading.

### P3: 1-frame focus-on-hidden-element window
When a hidden sidebar becomes visible via D-pad from content:
1. Focus system moves to `sidebarFocusRequester` (sidebar not yet composed)
2. `onPreviewKeyEvent` fires → opens sidebar
3. Next frame: sidebar composed, focus restored via `LaunchedEffect(expanded)`

Acceptable for most devices. On slow hardware this 1-frame gap can cause focus loss.

### P4: FocusRequesters recreated on data change → focus lost
```kotlin
// WRONG — new map every time rowFocusSpecs changes
val rowCardRequesters = remember(rowFocusSpecs) {
    rowFocusSpecs.associate { (key, size) -> key to List(size) { FocusRequester() } }
}

// CORRECT — preserve existing requesters
val rowCardRequesters: Map<String, List<FocusRequester>> = remember { mutableMapOf() }
remember(rowFocusSpecs) {
    val map = rowCardRequesters as MutableMap
    val validKeys = rowFocusSpecs.map { it.first }.toSet()
    map.keys.retainAll(validKeys)
    rowFocusSpecs.forEach { (key, size) ->
        val existing = map[key]
        if (existing == null || existing.size != size) {
            map[key] = List(size) { FocusRequester() }
        }
    }
}
```

### P5: Grid column index math brittle
When using `LazyVerticalGrid` with `GridCells.Adaptive()`, the actual column count depends on available width. Wiring focus with a static `gridColumns` value may mismatch. Always coerce with safety check: `coerceAtLeast(1)`.

### P6: Settings Right-preview must consume or focus splits
```kotlin
// WRONG — returns false, event propagates past handler
onPreviewKeyEvent {
    if (DirectionRight) { onMoveToDetail(); false }
}

// CORRECT — returns true, event consumed
onPreviewKeyEvent {
    if (DirectionRight) { onMoveToDetail(); true }
}
```

## Web Research Sources for Audits
- https://www.cosmiclearn.com/googletv/dpad-navigation.php — D-pad mechanics, focusProperties examples
- https://composables.com — focusProperties, focusRestorer, focusable API
- https://slack-chats.kotlinlang.org/t/28562097 — TV LazyRow focus management
- https://github.com/souravnoobcoder/roku-focus-list — Fixed-focus implementation reference
- https://medium.com/@prahaladsharma4u/android-tv-d-pad-navigation-handling-jetpack-compose-part-5 — D-pad handling guide
