# YouTube-Style Sidebar Design (Unspooled)

**Last updated:** 2026-06-14
**Status:** ✅ Current design — used by `GlassSidebar.kt` (active)

## Overview

Fixed 72dp icon-only rail, black background, pure icons (no boxes), shimmer label on focus. Matches YouTube TV's navigation aesthetic.

## Visual Design

| Property | Value |
|----------|-------|
| Width | `72.dp` — fixed, never animated |
| Background | `NuvioTheme.colors.Background.copy(alpha = 0.95f)` — pure dark |
| Glass blur | Optional `hazeChild` via `HazeState` (configurable) |
| Icons | `24dp`, centered, no background box or border |
| Icon focused | `Color.White` (bright white) |
| Icon selected | `Color.White` (bright white) |
| Icon idle | `Color(0xFF6D6D6D)` (muted gray) |
| Focus animation | `animateColorAsState(tween(180))` on icon tint |
| Label | Appears below icon on focus only — 10sp, white, `AnimatedVisibility` fade+scale (150ms) |
| Label idle | Invisible — 14dp spacer maintains layout height |
| Shimmer | `Brush.linearGradient` sweeps bright highlight across text — 1.8s loop, reverse |
| Items | 8: Search, Home, Shuffle, Trending, TV, Movies, Categories, + (Settings) |
| No brand logo | Removed from top of rail |
| Audio feedback | `playFocusGain()` on every focus gain (via `FocusAudioFeedback`) |

## Shimmer Label Animation

When an icon receives D-pad focus, its label text animates in with a light-sweep effect:

```kotlin
// 1. Shimmer progress — oscillates 0f ↔ 1f continuously
val transition = rememberInfiniteTransition(label = "shimmer")
val shimmerProgress by transition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(1800, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse,
    ),
    label = "shimmerProgress",
)

// 2. Label with AnimatedVisibility (appears only on focus)
AnimatedVisibility(
    visible = focused,
    enter = fadeIn(tween(150)) + scaleIn(initialScale = 0.8f, animationSpec = tween(150)),
    exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.8f, animationSpec = tween(100)),
) {
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Text(
            text = item.label,
            style = MaterialTheme.typography.labelSmall.copy(
                fontSize = 10.sp,
                fontWeight = FontWeight.Medium,
                letterSpacing = 0.5.sp,
                brush = shimmerLabelBrush(shimmerProgress),
            ),
        )
    }
}

// 3. Shimmer brush — horizontal gradient sweeping across text
private fun shimmerLabelBrush(progress: Float): Brush {
    val sweepPx = 120f
    val offset = progress * 180f
    return Brush.linearGradient(
        colors = listOf(
            Color.White.copy(alpha = 0.35f), // dim
            Color.White.copy(alpha = 0.35f), // dim
            Color.White,                      // bright highlight
            Color.White.copy(alpha = 0.35f), // dim
            Color.White.copy(alpha = 0.35f), // dim
        ),
        start = Offset(offset - sweepPx, 0f),
        end = Offset(offset + sweepPx, 0f),
    )
}
```

**The shimmer sweep** creates a scan-line effect — a bright white band (~2 characters wide) travels continuously left-to-right-to-left across the label text at 1.8s per direction. The label is invisible when idle; the animation only runs while the item is focused.

## D-pad Behavior

- **LEFT**: `FocusRequester.Cancel` — trapped in sidebar
- **RIGHT**: → content entry via route's `entryFocusRequesterMap` (or `contentFocusRequester`)
- **UP/DOWN**: adjacent sidebar items via sequential `FocusRequester` list
- **CENTER (SELECT)**: `onClick` on `Modifier.clickable(interactionSource, indication = null)` — navigates to route
- **RIGHT key event**: `onPreviewKeyEvent` catches `DirectionRight` to request close/expand

**CRITICAL:** `.focusable()` alone is NOT enough — must also have `.clickable(interactionSource, indication = null) { onClick() }` or `Surface(onClick=...)`. Without it, the icon shows focus visuals but D-pad center does nothing (focus trap).

## Active Indicator

**Red accent bar REMOVED** (was `#E50914`, 3dp × 20-28dp, centered below icon). Replaced with **animated shimmer label** on focus — the label IS the active/focus indicator.

## Scroll

`LaunchedEffect(expanded, selectedRoute)` auto-scrolls `LazyColumn` to selected index on entry, and requests focus on that item.

## Layout Structure

```
Column(width = 72.dp, fillMaxHeight) {
    Spacer(8dp)
    LazyColumn(items = icons) {
        Column(icon = 24dp)       // no box, no background
        Spacer(6dp)
        AnimatedVisibility {     // label on focus only
            Box(fillMaxSize) {
                Text(shimmer)    // 10sp, Brush.linearGradient
            }
        }
    }
}
```

## Implementation File

`app/src/main/java/com/unspooled/app/ui/scaffold/GlassSidebar.kt`

## Pitfalls

- **`AnimatedVisibility` in `ColumnScope` must NOT be inside a child `Box`.** `ColumnScope.AnimatedVisibility` can only be called with an implicit `ColumnScope` receiver. If wrapped in a `Box()` (which has `BoxScope`), the `ColumnScope` receiver is masked. Always put `AnimatedVisibility` directly in the `Column` scope.
- **`Text(color = Brush)` does not compile.** The `color` parameter of `Text` expects `Color`, not `Brush`. Pass `Brush` via `style = ...copy(brush = shimmerLabelBrush(progress))` instead.
- **`fillMaxSize()` requires explicit import.** Not included in `*` wildcard imports from `foundation.layout`. Add `import androidx.compose.foundation.layout.fillMaxSize`.
- **`rememberTvFocusState()` destructuring ambiguity.** Use `.first`/`.second` instead of destructuring to avoid `component2()` ambiguity compile error.
