# TV Onboarding UX Patterns for Android TV (Compose)

Patterns discovered and battle-tested on NexStream onboarding redesign (2026-05-22).
These apply to any Android TV onboarding / setup wizard built with Jetpack Compose for TV.

## The Single-Skip Principle

**Never show two skip buttons at the same time.** This was the #1 onboarding bug.

**Broken pattern (DebridSetupCard):**
```kotlin
Row {
    Button(onClick = onSkip) { Text("Skip") }     // skip button #1
    Button(onClick = onContinue) {
        Text(if (connected) "Continue →" else "Skip →")  // skip button #2
    }
}
```
User sees `[Skip] [Skip →]` — two different skip actions, no idea which does what.

**Fixed pattern:**
```kotlin
Row {
    Button(onClick = onSkip) { Text("Skip") }      // ALWAYS present, only one
    if (state == "connected") {
        Button(onClick = onContinue) { Text("Continue →") }  // only when completed
    }
}
```
Single skip, Continue only visible when the step is actually complete.

**Rule:** If step is incomplete → show only [Skip]. If step is complete → show [Skip] + [Continue →].

## Strong Selection States for TV (Visible from Couch)

On a TV 10 feet away, subtle border/background changes are invisible. Every interactive element needs:

### Pattern: 2dp Border + AccentSoft Tint + Checkmark Badge

```kotlin
val bgColor = if (isSelected) AccentSoft else SurfaceTop   // red tint vs neutral
val borderColor = if (isSelected) Accent else TextDisabled  // red vs gray
val borderWidth = if (isSelected) 2.dp else 1.dp            // thick vs thin

Surface(
    onClick = onClick,
    colors = ClickableSurfaceDefaults.colors(
        containerColor = bgColor,
        focusedContainerColor = if (isSelected) bgColor else Surface, // lift on D-pad focus
    ),
    border = ClickableSurfaceDefaults.border(
        focusedBorder = Border(BorderStroke(2.dp, Accent)),  // D-pad focus ring
    ),
) {
    Box(
        modifier = Modifier
            .border(borderWidth, borderColor, RoundedCornerShape(16.dp)),
    ) {
        // Content + optional ✓ badge
        if (isSelected) {
            Text("✓", fontSize = 16.sp, color = Accent,
                modifier = Modifier.align(Alignment.TopEnd))
        }
    }
}
```

**Key points:**
- `containerColor` shifts between neutral and tinted — different enough to see
- `focusedContainerColor` slightly lifts the card when D-pad is on it
- 2dp `focusedBorder` = visible ring from across the room
- Checkmark badge reinforces "this is selected"
- All three signals work in parallel — if one fails (e.g., TV color accuracy), the other two still communicate

### Service Option Card Pattern (Step Overview)

For overview screens where each card is a gateway to a sub-step:

```kotlin
Surface(
    onClick = onAction,
    modifier = Modifier.height(132.dp),
    colors = ClickableSurfaceDefaults.colors(
        containerColor = SurfaceHigh,
        focusedContainerColor = AccentSoft,          // red tint on D-pad focus
    ),
    border = ClickableSurfaceDefaults.border(
        focusedBorder = Border(BorderStroke(2.dp, Accent)),
    ),
    shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(16.dp)),
) {
    Row {
        // Emoji icon (40sp)
        // Title + "why this matters" description
        // Status badge: "✅ Connected" / "❌ Failed" / "Set up →"
    }
}
```

The status badge uses color coding:
- Connected → Success green bg at 15% opacity + green text
- Failed → Error red bg at 15% opacity + red text  
- Pending → Accent red bg at 15% opacity + accent text

## Netflix-Style Progress Bar

Replace tiny step dots with a thin animated progress bar:

```kotlin
@Composable
fun StepProgressBar(currentStep: Int, totalSteps: Int) {
    val progress by animateFloatAsState(
        targetValue = (currentStep + 1).toFloat() / totalSteps.toFloat(),
        animationSpec = tween(300),
    )
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Box(
            Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp))
                .background(TextDisabled),  // track
        ) {
            Box(
                Modifier.fillMaxWidth(progress).height(3.dp)
                    .clip(RoundedCornerShape(2.dp)).background(Accent),  // fill
            )
        }
        Spacer(Modifier.height(8.dp))
        Text("${currentStep + 1} / $totalSteps", fontSize = 13.sp, color = TextTertiary)
    }
}
```

**Why better than dots:**
- 3dp height vs 8dp dots — thinner, cleaner, more Netflix
- Animated fill gives clear direction (left → right)
- "3/5" label is unambiguous
- Works at any width — just set `fillMaxWidth(fraction)`

## Informative "Why This Matters" Copy

Every service setup step should explain WHAT it does and WHY the user cares:

**Before (uninformative):**
> "Get buffer-free 4K streams with cached debrid links."

**After (informative):**
> "Debrid services give you access to premium, cached 4K streams through private servers. No buffering, no ISP throttling, no waiting. Works with any debrid-compatible source."

**Template:**
```
[What it is]. [How it works in plain language]. [What YOU get from it].
```

## Onboarding Step Structure

```
Step 0: Welcome
  - App branding
  - One-sentence value proposition
  - "What [app] does" — 3 bullet points
  - [Get Started] (primary) + [Skip Setup] (secondary)

Steps 1-N: Service/Setup Steps
  - Title + "Why this matters" explanation
  - Interactive content (cards, pickers, inputs)
  - Bottom: [Skip] + [Continue] (Continue only when step complete)

Final Step: Save/Commit
  - [Skip] + [Save & Continue →]
```

## Related Traps (in android-project-build/SKILL.md)

- **Trap 26** — TV `ButtonDefaults.colors()` needs explicit `focusedContainerColor`
- **Trap 31** — LazyColumn single-item wrapper breaks D-pad focus
- **Trap 32** — Empty `onClick = { }` on Surface swallows TV D-pad Select
- **Trap 33** — Hardcoded FocusRequester on one button steals D-pad focus from adjacent buttons
