# Proportional Alignment & FocusRequester Audit Methodology

## When to Use This

When the user reports that D-pad UP/DOWN "jumps to wrong columns" between catalog rows, or when starting a new full-app focus audit.

## Systematic Grep-All Approach

Do NOT trace focus manually file-by-file. Use grep to find ALL alignment patterns:

```bash
# Find ALL coerceAtMost on requesters (index clamping bug)
grep -rn "coerceAtMost.*Requester\|coerceAtMost.*requester" app/src/main/java/ --include="*.kt"

# Find ALL index-based up/down requester lookups
grep -rn "up.*index.*Requester\|down.*index.*Requester" app/src/main/java/ --include="*.kt"

# Find ALL getOrNull with requesters (potential alignment issues)
grep -rn "\.getOrNull.*Requester\|\.getOrNull.*requester" app/src/main/java/ --include="*.kt"
```

## Classification Rules

| Pattern | Verdict | Reason |
|---------|---------|--------|
| `index.coerceAtMost(list.lastIndex)` | 🔴 Bug | Clamps to last card of shorter rows — jumps to wrong column |
| `index ± 1` in same list | ✅ Safe | Simple linear navigation within one list |
| `index ± columns` with `GridCells.Fixed(n)` | ✅ Safe | Columns never change on TV (fixed resolution) |
| `index ± gridColumns` with `GridCells.Adaptive()` | ⚠️ Fragile | Adaptive column count may mismatch cached `posterColumns` — prefer `GridCells.Fixed` |
| `remember(data.collection.size) { List(N) { FocusRequester() } }` | 🔴 Bug | Recreates ALL requesters on any data change — focus lost |
| `remember(rows.map { it.id }) { ... }` | 🔴 Bug | Same — all requesters recreated on data refresh |

## Fixed Proportional Alignment Pattern

Replace `coerceAtMost` with proportional position:

```kotlin
// BEFORE (bug): clamps index to target row size — jumps to wrong column
val upRequester = upCardRequesters?.getOrNull(index.coerceAtMost(upCardRequesters.lastIndex))

// AFTER: calculate % position in current row, apply same % to target row
val proportion = if (itemCount > 1) index.toFloat() / (itemCount - 1).toFloat() else 0.5f
val proportionalIndex: (List<FocusRequester>) -> FocusRequester? = { list ->
    if (list.isEmpty()) null
    else {
        val targetIdx = (proportion * (list.size - 1).coerceAtLeast(0)).roundToInt()
        list.getOrNull(targetIdx.coerceIn(0, list.lastIndex))
    }
}
val upRequester = upCardRequesters?.let(proportionalIndex)
```

Requires `import kotlin.math.roundToInt`.

## Preserve Existing Requesters Pattern

Replace `remember(key) { List(N) { FocusRequester() } }` with a mutable map:

```kotlin
// BEFORE (bug): all requesters recreated on every data refresh
val rowCardRequesters = remember(rowFocusSpecs) {
    rowFocusSpecs.associate { (key, size) -> key to List(size) { FocusRequester() } }
}

// AFTER: preserve existing, only create for new keys
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() }
        }
    }
}
```

## Known Duplicate Implementations

Unspooled has two parallel catalog row implementations that MUST be fixed together:

1. **CatalogRail.kt** — reusable composable used by `CatalogGridHost` (shared between Movies, TV Shows, My List)
2. **UnspooledHomeScreen.kt** — inline `CatalogRow` composable with duplicated focus wiring

Both had the same `coerceAtMost` bug. Fix both when either is touched.

## Files That Had This Bug (2026-06-14 audit)

| File | What | Fix |
|---|---|---|
| `CatalogRail.kt:66-67` | `coerceAtMost` on up/down | Proportional alignment |
| `UnspooledHomeScreen.kt:461-462` | Same `coerceAtMost` (duplicate inline impl) | Proportional alignment |
| `CatalogGridHost.kt:33-43` | `remember(rowFocusSpecs)` recreation | Preserve existing requesters |

## Verified as Safe

| File | Pattern | Why |
|---|---|---|
| `WatchedScreen.kt:149-150` | `index ± columns` | `GridCells.Fixed(4)` — stable |
| `CloudManagerScreen.kt:291-292` | `index ± 1` | Simple linear list |
| `PlayerSelectionPanels.kt:124-125` | `index ± 1` | Single-column panel |
| `PlayerSourceSidePanel.kt:165-167` | `index ± 1` | Single-column panel |
| `ProfileSelectScreen.kt:170` | `index ± 1` | Single Row |
| `GlassSourceFilterRow.kt:43-44` | `index ± 1` | Single Row |
| `CatalogGridHost.kt:72-73` | `getOrNull(rowIndex ± 1)` | Row-level lookup, not card |
