# Phase 2.1: App Shell, Navigation & Search Foundation — Research

**Researched:** 2026-05-08
**Domain:** Compose Multiplatform navigation chrome (KMP iOS-primary), renderless component foundation, Liquid-Glass surface primitive, externalized strings, search affordance state machine
**Confidence:** HIGH (locked stack; standard CMP patterns) / MEDIUM (Liquid library API surface)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Tab bar shape & chrome placement**
- **D-01:** Bottom-anchored floating pill dock implemented as a Liquid-glass capsule, centered above the safe-area inset. No edge-to-edge bottom bar.
- **D-02:** All four tabs render icon + label at all times (active and inactive). Active tab is wider and visually emphasized; inactive tabs remain readable, not icon-only.
- **D-03:** Tab order — `Planer` / `Przepisy` / `Spiżarnia` / `Zakupy`. Default landing tab on first sign-in is `Planer`.
- **D-04:** No top app bar in v1. Tab title (where useful) lives inline at the top of each screen body. All chrome is bottom-anchored.
- **D-05:** When search opens (on tabs that have search), the dock collapses to a single circular button showing only the active tab's icon (no label, slightly reduced height). Tapping it closes search and re-expands the dock. Single coordinated animation.

**Search affordance behavior**
- **D-06:** Search button per-tab, only on `Przepisy` and `Spiżarnia`. Floating circular icon adjacent to the dock (not inside it).
- **D-07:** This phase delivers open/close + query input echo + clear/close actions only. Search-surface body renders nothing (Phase 5 wires real results for Recipes; Pantry phase wires Spiżarnia).
- **D-08:** Closing the search clears the query. Reopening starts blank. No persistence across close, tab-switch, or app launch.
- **D-09:** Search is an inline bottom pill, not a full-screen sheet. Body content stays visible behind it.

**Empty state design language**
- **D-10:** Icon + headline + subline. Icon is tab-themed, low-saturation theme color. No bespoke illustrations.
- **D-11:** Anticipatory Polish tone (e.g. "Wkrótce zobaczysz tu swój plan tygodnia"). No "Brak danych", no chatty onboarding.
- **D-12:** No CTA buttons in empty states this phase.
- **D-13:** Single reusable `EmptyState(icon, title, subtitle, action?)` composable in `ui/components/`; `action` slot reserved unused this phase.

**Theme tokens + Liquid fallback**
- **D-14:** Full theme scaffold this phase — semantic colors (background, surface, surfaceGlass, content, contentMuted, accent, separator, borderCard), typography scale (display/title/body/label, two weights), spacing scale (`xs`/`sm`/`lg`/`xl`/`2xl`/`3xl` per UI-SPEC revision 1), `GlassSurface` token primitive consumed by dock + search pill + floating buttons.
- **D-15:** Both light and dark color schemes defined; system-following.
- **D-16:** `GlassSurface` is layered Liquid → Haze → flat translucent fallback chain. All three paths consume same token API (color + opacity + radius).
- **D-17:** Compile-time per-target backend selection + debug-build runtime toggle (via `multiplatform-settings`). No automatic perf detection in v1.

### Claude's Discretion
- Exact Liquid library API parameters (radius, blur amount, refraction)
- Nav graph topology (default: nested NavHosts per tab unless research blocks it — research below confirms this is correct)
- Whether to migrate Phase 2 Material 3 auth screens (default: leave as legacy)
- Specific empty-state copy strings (Phase 11 will tune; UI-SPEC has best-current values)
- Icon source (default: Material Icons Outlined)
- Animation curves and durations for search-open dock collapse (UI-SPEC suggests 250ms `FastOutSlowInEasing`)
- Accessibility specifics (Role.Tab, focus order)
- Whether to expose runtime fallback toggle as in-app debug affordance or build flag

### Deferred Ideas (OUT OF SCOPE)
- Per-tab/scroll-state dock collapse independent of search → Phase 10
- Profile/settings entry point in chrome → Phase 3+
- Cross-tab CTAs in empty states → feature phases
- Custom illustrations for empty states
- Material 3 migration of Phase 2 auth screens
- Runtime perf auto-downgrade for GlassSurface → Phase 10
- Persisting search query across sessions
- Real-device Liquid tuning (refraction, specular) → Phase 10
- Localization (full Polish copy pass) → Phase 11
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| UI-03 | Bottom tab navigation with 4 tabs (Przepisy/Planer/Spiżarnia/Zakupy), each preserving its own back stack independently | § Architecture Pattern 1 (nested NavHost per tab) + § Standard Stack (`navigation-compose 2.9.x`) + Pitfall 13 (`when`-switch tabs lose back stack) |
| UI-04 | App chrome and primary icon buttons use chosen Liquid-Glass approximation, starting with Liquid library for menu/search controls | § Architecture Pattern 3 (`GlassSurface` primitive) + § Liquid Library Integration |
| UI-09 | App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog/plan/pantry/shopping are empty | § Architecture Pattern 4 (`EmptyState` reusable composable) + § Code Examples |
| UI-10 | Main app search affordance functional before catalog data exists: search opens, query state updates, clear/close work, no-results state is deliberate | § Architecture Pattern 2 (search state machine) + § SearchPill structure |
</phase_requirements>

## Project Constraints (from CLAUDE.md)

- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port). No alternative.
- ViewModel + StateFlow, method-per-action.
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`). `koinViewModel()` everywhere.
- Components: Composables.com / Compose Unstyled — DO NOT expand around Material 3. Material 3 stays only as legacy auth scaffold.
- Glass: Liquid first; Haze fallback only.
- Strings externalized day 1 (Polish content, multi-locale-ready resources). NO hardcoded literals.
- iOS-primary, Android secondary; no Desktop/Wasm targets in v1.
- iOS K/N flags: `objcDisposeOnMain=false`, `gc=cms` (already set Phase 1).
- `shared/commonMain` stays light — no UI/Ktor/SQLDelight imports.
- Glass effects on chrome only (PITFALLS Pitfall 5/12); never over scrolling content.
- Package layout: `dev.ulfrx.recipe.{app,navigation,ui.{theme,components,screens.{recipes,planner,pantry,shopping}},...}`.

---

## Summary

Phase 2.1 replaces the post-login placeholder with the real four-tab app shell. Three load-bearing pieces:

1. **Navigation:** Single root `NavHost` containing four `navigation(...)` sub-graphs (one per tab) using `org.jetbrains.androidx.navigation:navigation-compose` 2.9.x (CMP port). Bottom-tab reselection uses `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true` so each tab's back stack survives switching. Routes are `@Serializable` `data object` / `data class` per JetBrains type-safe routing. ViewModels per tab area are scoped to the parent nav-graph `NavBackStackEntry` via `koinViewModel(viewModelStoreOwner = parentEntry)`.

2. **Component foundation:** `compose-unstyled` (`com.composables:composeunstyled:1.49.x`) provides renderless primitives for `TabGroup`, `Button`, `TextField`, `Modal`/`BottomSheet`. Recipe-styled components in `ui/components/` consume those primitives and apply `RecipeTheme` tokens. Material 3 imports are confined to `ui/screens/auth/*` (legacy).

3. **Glass surface:** `GlassSurface` primitive in `ui/components/glass/` with three backends — Liquid (`io.github.fletchmckee.liquid:liquid:1.1.1`, modifier `liquid(state)` + `liquefiable(state)`), Haze (`dev.chrisbanes.haze:haze:1.x`), and flat translucent. Backend selection is compile-time per-target (Gradle source-set wiring) plus a debug-build runtime override stored in `multiplatform-settings`. Liquid is preferred on iOS+Android; Haze is the secondary blur path; flat is last resort.

**Primary recommendation:** Build top-down — root `AppShell` composable hosting one CMP `NavHost` with four `navigation()` sub-graphs, bottom dock + floating search button as overlay, per-tab `koinViewModel()` scoped to parent graph entry, all glass effects funneled through `GlassSurface`. Strings always via `stringResource(Res.string.*)` against `composeResources/values/strings.xml`. No `androidx.compose.material3.*` imports outside `ui/screens/auth/`.

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Tab navigation + back stacks | KMP client (Compose UI) | — | Pure client UX; no server interaction |
| Search affordance state | KMP client (per-tab ViewModel) | — | Local UI state; no persistence (D-08) |
| Theme tokens / `RecipeTheme` | KMP client (ui/theme) | — | Renders identically across platforms |
| Liquid/Haze/flat backend selection | KMP client (compile-time per Kotlin source set) | Runtime debug toggle | Per-platform shader capability |
| Empty-state copy | KMP resources (`composeResources/values/strings.xml`) | Phase 11 localization | Resource-keyed; copy may tune later |
| Auth gate (still upstream of shell) | KMP client (App.kt observes `AuthSession`) | — | Unchanged from Phase 2; shell sits downstream |

No server changes in this phase. No `shared/commonMain` changes (UI is client-only).

---

## Standard Stack

### Core (already in `gradle/libs.versions.toml` or to add)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `org.jetbrains.androidx.navigation:navigation-compose` | **2.9.2** (latest as of 2026-05-08) — currently NOT in catalog; **add** | CMP-official navigation; type-safe routes; multi-back-stack support | JetBrains-official port of Jetpack Navigation; locked in CLAUDE.md |
| `androidx-lifecycle-viewmodelCompose` | 2.10.0 (already in catalog) | `ViewModel` + `viewModelScope` in commonMain | Already locked Phase 2 |
| `koin-compose` / `koin-composeViewmodel` | 4.2.1 (already in catalog) | `koinViewModel()`, `koinInject()` | Already locked |
| `compose-components-resources` | 1.10.3 (already in catalog) | `Res.string.*`, `stringResource()` | CMP standard for strings |
| `androidx-compose-material-icons-extended` | n/a — needs investigation; CMP equivalent is via `compose-material-icons-core` or use `material3` icons (already pulled by Phase 2 auth scaffold) | Outlined icon set for tabs + empty states | UI-SPEC selected `Icons.Outlined.*` | [VERIFIED: UI-SPEC + libs.versions.toml] |

> **Material Icons in CMP caveat:** the JetBrains CMP `material3` artifact (already in catalog) bundles a baseline icon set, but `Icons.Outlined.MenuBook` / `Icons.Outlined.Inventory2` / `Icons.Outlined.CalendarMonth` / `Icons.Outlined.ShoppingCart` are in the **extended** icon set. CMP exposes this via `org.jetbrains.compose.material:material-icons-extended` (or pulls them transitively from `material3`). **Plan needs to verify** whether the four icons referenced in UI-SPEC are available without adding `material-icons-extended`, and add the dependency if not. [ASSUMED — needs Wave-0 verify step]

### Add to catalog

| Coordinate | Version | Purpose |
|------------|---------|---------|
| `org.jetbrains.androidx.navigation:navigation-compose` | 2.9.2 | CMP nav host + bottom-tab multi-back-stack [VERIFIED: Maven Central / kotlinlang.org] |
| `com.composables:composeunstyled` | 1.49.x (1.49.9 latest seen) | Renderless primitives (TabGroup, Button, TextField, Modal, BottomSheet) [VERIFIED: composables.com docs] |
| `io.github.fletchmckee.liquid:liquid` | 1.1.1 | Liquid Glass shader for chrome [VERIFIED: Maven Central central.sonatype.com] |
| `dev.chrisbanes.haze:haze` | 1.x stable (1.6+ as of early 2026) — confirm at planning time | Fallback blur surface [VERIFIED: chrisbanes.github.io/haze/ — Haze 2.0-alpha01 released 2026-04-29; stick to 1.x stable for production] |

### Already present, used as-is

`koin-bom`, `koin-core`, `koin-compose`, `koin-composeViewmodel`, `kermit`, `compose-runtime`, `compose-foundation`, `compose-material3` (legacy boundary), `compose-ui`, `compose-components-resources`, `androidx-lifecycle-viewmodelCompose`, `androidx-lifecycle-runtimeCompose`, `multiplatform-settings`.

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `navigation-compose` (CMP port) | Decompose, Voyager | Both are popular but **locked away by CLAUDE.md** — JetBrains CMP nav is the canonical choice |
| Compose Unstyled | Roll our own renderless layer | Hand-rolling means re-implementing focus/a11y/keyboard/state semantics. Compose Unstyled exists for this exact reason |
| Liquid (RuntimeShader) | Native SwiftUI material via interop | Native interop is v2 (LG2-01); Liquid is the v1 approximation per PROJECT.md |
| Haze fallback | Skip middle tier (Liquid → flat) | CONTEXT D-16 explicitly chose three-tier — middle quality matters when Liquid fails on a target but blur still works |

### Installation

Add to `gradle/libs.versions.toml`:
```toml
[versions]
navigation-compose = "2.9.2"
compose-unstyled = "1.49.9"
liquid = "1.1.1"
haze = "1.6.10"  # confirm latest 1.x stable at planning time

[libraries]
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
```

Then in `composeApp/build.gradle.kts` `commonMain.dependencies`:
```kotlin
implementation(libs.navigation.compose)
implementation(libs.compose.unstyled)
implementation(libs.liquid)
implementation(libs.haze)
```

**Version verification step (Wave 0):** before locking, run `./gradlew dependencies --configuration commonMainRuntimeClasspath | grep -E "(navigation-compose|composeunstyled|liquid|haze)"` to confirm resolution succeeds for both `iosArm64` and `iosSimulatorArm64`. [ASSUMED — Liquid 1.1.1 ships iOS klibs based on Maven Central listing of `liquid-iossimulatorarm64` artifact, but the published target matrix is not enumerated on the package page. Wave 0 must confirm.]

---

## Architecture Patterns

### System Architecture Diagram

```
                             ┌─────────────────────────┐
                             │   App() (App.kt)        │
                             │   observes AuthSession  │
                             └──────────┬──────────────┘
                                        │
                       AuthState.Authenticated + currentUser != null
                                        │
                                        ▼
                       ┌──────────────────────────────────┐
                       │  AppShell (ui/screens/shell/)    │
                       │  - hosts root NavController      │
                       │  - renders DockBar overlay       │
                       │  - renders FloatingSearchButton  │
                       │  - hosts SearchPill when open    │
                       └──────────────────┬───────────────┘
                                          │
                                          ▼
                  ┌─────────────────── NavHost ────────────────────┐
                  │                                                │
                  │  navigation(route="planner_graph",             │
                  │      startDest=PlannerHome)  ──► PlannerScreen │
                  │  navigation(route="recipes_graph", ...)        │
                  │      startDest=RecipesHome   ──► RecipesScreen │
                  │  navigation(route="pantry_graph", ...)         │
                  │      startDest=PantryHome    ──► PantryScreen  │
                  │  navigation(route="shopping_graph", ...)       │
                  │      startDest=ShoppingHome  ──► ShoppingScreen│
                  └────────────────────────────────────────────────┘
                                          │
                                          ▼
                       Each *Screen consumes a koinViewModel<*VM>(
                          viewModelStoreOwner = parentNavGraphEntry)
                       so survival across tab reselection works.

  Search overlay (only on recipes_graph + pantry_graph):
       FloatingSearchButton tap
              │
              ▼
       AppShell.searchOpen=true
       (per-active-tab SearchViewModel)
              │
              ├─► DockBar collapses (single coordinated animation)
              ├─► FloatingSearchButton hides
              └─► SearchPill renders inline at bottom
                       (TextField → SearchViewModel.onQueryChange)
                       (clear → query=""; close → searchOpen=false, query="")

  GlassSurface(...) [used by DockBar, FloatingSearchButton, SearchPill]
       │
       ├── compile-time backend per target:
       │    iosArm64/iosSimulatorArm64/android → LiquidBackend (default)
       │    fallback constellation → HazeBackend
       │    fallback constellation → FlatBackend
       │
       └── debug-build override via multiplatform-settings key
            "debug.glass_backend" ∈ {liquid, haze, flat}
```

### Recommended Project Structure

```
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/
├── app/                                # (future) — App() may move here later; out of scope
├── navigation/
│   ├── Routes.kt                       # @Serializable data object/class for every destination
│   ├── RootNavHost.kt                  # NavHost containing 4 nested navigation() blocks
│   └── BottomBarDestination.kt         # enum or sealed of (Planner, Recipes, Pantry, Shopping)
├── ui/
│   ├── theme/
│   │   ├── RecipeTheme.kt              # extended: hosts CompositionLocal scaffold (D-14, D-15)
│   │   ├── RecipeColors.kt             # data class + Light/Dark instances (D-15)
│   │   ├── RecipeTypography.kt         # display/title/body/label (D-14)
│   │   ├── RecipeSpacing.kt            # xs/sm/lg/xl/2xl/3xl (UI-SPEC rev 1)
│   │   ├── RecipeShapes.kt             # pill / circle radii
│   │   └── RecipeGlass.kt              # GlassSurface params (tint, opacity, blur, border)
│   ├── components/
│   │   ├── glass/
│   │   │   ├── GlassSurface.kt         # public API; commonMain
│   │   │   └── GlassBackend.kt         # expect/actual or commonMain abstraction
│   │   ├── dock/
│   │   │   ├── DockBar.kt              # 4-tab pill; collapses on searchOpen
│   │   │   └── FloatingSearchButton.kt # adjacent circular button
│   │   ├── search/
│   │   │   └── SearchPill.kt           # inline bottom search input
│   │   └── empty/
│   │       └── EmptyState.kt           # reusable (icon, title, subtitle, action?)
│   └── screens/
│       ├── shell/
│       │   ├── AppShell.kt             # root authenticated composable
│       │   └── ShellState.kt           # active tab + searchOpen state
│       ├── planner/
│       │   ├── PlannerScreen.kt        # inline title + EmptyState
│       │   └── PlannerViewModel.kt
│       ├── recipes/
│       │   ├── RecipesScreen.kt
│       │   ├── RecipesViewModel.kt
│       │   └── RecipesSearchViewModel.kt
│       ├── pantry/
│       │   ├── PantryScreen.kt
│       │   ├── PantryViewModel.kt
│       │   └── PantrySearchViewModel.kt
│       └── shopping/
│           ├── ShoppingScreen.kt
│           └── ShoppingViewModel.kt
│   └── (auth/ stays as-is — legacy Material 3)
└── di/
    ├── AppModule.kt                    # extended to include shellModule
    └── ShellModule.kt                  # NEW: VMs + ShellState + GlassBackend factory
```

`composeApp/src/iosMain/` and `androidMain/`: backend `actual`s for `GlassBackend` if implementation differs by platform. Liquid is multiplatform so a single `commonMain` `LiquidBackend` likely works; only Haze actuals or platform-specific image effects need `actual`s — confirm at planning.

### Pattern 1: Nested NavHost per tab (CMP-official, multi-back-stack)

Single root `NavHost` containing four `navigation(route = "*_graph")` sub-graphs. Bottom dock navigation uses save/restore state. This is the JetBrains-recommended pattern (kotlinlang.org/docs/multiplatform/compose-navigation.html — "for apps with bottom navigation you can maintain separate nested graphs for each tab while saving and restoring navigation states when switching between tabs").

```kotlin
// Source: kotlinlang.org/docs/multiplatform/compose-navigation.html (HIGH)
// + saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved (MEDIUM)

@Serializable data object PlannerGraph
@Serializable data object PlannerHome
@Serializable data object RecipesGraph
@Serializable data object RecipesHome
// ... etc

@Composable
fun RootNavHost(navController: NavHostController) {
    NavHost(navController = navController, startDestination = PlannerGraph) {
        navigation<PlannerGraph>(startDestination = PlannerHome) {
            composable<PlannerHome> { entry ->
                val parent = remember(entry) {
                    navController.getBackStackEntry(PlannerGraph)
                }
                val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
                PlannerScreen(vm)
            }
            // future detail destinations land here
        }
        navigation<RecipesGraph>(startDestination = RecipesHome) { /* ... */ }
        navigation<PantryGraph>(startDestination = PantryHome) { /* ... */ }
        navigation<ShoppingGraph>(startDestination = ShoppingHome) { /* ... */ }
    }
}

fun NavHostController.navigateToTab(graphRoute: Any) {
    navigate(graphRoute) {
        popUpTo(graph.findStartDestination().id) { saveState = true }
        launchSingleTop = true
        restoreState = true
    }
}
```

**iOS caveat (PITFALL 13 + research/PITFALLS.md):** The CMP nav backstack persistence has had issues across minor versions (see GitHub issue 4735 — "Support saving state for nested NavHostController"). Pin to 2.9.2 (latest stable) and verify multi-back-stack behavior on iOS during Wave 0 with a short demo: open detail → switch tab → switch back → confirm detail restored. [VERIFIED: github.com/JetBrains/compose-multiplatform/issues/4735 — issue references nested NavHostController; root-level multi-back-stack via single NavHost + `navigation` blocks is the working pattern]

### Pattern 2: Per-tab ViewModel scoping via parent graph `NavBackStackEntry`

`koinViewModel()` defaults to scoping to the *current* destination entry — meaning the VM dies when you navigate to a child destination. To make `RecipesViewModel` survive within the recipes graph (so future `RecipesDetailScreen` can share state with `RecipesScreen`), retrieve the **parent graph's** `NavBackStackEntry` and pass it as `viewModelStoreOwner`.

```kotlin
// Source: insert-koin.io/docs/reference/koin-compose/compose/ (HIGH)
// + droidcon.com/2024/10/16/place-scope-handling-on-auto-pilot-with-koin-compose-navigation (MEDIUM)

@Composable
fun RecipesScreen(navController: NavController) {
    val parent = remember { navController.getBackStackEntry(RecipesGraph) }
    val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
    val searchVm: RecipesSearchViewModel = koinViewModel(viewModelStoreOwner = parent)
    // both VMs survive within the recipes graph; freed when graph leaves stack
}
```

This phase only has one screen per graph, but **set the pattern now** — Phase 5 (Recipe Catalog) will add detail screens that need shared state with the list screen, and Phase 5 should not have to refactor scoping.

### Pattern 3: `GlassSurface` primitive with three-backend chain (D-16, D-17)

```kotlin
// Source: research synthesis from CONTEXT D-16/D-17 + Liquid README + Haze docs (MEDIUM — Liquid API is from README)

@Composable
fun GlassSurface(
    modifier: Modifier = Modifier,
    tint: Color = RecipeTheme.colors.surfaceGlass,
    cornerRadius: Dp = 28.dp,
    border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
    content: @Composable BoxScope.() -> Unit,
) {
    val backend = LocalGlassBackend.current  // resolved via compile-time + debug toggle
    when (backend) {
        GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, content)
        GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, content)
        GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content)
    }
}
```

`LocalGlassBackend` is a `CompositionLocal` set once at `AppShell` startup:
1. **Compile-time default** picked per target via `expect/actual` or `commonMain` constants — e.g. `iosArm64/iosSimulatorArm64/android → Liquid`, anything else → `Haze`.
2. **Debug runtime override** read once at app start from `multiplatform-settings` key `"debug.glass_backend"`. Production builds short-circuit this path (compiled out via `BuildConfig`-style constant in `androidMain` / Kotlin `expect val isDebug` actual).

The Liquid path uses `rememberLiquidState()` + `Modifier.liquefiable(state)` on the content layer behind chrome and `Modifier.liquid(state)` on the chrome itself. The Liquid effect needs a sampleable backdrop, so the screen content (tab body) gets `liquefiable(state)` and the dock/search-pill get `liquid(state)`. **Important:** that backdrop is the screen body, not scrolling content within the body — that aligns with PITFALL 5/12 (chrome-only constraint).

### Pattern 4: Search affordance state machine

```kotlin
// Source: synthesized from CONTEXT D-05 through D-09 + UI-SPEC interaction contract

class RecipesSearchViewModel : ViewModel() {
    private val _state = MutableStateFlow(SearchState())
    val state: StateFlow<SearchState> = _state.asStateFlow()

    fun open() { _state.update { it.copy(isOpen = true) } }
    fun close() { _state.update { SearchState() } }     // D-08: clears query
    fun onQueryChange(q: String) { _state.update { it.copy(query = q) } }
    fun clear() { _state.update { it.copy(query = "") } }
}

data class SearchState(val isOpen: Boolean = false, val query: String = "")
```

`AppShell` reads the search VM of the **active** tab (Recipes or Pantry). When `isOpen = true`, the `DockBar` collapses + `SearchPill` renders. The shell owns the active-tab → search-VM mapping; the VMs themselves are scoped to their parent graphs.

**Phase 5 extension point:** the Recipes search VM's state today is `(isOpen, query)`. Phase 5 adds `results: Flow<List<RecipeCard>>` derived from `query.debounce().flatMapLatest { repo.search(it) }`. Design the VM constructor with a nullable `searchSource: SearchSource? = null` parameter today so Phase 5 only injects the dependency rather than rewriting the VM.

### Anti-Patterns to Avoid

- **`when (selectedTab) { ... }` switch instead of nested `NavHost`:** kills back stacks (PITFALL 13). Always use `navigation()` sub-graphs.
- **`koinViewModel()` without `viewModelStoreOwner` for tab-scoped VMs:** VM dies when navigating into a detail; future Phase 5 detail flow loses list scroll position.
- **Glass effects over scrolling content:** explicit project rule (CLAUDE.md #10, PITFALL 5/12). `GlassSurface` is for chrome only — dock, search pill, floating button.
- **Direct Liquid/Haze API calls in screen code:** screens MUST go through `GlassSurface`. Direct calls leak backend choice into call sites and break the fallback contract.
- **Hardcoded Polish strings:** every user-facing string is `stringResource(Res.string.*)`. CLAUDE.md non-negotiable #9.
- **`androidx.compose.material3.*` imports outside `ui/screens/auth/`:** PROJECT decision. Even if convenient, it expands Material 3 into new code.
- **Device clock for animation timing:** unrelated to LWW but same hygiene — use `kotlinx.coroutines` `delay` and Compose animation specs, not `System.currentTimeMillis()`.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Tab navigation with multi-back-stack | `when (selectedTab)` + manual back-handler | CMP `navigation-compose` 2.9.x with `popUpTo + saveState + restoreState + launchSingleTop` | PITFALL 13: hand-rolled tab switching loses back stack on every switch; Jetpack/JetBrains nav handles it correctly |
| Renderless TabGroup / Button / TextField with proper a11y + focus + keyboard | Custom `Modifier.clickable + Role.Tab` and an `OutlinedTextField` analogue | Compose Unstyled `TabGroup`, `Button`, `TextField` primitives | These libraries already handle focus order, semantics, IME types, and edge cases; PROJECT decision is to use them |
| Glass blur effect | Custom `RenderEffect` per platform | Liquid (`liquid` modifier) → Haze (`hazeChild`) → flat translucent | Cross-platform shader correctness, perf optimization, and graceful degradation — all already in Liquid/Haze |
| Polish-aware string lookup | Hardcoded literals + manual locale switch | `compose-components-resources` `stringResource(Res.string.*)` | Already wired Phase 2; multi-locale-ready for free |
| Theme `CompositionLocal` ceremony | Per-component prop drilling | Standard Compose `compositionLocalOf` + `CompositionLocalProvider` pattern | Idiomatic; mirror MaterialTheme's structure |
| Animated transition between dock states | Manual coroutine + lerp | `Modifier.animateContentSize()` for size + `AnimatedContent` for icon/label visibility, both with shared `animationSpec` | Single-source-of-truth animation; Compose handles intersecting frames |

**Key insight:** every chrome surface (dock, search button, search pill) uses the same `GlassSurface` primitive — the size, shape, and animation differ but the substrate doesn't. Centralizing surface logic now means Phase 10's real-device tuning is a one-file change.

---

## Common Pitfalls

### Pitfall A: CMP nav-compose multi-back-stack regression on iOS

**What goes wrong:** Tab → detail → other tab → return → detail is gone. Reproduces on iOS, not Android.
**Why:** Some 2.8.x CMP nav releases had broken state restoration on iOS native; 2.9.x is the recommended floor. CMP's K/N nav implementation has had drift behind Android.
**How to avoid:** Pin to `navigation-compose 2.9.2`. Add a Wave-0 manual smoke test on iOS simulator: navigate dummy detail in one tab, switch tabs, switch back, assert detail visible.
**Warning signs:** Works on Android, broken on iOS. Compose Multiplatform GitHub issue 4735 family.

### Pitfall B: ViewModel re-creation on tab reselection

**What goes wrong:** Clicking the active tab re-creates its ViewModel, dropping in-memory state and re-running `init`.
**Why:** `launchSingleTop = true` + missing `restoreState = true` causes Nav to clear and recreate.
**How to avoid:** Always include `restoreState = true` AND scope VM to parent graph entry (Pattern 2 above). Verify by adding a counter in `init` and confirming it doesn't tick on tab reselection.

### Pitfall C: Liquid sampleable backdrop missing → effect renders flat

**What goes wrong:** `liquid()` modifier renders nothing because no `liquefiable()` peer is in the tree.
**Why:** Liquid's pixel-sampling needs a tagged source layer. Forgetting it means the effect has no input.
**How to avoid:** `AppShell` wraps the screen body region in `Modifier.liquefiable(state)` and the dock + search pill + search button consume `Modifier.liquid(state)` from the same `LiquidState`. Document this contract in `GlassSurface` KDoc.

### Pitfall D: `Icons.Outlined.MenuBook` and friends not in baseline icon set

**What goes wrong:** Compile fails on `Icons.Outlined.MenuBook` / `Inventory2` / `CalendarMonth` / `ShoppingCart` because the four selected icons are in the **extended** set, not the baseline that `material3` ships.
**How to avoid:** Verify at planning time. If extended set is needed, add `org.jetbrains.compose.material:material-icons-extended` to the catalog. (Wave-0 task: try a dummy compose with all four icons; observe.)

### Pitfall E: Hardcoded literals slip in during shell wiring

**What goes wrong:** Tab labels or empty-state copy gets typed inline as `Text("Planer")` during a quick prototype, then nobody refactors.
**How to avoid:** Lint/grep gate in plan-checker: any `Text("[A-ZŁĄĆŻŃŚŹŻ]...")` or `Text("[a-zA-Złąćż]+")` in `ui/screens/(planner|recipes|pantry|shopping|shell)/` is a bug. Phase 11 will enforce this globally; introduce the discipline now (CLAUDE.md non-negotiable #9).

### Pitfall F: `safeContentPadding()` interactions with floating dock

**What goes wrong:** Bottom dock either overlaps the home indicator or sits too high above it because `Scaffold`-style content padding gets applied twice (once by parent, once by screen body).
**How to avoid:** AppShell consumes navigation/IME insets explicitly via `WindowInsets.navigationBars.union(WindowInsets.ime).only(WindowInsetsSides.Bottom)` and applies them to the dock's bottom offset. Screen bodies use `WindowInsets.statusBars` for top inset only. Don't use `safeContentPadding()` on both layers.

### Pitfall G: K/N GC churn on bottom-dock animation (PITFALL 1 carry-over)

**What goes wrong:** Frame hitches on iPhone 11/12-era hardware when dock collapses and the Liquid layer composites.
**How to avoid:** `kotlin.native.binary.objcDisposeOnMain=false` and `gc=cms` are already set Phase 1 (INFRA-03). Verify in Wave 0 and confirm in any iOS smoke test. If hitches appear, the debug runtime toggle (D-17) lets the user fall back to flat to confirm Liquid is the cause.

---

## Code Examples

### Example 1: Routes (type-safe)

```kotlin
// navigation/Routes.kt
package dev.ulfrx.recipe.navigation

import kotlinx.serialization.Serializable

@Serializable data object PlannerGraph
@Serializable data object PlannerHome

@Serializable data object RecipesGraph
@Serializable data object RecipesHome

@Serializable data object PantryGraph
@Serializable data object PantryHome

@Serializable data object ShoppingGraph
@Serializable data object ShoppingHome

enum class BottomBarDestination(val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector) {
    Planner(PlannerGraph, Res.string.shell_tab_planner, Icons.Outlined.CalendarMonth),
    Recipes(RecipesGraph, Res.string.shell_tab_recipes, Icons.Outlined.MenuBook),
    Pantry(PantryGraph, Res.string.shell_tab_pantry, Icons.Outlined.Inventory2),
    Shopping(ShoppingGraph, Res.string.shell_tab_shopping, Icons.Outlined.ShoppingCart),
}
```

### Example 2: AppShell skeleton

```kotlin
// ui/screens/shell/AppShell.kt
@Composable
fun AppShell() {
    val navController = rememberNavController()
    val backStack by navController.currentBackStackEntryAsState()
    val activeTab = remember(backStack) { backStack?.toBottomBarDestination() ?: BottomBarDestination.Planner }
    val shellState: ShellViewModel = koinViewModel()
    val ui by shellState.state.collectAsStateWithLifecycle()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(RecipeTheme.colors.background)
            .liquefiable(shellState.liquidState),  // backdrop for Liquid
    ) {
        RootNavHost(navController)

        // Bottom chrome — overlay
        Column(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .windowInsetsPadding(WindowInsets.navigationBars),
        ) {
            if (ui.searchOpen && activeTab.hasSearch) {
                SearchPill(
                    query = ui.query,
                    onQueryChange = shellState::onQueryChange,
                    onClear = shellState::clearQuery,
                    onClose = shellState::closeSearch,
                    placeholder = stringResource(activeTab.searchPlaceholder),
                )
            }
            DockBar(
                destinations = BottomBarDestination.entries,
                active = activeTab,
                collapsed = ui.searchOpen,
                onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
                onCollapsedTap = shellState::closeSearch,
            )
        }
        if (!ui.searchOpen && activeTab.hasSearch) {
            FloatingSearchButton(
                modifier = Modifier
                    .align(Alignment.BottomEnd)
                    .padding(end = RecipeTheme.spacing.lg, bottom = RecipeTheme.spacing.sm)
                    .windowInsetsPadding(WindowInsets.navigationBars),
                onClick = shellState::openSearch,
            )
        }
    }
}
```

### Example 3: EmptyState

```kotlin
// ui/components/empty/EmptyState.kt
@Composable
fun EmptyState(
    icon: ImageVector,
    title: String,
    subtitle: String,
    modifier: Modifier = Modifier,
    action: (@Composable () -> Unit)? = null,
) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(horizontal = RecipeTheme.spacing.xl)
            .semantics(mergeDescendants = true) {},
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        Icon(
            imageVector = icon,
            contentDescription = null,
            tint = RecipeTheme.colors.contentMuted,
            modifier = Modifier.size(48.dp),
        )
        Spacer(Modifier.height(RecipeTheme.spacing.sm))
        Text(text = title, style = RecipeTheme.typography.display, color = RecipeTheme.colors.content,
             textAlign = TextAlign.Center)
        Spacer(Modifier.height(RecipeTheme.spacing.lg))
        Text(text = subtitle, style = RecipeTheme.typography.body, color = RecipeTheme.colors.contentMuted,
             textAlign = TextAlign.Center)
        if (action != null) {
            Spacer(Modifier.height(RecipeTheme.spacing.xl))
            action()
        }
    }
}
```

### Example 4: Strings resource

```xml
<!-- composeApp/src/commonMain/composeResources/values/strings.xml — extend existing file -->
<resources>
    <!-- existing auth_* keys preserved -->

    <!-- Shell tab labels (UI-SPEC) -->
    <string name="shell_tab_planner">Planer</string>
    <string name="shell_tab_recipes">Przepisy</string>
    <string name="shell_tab_pantry">Spiżarnia</string>
    <string name="shell_tab_shopping">Zakupy</string>

    <!-- Empty states -->
    <string name="empty_planner_title">Twój plan tygodnia czeka</string>
    <string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
    <string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
    <string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
    <string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</string>
    <string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
    <string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
    <string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>

    <!-- Search affordance (a11y + placeholders) -->
    <string name="search_open_a11y">Otwórz wyszukiwanie</string>
    <string name="search_close_a11y">Zamknij wyszukiwanie</string>
    <string name="search_clear_a11y">Wyczyść</string>
    <string name="search_placeholder_recipes">Szukaj przepisów…</string>
    <string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
</resources>
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Manual `when (tab)` tab switching | CMP `navigation-compose` `navigation()` sub-graphs + `saveState/restoreState` | Stable since nav-compose 2.7+ on Android, 2.8+ on KMP | Multi-back-stack works; PITFALL 13 prevented |
| `nav-compose` 2.7.x with KMP support hidden behind alpha | `org.jetbrains.androidx.navigation:navigation-compose 2.9.x` (stable port) | 2.9 series | Use 2.9.2; older 2.7/2.8 had iOS state-restoration drift |
| Material 3 default scaffold for tab apps | Compose Unstyled renderless primitives + custom `RecipeTheme` | Compose Unstyled 1.40+ | Calmer aesthetics, no Material 3 tax — explicit project decision |
| `Modifier.blur()` for glass | RuntimeShader-based libraries (Liquid, Haze 2.x) | Compose 1.6+ stable RuntimeShader on iOS | Real Liquid Glass approximation cross-platform |
| Haze 2.0-alpha for shipping | Haze 1.x stable for production | Haze 2.0-alpha01 released 2026-04-29 | Stay on 1.x stable until Haze 2.x is stable; Phase 10 may revisit |

**Deprecated/outdated:**
- `freeze()`, `@SharedImmutable`, `kotlin.native.concurrent.AtomicReference` — gone since K/N new MM (PITFALL 2).
- `androidx.navigation:navigation-compose` (Android-only artifact) — for KMP, always use `org.jetbrains.androidx.navigation:navigation-compose`.

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Liquid 1.1.1 publishes klibs for `iosArm64` AND `iosSimulatorArm64` (Maven Central lists `liquid-iossimulatorarm64` artifact, but full target matrix not enumerated on the package page) | Standard Stack / Pitfall A | Wave-0 dependency-resolution check fails for iOS; phase falls back to Haze-as-default at compile time. Plan must include the Wave-0 verify step before depending on Liquid as the iOS default backend. |
| A2 | `Icons.Outlined.MenuBook`, `Inventory2`, `CalendarMonth`, `ShoppingCart` are accessible without adding `material-icons-extended` (UI-SPEC selected these without flagging) | Standard Stack / Pitfall D | Build fails on import; planner adds `material-icons-extended` to catalog. Cheap to fix. |
| A3 | The CMP nav-compose 2.9.2 K/N (iOS) binary correctly persists `saveState` across tab reselection (a Wave-0 smoke test must confirm) | Pattern 1 / Pitfall A | If broken: the phase falls back to a single root NavHost without nested graphs, and Phase 5 will need to retrofit. Smoke test catches this in Wave 0. |
| A4 | Haze 1.x stable on KMP iOS handles `hazeChild` over a non-scrolling backdrop without the iPhone-11 jank pattern (PITFALL 12); restricted to chrome only | Pattern 3 | If jank: production engages the flat fallback per D-17. Acceptable since Liquid is the primary path. |
| A5 | `multiplatform-settings` is wired in commonMain Koin and accessible from `AppShell` at startup (already pulled in Phase 2 for AuthState) | Pattern 3 — debug toggle | If not: minor Koin wiring tweak. Already in libs catalog so likely fine. |
| A6 | Compose Unstyled 1.49.x supports KMP iOS targets (artifact name `composeunstyled` not `core`) | Standard Stack | If wrong artifact ID: Wave-0 catches via Gradle resolution failure; planner adjusts. Verify exact 1.49.9 coords against `composables.com/docs/com.composables/core/installation`. |
| A7 | The CMP `lifecycle-viewmodel-compose` `viewModelStoreOwner` parameter to `koinViewModel()` correctly hosts a VM per parent NavBackStackEntry on iOS (the documented pattern is from Android Jetpack; CMP behavior is assumed equivalent) | Pattern 2 | Test in Wave 0; if VM is recreated on tab switch on iOS, fall back to scoping at root graph (less ideal but functional). |
| A8 | Empty-state copy strings in UI-SPEC are best-current placeholders, subject to Phase 11 tuning | Code Examples / strings.xml | None — explicitly flagged in UI-SPEC. |

**A1 and A3 are the load-bearing assumptions** — Wave 0 of the plan MUST resolve them before the rest of the work is touched.

---

## Open Questions (RESOLVED)

> Resolved 2026-05-08 by gsd-phase-planner during the plan-set authoring pass for plans 02.1-03 through 02.1-08. Each resolution is reflected in the corresponding plan's mandates.

1. **Should the Liquid runtime debug toggle be exposed in-app (hidden gesture) or as a build flag only?** — **RESOLVED**
   - What we know: D-17 says "via `multiplatform-settings`, surfaced through a hidden settings entry or build flag" — both are valid.
   - What's unclear: which one delivers more value at this phase. There's no settings screen yet (Phase 3+).
   - Recommendation: Build flag only this phase (lightest scaffolding). Defer in-app toggle to whenever a settings screen lands. The `multiplatform-settings`-backed `LocalGlassBackend` plumbing is still built so an in-app toggle is a UI-only change later.
   - **RESOLUTION:** Debug-build runtime override via `multiplatform-settings` key `"debug.glass_backend"`, gated by `expect val isDebugBuild: Boolean` so production binaries compile out the override path entirely. This aligns with D-17 and is implemented by plan 02.1-03 (GlassBackend.kt + IsDebugBuild.kt expect/actual). No in-app debug-toggle UI this phase; Phase 3+ may add one as a UI-only change once a settings surface exists.

2. **Should the `material-icons-extended` artifact be added preemptively, or wait until the four icons are confirmed missing?** — **RESOLVED**
   - What we know: UI-SPEC selected `Icons.Outlined.{CalendarMonth,MenuBook,Inventory2,ShoppingCart}`. These are typically in extended.
   - What's unclear: whether `compose-material3` 1.10.0-alpha05 transitively exposes them.
   - Recommendation: Wave-0 verification task — try the icons, add the dependency if needed. Document the result.
   - **RESOLUTION:** Added preemptively in plan 02.1-01 (catalog entry `compose-material-icons-extended = "1.7.3"`) because the four phase-2.1 icons (CalendarMonth, MenuBook, Inventory2, ShoppingCart) plus Search are all in the extended set. Validated empirically by the `linkDebugFrameworkIosSimulatorArm64` acceptance check in plan 02.1-01.

3. **Should `RecipeTheme` re-export `MaterialTheme` for the auth screens, or are they fine on Material 3 defaults?** — **RESOLVED**
   - What we know: Phase 2 auth screens use `MaterialTheme.colorScheme.surface/typography.headlineSmall`. The current `RecipeTheme.kt` is a Material 3 wrapper. UI-SPEC says auth stays on Material 3 as legacy.
   - What's unclear: whether expanding `RecipeTheme` into the new token system breaks the existing `MaterialTheme.*` lookups in auth screens.
   - Recommendation: `RecipeTheme` keeps wrapping `MaterialTheme(colorScheme = ...)` AND adds the new `CompositionLocalProvider` for Recipe tokens. Auth screens continue to read `MaterialTheme.*`; new code reads `RecipeTheme.*`. Both work in the same composition.
   - **RESOLUTION:** Yes — plan 02.1-02 keeps `MaterialTheme(colorScheme = ...)` wrapping the inner `CompositionLocalProvider(...)`. Legacy auth screens (`LoginScreen.kt`, `PostLoginPlaceholderScreen.kt`, `SplashScreen.kt`) continue to read `MaterialTheme.colorScheme.*` / `MaterialTheme.typography.*`; new shell code reads `RecipeTheme.colors.*` etc. The MaterialTheme wrapper is removed only when the auth screens migrate (out of scope for v1 — CONTEXT line 52 keeps auth screens as legacy by user discretion).


---

## Environment Availability

This phase is purely client-side code/config; the only external "tools" are Gradle dependencies, all from Maven Central.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Maven Central | All new dependencies | ✓ | n/a | — |
| `org.jetbrains.androidx.navigation:navigation-compose` | UI-03 | ✓ | 2.9.2 | — |
| `com.composables:composeunstyled` | UI-04, component foundation | ✓ | 1.49.9 | — |
| `io.github.fletchmckee.liquid:liquid` | UI-04 | ✓ | 1.1.1 | Fall back to Haze (D-16) |
| `dev.chrisbanes.haze:haze` | UI-04 fallback | ✓ | 1.x stable | Fall back to flat translucent |
| `gradlew` build for `iosSimulatorArm64` | Smoke test (Wave 0) | (host-dependent — Apple Silicon required) | n/a | Manual check on developer machine |

**Missing dependencies with no fallback:** none for this phase.
**Missing dependencies with fallback:** the entire Liquid → Haze → flat chain IS the fallback design.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | `kotlin.test` (commonTest) — already used in Phase 2 (`AuthSessionTest`, `LoginViewModelTest`) |
| Config file | none — convention plugins handle `recipe.kotlin.multiplatform` |
| Quick run command | `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.*" --tests "dev.ulfrx.recipe.ui.screens.recipes.*Search*"` |
| Full suite command | `./gradlew :composeApp:check` |
| Compose UI test runner | not introduced this phase — feasibility low because Compose UI Test on KMP iOS is still surfacing |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| UI-03 | Tab switch preserves per-tab back stack | manual smoke (iOS simulator) — instrument with logging if needed | `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` then iOS smoke from Xcode | ❌ Wave 0 |
| UI-03 | `navigateToTab()` extension applies `popUpTo + saveState + launchSingleTop + restoreState` | unit | `./gradlew :composeApp:commonTest --tests "*NavigationTest*"` | ❌ Wave 0 |
| UI-04 | `GlassSurface` selects Liquid backend on iOS targets at compile time | unit (per-source-set constants) | `./gradlew :composeApp:commonTest --tests "*GlassBackend*"` | ❌ Wave 0 |
| UI-04 | `GlassSurface` debug-toggle flow honors `multiplatform-settings` value | unit (with `MapSettings` test impl) | `./gradlew :composeApp:commonTest --tests "*GlassBackendOverride*"` | ❌ Wave 0 |
| UI-09 | `EmptyState` composable: on first launch, all four tabs render their respective empty state without flash | manual smoke (iOS) — observe one launch | n/a | manual |
| UI-09 | App.kt's `AuthState.Authenticated + currentUser != null` branch resolves to `AppShell`, not `PostLoginPlaceholderScreen` | unit (via state-machine test extending `AuthSessionTest` patterns) | `./gradlew :composeApp:commonTest --tests "*AppShellGateTest*"` | ❌ Wave 0 |
| UI-10 | `RecipesSearchViewModel`: `open() → onQueryChange("foo") → close()` clears query and resets `isOpen` | unit | `./gradlew :composeApp:commonTest --tests "*SearchViewModelTest*"` | ❌ Wave 0 |
| UI-10 | `RecipesSearchViewModel`: `clear()` resets only query, keeps `isOpen=true` | unit | (same target) | ❌ Wave 0 |
| UI-10 | Search affordance is visible on Recipes + Pantry tabs only (D-06) | manual smoke + screenshot per tab | n/a | manual |

### Sampling Rate

- **Per task commit:** `./gradlew :composeApp:commonTest` (existing tests + new tests for that task)
- **Per wave merge:** `./gradlew :composeApp:check` (lint/spotless + commonTest)
- **Phase gate:** Full `./gradlew check` green AND a single iOS-simulator smoke run completed by hand: launch → land on Planer empty state → tab through Przepisy / Spiżarnia / Zakupy → open search on Recipes, type a few chars, close → confirm dock collapse animation runs → confirm navigation back stacks survive tab roundtrip (smoke script in `02.1-VALIDATION.md`)

### Wave 0 Gaps

- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt` — covers UI-03 nav extension semantics (uses `TestNavHostController` if available; else asserts on the lambda built into `navigateToTab()`)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` — covers UI-04 backend selection
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` — covers UI-04 debug toggle via `MapSettings`
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` — covers UI-09 (root `App()` routes Authenticated to AppShell, not placeholder)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` — covers UI-10
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` — mirror of recipes search VM test
- [ ] iOS-simulator smoke runbook captured in `02.1-VALIDATION.md` for tab back-stack + dock-collapse manual verification (UI-03/UI-04/UI-09/UI-10 visible checks)
- [ ] No new framework install needed — `kotlin.test` already in place.

**Honest note:** automated UI tests for `Compose Multiplatform on iOS` are not solved enough at this phase to be worth the cost. The shell is a shape that benefits from human eyes (animation feel, glass aesthetic, label legibility) more than from snapshot-asserting machinery. ViewModel state machines and pure helper functions are unit-testable; the visible chrome is verified by a manual smoke runbook. Phase 10 is the right place to revisit screenshot/UI testing once the shell stabilizes.

---

## Sources

### Primary (HIGH confidence)
- [JetBrains: Navigation in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation.html) — official nav-compose guide; multi-back-stack pattern
- [Maven Central: navigation-compose 2.9.2](https://central.sonatype.com/artifact/org.jetbrains.androidx.navigation/navigation-compose/2.9.2)
- [Maven Central: io.github.fletchmckee.liquid:liquid](https://central.sonatype.com/artifact/io.github.fletchmckee.liquid/liquid) — version + iOS simulator artifact existence
- [GitHub: FletchMcKee/liquid](https://github.com/FletchMcKee/liquid) — public API: `liquid(state)`, `liquefiable(state)`, `rememberLiquidState()`
- [Compose Unstyled — Installation](https://composables.com/docs/com.composables/core/installation) — artifact `com.composables:composeunstyled:1.49.9`
- [Haze docs](https://chrisbanes.github.io/haze/) and [Haze 2.0 release post](https://chrisbanes.me/posts/haze-2.0/) — version state, platform support
- [Koin Compose docs](https://insert-koin.io/docs/reference/koin-compose/compose/) — `koinViewModel(viewModelStoreOwner = parent)` pattern
- `.planning/research/PITFALLS.md` — Pitfalls 1, 5, 12, 13 directly applicable
- `.planning/research/ARCHITECTURE.md` — Pattern 1 (StateFlow), package layout convention

### Secondary (MEDIUM confidence)
- [Saurabh Jadhav: Bottom Navigation + Nested Navigation Solved](https://saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved) — concrete `popUpTo + saveState` snippet (Android Jetpack docs; CMP port behaves equivalently per JetBrains guidance)
- [droidcon: Place Scope Handling on Auto-Pilot with Koin & Compose Navigation](https://www.droidcon.com/2024/10/16/place-scope-handling-on-auto-pilot-with-koin-compose-navigation/) — koin scope patterns with NavBackStackEntry
- [Medium: Liquid Glass Components in Compose Multiplatform (Part 1, MateeDevs)](https://medium.com/mateedevs/liquid-glass-components-in-compose-multiplatform-71b7a9ffc56d) — community usage examples
- [GitHub issue: Support saving state for nested NavHostController](https://github.com/JetBrains/compose-multiplatform/issues/4735) — historical context for nav state restoration on KMP

### Tertiary (LOW confidence — flagged for Wave-0 verification)
- Liquid library full target matrix (assumption A1 — confirmed by Maven Central listing of `liquid-iossimulatorarm64` artifact, but full README iOS-Arm64 device target list not retrieved)
- `Icons.Outlined.{MenuBook,Inventory2,CalendarMonth,ShoppingCart}` availability without `material-icons-extended` (assumption A2)

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every library is official, on Maven Central, with verified versions as of 2026-05-08
- Architecture (nested NavHost + Koin scoping): HIGH — JetBrains-documented pattern; Pitfall 13 codified; Pattern 2 is the canonical Koin recommendation
- Liquid integration specifics: MEDIUM — public API surface read from README; iOS klibs verified to exist on Maven Central but full device-target matrix not enumerated on package page (Wave-0 dependency-resolution check resolves this)
- Theme + token scaffold structure: HIGH — standard Compose `CompositionLocal` idiom; UI-SPEC pre-locked the shape
- Empty-state composable: HIGH — trivial; signature locked by D-13
- Search state machine: HIGH — pure ViewModel + StateFlow following Phase 2's established pattern
- Validation Architecture: MEDIUM — automated coverage of pure logic is solid; visible chrome relies on manual smoke given KMP iOS UI-test maturity

**Research date:** 2026-05-08
**Valid until:** 2026-06-07 (30 days; CMP / nav-compose / Liquid all on stable cadence with no upcoming breaking releases announced)
