---
phase: 02.1
plan: 08
type: execute
wave: 5
depends_on: ["02.1-05", "02.1-06", "02.1-07"]
files_modified:
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
  - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt
autonomous: true
requirements: [UI-09]
tags: [kotlin, koin, di, app-entry, navigation, glass, expect-actual, integration, multiplatform-settings]

must_haves:
  truths:
    - "shellModule registers all 4 tab VMs (PlannerViewModel, RecipesViewModel, PantryViewModel, ShoppingViewModel), both Search VMs (RecipesSearchViewModel, PantrySearchViewModel), ShellViewModel, and a single<GlassBackend> { resolveGlassBackend(get<Settings>(), isDebugBuild, default) } provider"
    - "AppModule.includes(...) gains shellModule alongside authModule + userModule"
    - "App.kt's Authenticated + currentUser != null branch resolves to AppShell() instead of PostLoginPlaceholderScreen(...)"
    - "App.kt preserves the LaunchedEffect(authSession) { initialize() } block and the currentUser == null → SplashScreen() arm"
    - "PostLoginPlaceholderScreen + PostLoginViewModel are NOT deleted (logout-bridge possibility per CONTEXT line 101 / RESEARCH § Open Questions Q3)"
    - "RecipeTheme.kt provides LocalGlassBackend via CompositionLocalProvider so AppShell + chrome composables resolve the backend"
    - "RootNavHost's TabHomePlaceholder stubs (from plan 02.1-04) are replaced with the real Tab*Screen calls using koinViewModel(viewModelStoreOwner = parent) per RESEARCH § Pattern 2"
    - "V-04 anchor: AppShellGateTest replaces its @Ignore stub with a real test asserting that App's Authenticated+user routing branches to the AppShell branch (or extracted RootRouter pure function)"
  artifacts:
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt"
      provides: "Koin shellModule — 7 VMs + GlassBackend single"
      contains: "val shellModule"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt"
      provides: "appModule extended to include shellModule"
      contains: "shellModule"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
      provides: "App() composable routing Authenticated+user to AppShell()"
      contains: "AppShell()"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt"
      provides: "RootNavHost wired to call PlannerScreen / RecipesScreen / PantryScreen / ShoppingScreen with VM scoping"
      contains: "PlannerScreen"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt"
      provides: "RecipeTheme provides LocalGlassBackend value resolved at startup"
      contains: "LocalGlassBackend provides"
    - path: "composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt"
      provides: "V-04 anchor — real assertion that Authenticated+user routes to AppShell branch"
  key_links:
    - from: "App.kt"
      to: "ui/screens/shell/AppShell.kt"
      via: "Authenticated branch invokes AppShell() instead of PostLoginPlaceholderScreen(...)"
      pattern: "AppShell\\(\\)"
    - from: "di/AppModule.kt"
      to: "di/ShellModule.kt"
      via: "includes(authModule, userModule, shellModule)"
      pattern: "shellModule"
    - from: "di/ShellModule.kt"
      to: "ui/components/glass/GlassBackend.kt"
      via: "single<GlassBackend> { resolveGlassBackend(get<Settings>(), isDebugBuild, default) }"
      pattern: "resolveGlassBackend"
    - from: "ui/theme/RecipeTheme.kt"
      to: "ui/components/glass/GlassBackend.kt"
      via: "CompositionLocalProvider(LocalGlassBackend provides koinInject<GlassBackend>())"
      pattern: "LocalGlassBackend"
    - from: "navigation/RootNavHost.kt"
      to: "ui/screens/{planner,recipes,pantry,shopping}/{Tab}Screen.kt"
      via: "composable<*Home>{ koinViewModel(viewModelStoreOwner = parent) → Tab*Screen(viewModel = vm) }"
      pattern: "PlannerScreen\\(viewModel ="
---

<objective>
Final integration — wire the seven shell ViewModels and the GlassBackend resolver into a Koin `shellModule`; extend `appModule.includes(...)` to pull in `shellModule`; provide `LocalGlassBackend` in `RecipeTheme` so all chrome consuming `GlassSurface` resolves the right backend; replace the four `TabHomePlaceholder` stubs in `RootNavHost.kt` (from plan 02.1-04) with calls into the real `PlannerScreen` / `RecipesScreen` / `PantryScreen` / `ShoppingScreen` (from plan 02.1-07) using `koinViewModel(viewModelStoreOwner = parent)` per RESEARCH § Pattern 2; and finally swap the `Authenticated + currentUser != null` branch in `App.kt` from `PostLoginPlaceholderScreen(...)` to `AppShell()`.

Replace the @Ignore'd Wave-0 stub in `AppShellGateTest.kt` (V-04) with a real assertion. The cleanest test path: extract the routing logic in `App.kt` into a pure `RootRouter` enum (Splash / Login / Shell) computed from `(authState, currentUser)` and assert the enum value directly. The `App()` composable becomes a thin wrapper that switches on the enum. This keeps the test deterministic without instrumenting Compose composition.

`PostLoginPlaceholderScreen.kt` and `PostLoginViewModel.kt` are NOT deleted — RESEARCH § Open Questions Q3 (now RESOLVED) and CONTEXT line 101 keep them as a logout-bridge possibility. They are simply no longer reachable from the auth-gate flow this phase. A future phase may delete them or repurpose them.

Per CONTEXT line 52, the auth screens (LoginScreen, PostLoginPlaceholderScreen, SplashScreen) keep their Material 3 imports as legacy. Plan 02.1-02 preserved `MaterialTheme(colorScheme = ...)` wrapping in RecipeTheme so those screens keep working.

Purpose: turn the shell from "exists in the codebase" to "actually rendered after sign-in". UI-09 final closure: the Authenticated user lands in the real shell, not the placeholder.
Output: 1 new file (ShellModule.kt) + 5 modified files; 1 test un-ignored covering V-04.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md
@.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md
@.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md
@.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md
@.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
@composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt

<interfaces>
After Wave 4 (plan 02.1-05) and its prerequisites (02.1-06, 02.1-07) land, the following symbols are available:

From plan 02.1-05:
- `dev.ulfrx.recipe.ui.screens.shell.AppShell` — composable taking no required params
- `dev.ulfrx.recipe.ui.screens.shell.ShellViewModel`

From plan 02.1-06:
- `dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel(searchSource: SearchSource? = null)`
- `dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel(searchSource: SearchSource? = null)`

From plan 02.1-07:
- `dev.ulfrx.recipe.ui.screens.planner.{PlannerScreen, PlannerViewModel}`
- `dev.ulfrx.recipe.ui.screens.recipes.{RecipesScreen, RecipesViewModel}`
- `dev.ulfrx.recipe.ui.screens.pantry.{PantryScreen, PantryViewModel}`
- `dev.ulfrx.recipe.ui.screens.shopping.{ShoppingScreen, ShoppingViewModel}`

From plan 02.1-03:
- `dev.ulfrx.recipe.ui.components.glass.{GlassBackend, LocalGlassBackend, resolveGlassBackend, isDebugBuild, DEBUG_GLASS_BACKEND_KEY}`

From plan 02.1-04:
- `dev.ulfrx.recipe.navigation.{PlannerGraph, PlannerHome, RecipesGraph, RecipesHome, PantryGraph, PantryHome, ShoppingGraph, ShoppingHome}`

Existing analog (`auth/AuthModule.kt:9-25`) — Koin module shape:
```kotlin
val authModule = module {
    single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
    // ...
    viewModel<LoginViewModel>()
    viewModel<PostLoginViewModel>()
}
```

Existing AppModule (`di/AppModule.kt`):
```kotlin
val appModule = module {
    includes(authModule, userModule)
}
```

`com.russhwolf:multiplatform-settings:1.3.0` provides `Settings` interface — already on commonMain via Phase 2 (used by SecureAuthStateStore) and registered in Koin.

Current App.kt structure (App.kt:43-58):
```kotlin
when (authState) {
    AuthState.Loading -> SplashScreen()
    AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
    AuthState.Authenticated -> {
        val user = currentUser
        if (user == null) {
            SplashScreen()
        } else {
            PostLoginPlaceholderScreen(
                user = user,
                viewModel = koinViewModel<PostLoginViewModel>(),
            )
        }
    }
}
```

The modification: replace the `PostLoginPlaceholderScreen(...)` call (lines 53-56) with `AppShell()`. The `currentUser == null → SplashScreen()` arm stays. The `LaunchedEffect(authSession) { initialize() }` block (lines 39-41) stays untouched.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create ShellModule.kt + extend AppModule.kt + provide LocalGlassBackend in RecipeTheme</name>
  <files>
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt,
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt,
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
  </files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt — analog Koin module shape (lines 9-25)
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt — current state (preserve includes; just append shellModule)
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — current state from plan 02.1-02 (must preserve MaterialTheme wrapper for legacy auth screens — RESEARCH § Open Questions Q3 RESOLVED)
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt — for resolveGlassBackend signature
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § di/ShellModule (lines 268-289) + § di/AppModule (lines 293-304)
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-17 — debug runtime override mechanism
  </read_first>
  <action>
    Step 1 — create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt`:

    ```kotlin
    package dev.ulfrx.recipe.di

    import com.russhwolf.settings.Settings
    import dev.ulfrx.recipe.ui.components.glass.GlassBackend
    import dev.ulfrx.recipe.ui.components.glass.isDebugBuild
    import dev.ulfrx.recipe.ui.components.glass.resolveGlassBackend
    import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
    import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
    import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
    import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
    import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
    import dev.ulfrx.recipe.ui.screens.shell.ShellViewModel
    import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
    import org.koin.dsl.module
    import org.koin.plugin.module.dsl.viewModel

    /**
     * Phase 2.1 (UI-03 / UI-04 / UI-09 / UI-10) — DI module for the app-shell layer.
     *
     * Registers:
     *  - 4 tab ViewModels (Planner / Recipes / Pantry / Shopping) — pure StateFlow,
     *    no dependencies this phase. Phase 5+ extends each to inject repositories.
     *  - 2 Search ViewModels (Recipes + Pantry) — pure StateFlow with nullable
     *    `searchSource: SearchSource? = null` per RESEARCH § Pattern 4 line 410.
     *  - 1 ShellViewModel — active-tab + search-open state machine.
     *  - 1 GlassBackend single — resolved at composition root from
     *    [resolveGlassBackend] (CONTEXT D-16 / D-17). The default backend chosen here
     *    is [GlassBackend.Liquid] — the iOS+Android primary path; if Liquid fails to
     *    compile for a future target, the per-target source-set actual will pick
     *    [GlassBackend.Haze] or [GlassBackend.Flat] before this resolve runs.
     */
    val shellModule =
        module {
            // Glass backend — resolved once at startup. Production builds short-circuit
            // [resolveGlassBackend] via [isDebugBuild] = false; debug builds may pick up
            // a runtime override stored in `multiplatform-settings`.
            single<GlassBackend> {
                resolveGlassBackend(
                    settings = get<Settings>(),
                    isDebug = isDebugBuild,
                    default = GlassBackend.Liquid,
                )
            }

            // Shell-level state machine.
            viewModel<ShellViewModel>()

            // Tab ViewModels — empty-state-only this phase; feature phases extend them.
            viewModel<PlannerViewModel>()
            viewModel<RecipesViewModel>()
            viewModel<PantryViewModel>()
            viewModel<ShoppingViewModel>()

            // Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject
            // their respective SearchSource implementations.
            viewModel<RecipesSearchViewModel>()
            viewModel<PantrySearchViewModel>()
        }
    ```

    Note on `Settings` provider: `Settings` is already registered in Koin via the
    multiplatform-settings wiring from Phase 1 / Phase 2 (used by `SecureAuthStateStore`).
    If `get<Settings>()` does not resolve (Koin can't find a Settings binding), then
    multiplatform-settings was registered scoped or under a different type. In that
    case, inspect `auth/AuthModule.kt` and the platform-specific Koin modules; either
    promote the Settings binding to a single<Settings> in commonMain shellModule, or
    reuse whatever scope SecureAuthStateStore used.

    Step 2 — modify `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`:

    Replace the existing `appModule` declaration with:
    ```kotlin
    package dev.ulfrx.recipe.di

    import dev.ulfrx.recipe.auth.authModule
    import dev.ulfrx.recipe.user.userModule
    import org.koin.dsl.module

    // Phase 2 added authModule + userModule. Phase 2.1 adds shellModule (UI-03/04/09/10).
    // Phase 4 will add syncModule; Phase 5 will add catalogModule; etc.
    val appModule =
        module {
            includes(authModule, userModule, shellModule)
        }
    ```

    Step 3 — modify `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` to provide `LocalGlassBackend` to all descendants.

    Plan 02.1-02 produced a `RecipeTheme` composable that wraps `MaterialTheme(...)` and
    provides `LocalRecipeColors` / `LocalRecipeTypography` / etc. via
    `CompositionLocalProvider`. THIS plan adds one more local: `LocalGlassBackend`,
    resolved via `koinInject<GlassBackend>()` at startup.

    Read the current RecipeTheme.kt (post plan 02.1-02). Locate the `CompositionLocalProvider(...)` block.
    Add `LocalGlassBackend provides koinInject<GlassBackend>()` to the `provides` list.

    Required additional imports in RecipeTheme.kt:
    ```kotlin
    import dev.ulfrx.recipe.ui.components.glass.GlassBackend
    import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackend
    import org.koin.compose.koinInject
    ```

    Conceptual edit (for guidance — actual line numbers depend on plan 02.1-02's output):

    Before:
    ```kotlin
    @Composable
    fun RecipeTheme(content: @Composable () -> Unit) {
        val colors = if (isSystemInDarkTheme()) DarkRecipeColors else LightRecipeColors
        MaterialTheme(colorScheme = colors.toMaterialColorScheme()) {
            CompositionLocalProvider(
                LocalRecipeColors provides colors,
                LocalRecipeTypography provides RecipeTypographyDefault,
                LocalRecipeSpacing provides RecipeSpacingDefault,
                LocalRecipeShapes provides RecipeShapesDefault,
                LocalRecipeGlass provides RecipeGlassDefault,
            ) {
                content()
            }
        }
    }
    ```

    After:
    ```kotlin
    @Composable
    fun RecipeTheme(content: @Composable () -> Unit) {
        val colors = if (isSystemInDarkTheme()) DarkRecipeColors else LightRecipeColors
        val glassBackend = koinInject<GlassBackend>()
        MaterialTheme(colorScheme = colors.toMaterialColorScheme()) {
            CompositionLocalProvider(
                LocalRecipeColors provides colors,
                LocalRecipeTypography provides RecipeTypographyDefault,
                LocalRecipeSpacing provides RecipeSpacingDefault,
                LocalRecipeShapes provides RecipeShapesDefault,
                LocalRecipeGlass provides RecipeGlassDefault,
                LocalGlassBackend provides glassBackend,
            ) {
                content()
            }
        }
    }
    ```

    The exact symbol names (`LightRecipeColors`, `RecipeTypographyDefault`, `toMaterialColorScheme`)
    depend on what plan 02.1-02 produced. The contract that matters: `LocalGlassBackend`
    is now provided via `koinInject<GlassBackend>()` at the same level as the other Recipe locals.

    Append-only: do not remove any existing `provides` entry. Do not change the
    `MaterialTheme(...)` wrapper (legacy auth screens still depend on it — Open Questions Q3).
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'val shellModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1
    - All 7 VMs registered: `grep -cE 'viewModel<(ShellViewModel|PlannerViewModel|RecipesViewModel|PantryViewModel|ShoppingViewModel|RecipesSearchViewModel|PantrySearchViewModel)>\(\)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 7
    - GlassBackend single registered via resolveGlassBackend: `grep -c 'single<GlassBackend>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1
    - GlassBackend single uses isDebugBuild: `grep -c 'isDebug = isDebugBuild' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1
    - GlassBackend single defaults to Liquid: `grep -c 'default = GlassBackend.Liquid' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1
    - AppModule extended: `grep -c 'shellModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns at least 1
    - AppModule includes 3 modules: `grep -c 'includes(authModule, userModule, shellModule)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns 1
    - RecipeTheme provides LocalGlassBackend: `grep -c 'LocalGlassBackend provides' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` returns 1
    - RecipeTheme uses koinInject<GlassBackend>: `grep -c 'koinInject<GlassBackend>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` returns 1
    - MaterialTheme wrapper preserved (Open Questions Q3): `grep -c 'MaterialTheme' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` returns at least 1
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>shellModule registers 7 VMs + 1 GlassBackend single; AppModule pulls it in; RecipeTheme provides LocalGlassBackend via koinInject so all descendants of RecipeTheme see the resolved backend.</done>
</task>

<task type="auto">
  <name>Task 2: Replace TabHomePlaceholder stubs in RootNavHost.kt with real Tab*Screen calls + per-tab VM scoping</name>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt</files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt — current state from plan 02.1-04 (placeholder stubs in each tab's composable<*Home> block)
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt — from plan 02.1-07
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt — from plan 02.1-07
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt — from plan 02.1-07
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt — from plan 02.1-07
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pattern 2 (lines 343-360) — verbatim koinViewModel(viewModelStoreOwner = parent) idiom
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § Tab screens (lines 206-238) + § App.kt (lines 99-122)
  </read_first>
  <action>
    Open `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` (state from plan 02.1-04 has 4 placeholder `TabHomePlaceholder(...)` calls).

    Replace each `TabHomePlaceholder(name = "...", parent = parent)` call with a real
    `koinViewModel<TabViewModel>(viewModelStoreOwner = parent)` lookup followed by the
    real `Tab*Screen(viewModel = vm)` call. Then DELETE the now-unused
    `TabHomePlaceholder` private composable at the bottom of the file.

    Required new imports:
    ```kotlin
    import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
    import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
    import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
    import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
    import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
    import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
    import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
    import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
    import org.koin.compose.viewmodel.koinViewModel
    ```

    Imports to REMOVE:
    ```kotlin
    import androidx.compose.foundation.text.BasicText  // (or whatever placeholder Text was used)
    import androidx.compose.foundation.layout.Box      // if no longer needed elsewhere in the file
    ```

    Resulting per-tab block (Planner shown — repeat for Recipes / Pantry / Shopping):
    ```kotlin
    navigation<PlannerGraph>(startDestination = PlannerHome) {
        composable<PlannerHome> { entry ->
            val parent = remember(entry) {
                navController.getBackStackEntry(PlannerGraph)
            }
            val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
            PlannerScreen(viewModel = vm)
        }
        // future: composable<PlannerDetail>{ ... }
    }
    ```

    Same shape for the other three tabs:
    ```kotlin
    navigation<RecipesGraph>(startDestination = RecipesHome) {
        composable<RecipesHome> { entry ->
            val parent = remember(entry) { navController.getBackStackEntry(RecipesGraph) }
            val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
            RecipesScreen(viewModel = vm)
        }
    }

    navigation<PantryGraph>(startDestination = PantryHome) {
        composable<PantryHome> { entry ->
            val parent = remember(entry) { navController.getBackStackEntry(PantryGraph) }
            val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent)
            PantryScreen(viewModel = vm)
        }
    }

    navigation<ShoppingGraph>(startDestination = ShoppingHome) {
        composable<ShoppingHome> { entry ->
            val parent = remember(entry) { navController.getBackStackEntry(ShoppingGraph) }
            val vm: ShoppingViewModel = koinViewModel(viewModelStoreOwner = parent)
            ShoppingScreen(viewModel = vm)
        }
    }
    ```

    DELETE the trailing `private fun TabHomePlaceholder(...)` composable that was added
    by plan 02.1-04 — it has no remaining call sites.

    The `// TODO(02.1-08): replace with ...` comments should also be deleted (the work
    they reference is done).
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - All 4 Tab*Screen composables called: `grep -cE '(PlannerScreen|RecipesScreen|PantryScreen|ShoppingScreen)\(viewModel = vm\)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4
    - All 4 koinViewModel calls with viewModelStoreOwner: `grep -c 'koinViewModel(viewModelStoreOwner = parent)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4
    - All 4 getBackStackEntry calls remain: `grep -c 'getBackStackEntry' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4
    - TabHomePlaceholder is deleted: `grep -c 'TabHomePlaceholder' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 0
    - TODO markers from plan 02.1-04 are cleared: `grep -c 'TODO(02.1-08)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 0
    - All 4 navigation<*Graph> blocks preserved: `grep -cE 'navigation<(Planner|Recipes|Pantry|Shopping)Graph>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4
    - startDestination = PlannerGraph preserved: `grep -c 'startDestination = PlannerGraph' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 1
    - Material 3 boundary still preserved: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 0
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>RootNavHost wires the four real tab screens with per-tab VM scoping per RESEARCH § Pattern 2; all placeholder code is gone; tab navigation graph is the production shape feature phases inherit.</done>
</task>

<task type="auto">
  <name>Task 3: Swap App.kt's Authenticated branch from PostLoginPlaceholderScreen to AppShell + extract testable RootRouter</name>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt</files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt — current state (the Authenticated branch on lines 48-58)
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt — AuthState enum/sealed
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt — from plan 02.1-05
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § App.kt (lines 99-122) — modification contract
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md line 101 — keep PostLoginPlaceholderScreen as logout-bridge possibility
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Open Questions Q3 (RESOLVED) — auth screens stay as Material 3 legacy
  </read_first>
  <action>
    Open `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`.

    Step 1 — extract a pure routing helper function so the routing logic is unit-testable
    (V-04 anchor). Add at the top of the file (after imports, before `@Composable fun App()`):

    ```kotlin
    /**
     * Pure routing decision for [App] — facilitates unit testing of the auth gate.
     * Maps an [AuthState] + nullable currentUser to one of three top-level branches.
     */
    enum class RootRoute { Splash, Login, Shell }

    /**
     * Pure helper — returned route is what [App] should render. Unit-tested in
     * AppShellGateTest (V-04).
     */
    internal fun resolveRootRoute(authState: AuthState, hasCurrentUser: Boolean): RootRoute =
        when (authState) {
            AuthState.Loading -> RootRoute.Splash
            AuthState.Unauthenticated -> RootRoute.Login
            AuthState.Authenticated -> if (hasCurrentUser) RootRoute.Shell else RootRoute.Splash
        }
    ```

    Step 2 — modify the `App()` composable body. Replace lines 43-58 (the `when (authState) { ... }` block) with a use of `resolveRootRoute(...)`:

    Before:
    ```kotlin
    when (authState) {
        AuthState.Loading -> SplashScreen()
        AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
        AuthState.Authenticated -> {
            val user = currentUser
            if (user == null) {
                SplashScreen()
            } else {
                PostLoginPlaceholderScreen(
                    user = user,
                    viewModel = koinViewModel<PostLoginViewModel>(),
                )
            }
        }
    }
    ```

    After:
    ```kotlin
    when (resolveRootRoute(authState, hasCurrentUser = currentUser != null)) {
        RootRoute.Splash -> SplashScreen()
        RootRoute.Login -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
        RootRoute.Shell -> AppShell()
    }
    ```

    Step 3 — clean up imports. ADD:
    ```kotlin
    import dev.ulfrx.recipe.ui.screens.shell.AppShell
    ```

    REMOVE (no longer used in the routing branch — but keep them if anything else in the
    file still references them; at the time this plan runs, the only reference site
    was the placeholder branch, so they should be safe to drop):
    ```kotlin
    import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
    import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
    ```

    HOWEVER: per CONTEXT line 101 + RESEARCH § Open Questions Q3 (RESOLVED), DO NOT
    delete the `PostLoginPlaceholderScreen.kt` and `PostLoginViewModel.kt` source files
    themselves. They remain in the codebase as a logout-bridge possibility — a future
    phase may revive them or repurpose them. Only the imports and the call site in App.kt
    are removed.

    Step 4 — preserve the rest of the file:
    - The `@Composable @Preview fun App()` declaration
    - The `RecipeTheme { ... }` wrapper
    - The `koinInject<AuthSession>()` and `koinInject<UserRepository>()` calls
    - The `collectAsStateWithLifecycle()` observations
    - The `LaunchedEffect(authSession) { authSession.initialize() }` block — this is
      load-bearing per CONTEXT and the docstring on line 20-25.
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - Authenticated branch routes to AppShell: `grep -c 'RootRoute.Shell -> AppShell()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - PostLoginPlaceholderScreen no longer called in App.kt: `grep -c 'PostLoginPlaceholderScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 0
    - PostLoginViewModel no longer imported / called in App.kt: `grep -c 'PostLoginViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 0
    - Pure routing helper extracted: `grep -c 'fun resolveRootRoute' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - RootRoute enum declared: `grep -c 'enum class RootRoute' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - LaunchedEffect preserved: `grep -c 'LaunchedEffect(authSession)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - RecipeTheme wrapper preserved: `grep -c 'RecipeTheme {' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - SplashScreen still used: `grep -c 'SplashScreen()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns at least 1
    - LoginScreen still used: `grep -c 'LoginScreen(' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - AppShell imported: `grep -c 'import dev.ulfrx.recipe.ui.screens.shell.AppShell' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
    - PostLoginPlaceholderScreen.kt + PostLoginViewModel.kt source files still exist on disk: `test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt`
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>App() routes Authenticated+user to AppShell instead of PostLoginPlaceholderScreen. The pure routing helper resolveRootRoute is extracted and ready for V-04 unit testing. PostLoginPlaceholderScreen / PostLoginViewModel source files remain on disk per Open Questions Q3.</done>
</task>

<task type="auto">
  <name>Task 4: Replace @Ignore stub in AppShellGateTest.kt with real assertion that resolveRootRoute(Authenticated, hasUser=true) → Shell (V-04)</name>
  <files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt</files>
  <read_first>
    - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt — current Wave-0 stub
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt — for resolveRootRoute helper just-added
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt — AuthState shape
    - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt — kotlin.test pattern shape
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md § Per-Task Verification Map V-04 (line 49)
  </read_first>
  <action>
    Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` with:

    ```kotlin
    package dev.ulfrx.recipe.ui.screens.shell

    import dev.ulfrx.recipe.RootRoute
    import dev.ulfrx.recipe.auth.AuthState
    import dev.ulfrx.recipe.resolveRootRoute
    import kotlin.test.Test
    import kotlin.test.assertEquals

    /**
     * V-04 — UI-09 — App.kt's `Authenticated + currentUser != null` branch resolves to
     * the AppShell route, not PostLoginPlaceholderScreen.
     *
     * Tested via the pure [resolveRootRoute] helper extracted in plan 02.1-08, so the
     * routing semantics are deterministic without instrumenting a real Compose
     * composition. (The CMP iOS Compose UI testing surface is too immature this phase
     * for snapshot/UI tests on the actual `App()` composable — VALIDATION.md line 27.)
     */
    class AppShellGateTest {
        @Test
        fun authenticatedWithUser_routesToShell_notPlaceholder() {
            val route = resolveRootRoute(
                authState = AuthState.Authenticated,
                hasCurrentUser = true,
            )
            assertEquals(RootRoute.Shell, route)
        }

        @Test
        fun authenticatedWithoutUserYet_routesToSplash() {
            // Two-layer gate per App.kt docstring lines 20-25: tokens present but
            // /me has not returned yet → hold on splash, never show empty post-login.
            val route = resolveRootRoute(
                authState = AuthState.Authenticated,
                hasCurrentUser = false,
            )
            assertEquals(RootRoute.Splash, route)
        }

        @Test
        fun unauthenticated_routesToLogin() {
            val route = resolveRootRoute(
                authState = AuthState.Unauthenticated,
                hasCurrentUser = false,
            )
            assertEquals(RootRoute.Login, route)
        }

        @Test
        fun loadingAuth_routesToSplash() {
            val route = resolveRootRoute(
                authState = AuthState.Loading,
                hasCurrentUser = false,
            )
            assertEquals(RootRoute.Splash, route)
        }

        @Test
        fun loadingAuthIgnoresHasCurrentUser() {
            // Defensive: while Loading, we should always splash regardless of whether
            // a stale currentUser is observable from a previous session.
            val route = resolveRootRoute(
                authState = AuthState.Loading,
                hasCurrentUser = true,
            )
            assertEquals(RootRoute.Splash, route)
        }
    }
    ```

    Drop the `@Ignore` import and annotation. Use `kotlin.test` only.

    Note: the imports `dev.ulfrx.recipe.RootRoute` and `dev.ulfrx.recipe.resolveRootRoute`
    target the helpers added in App.kt (top-level declarations in the `dev.ulfrx.recipe`
    package). Confirm the package matches App.kt's `package dev.ulfrx.recipe` line.
    `resolveRootRoute` should be `internal` (visible from commonTest in the same module).
  </action>
  <verify>
    <automated>./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.AppShellGateTest" -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 0
    - `grep -c '@Test' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns at least 5
    - V-04 anchor test name present: `grep -c 'authenticatedWithUser_routesToShell_notPlaceholder' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1
    - Two-layer gate covered: `grep -c 'authenticatedWithoutUserYet_routesToSplash' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1
    - Imports resolveRootRoute: `grep -c 'import dev.ulfrx.recipe.resolveRootRoute' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1
    - Imports RootRoute: `grep -c 'import dev.ulfrx.recipe.RootRoute' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1
    - `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.AppShellGateTest" -q` exits 0
  </acceptance_criteria>
  <done>AppShellGateTest contains 5 passing assertions covering all four AuthState × hasCurrentUser combinations. V-04 anchor backed by real assertions; UI-09's auth-gate-to-shell routing is deterministically tested.</done>
</task>

</tasks>

<verification>
- iOS K/N compile green: `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
- Android compile green: `./gradlew :composeApp:compileDebugKotlinAndroid -q` exits 0
- Full commonTest green: `./gradlew :composeApp:commonTest -q` exits 0
- Full check green: `./gradlew :composeApp:check -q` exits 0
- iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0
- All V-anchors V-01..V-07 are now covered by passing tests (no @Ignore left in any test file): `grep -rE '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ | wc -l` returns 0
- App.kt routes Authenticated+user to AppShell: `grep -c 'RootRoute.Shell -> AppShell()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1
- AppModule pulls in shellModule: `grep -c 'shellModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns at least 1
- Material 3 boundary preserved across plan-08 changes: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns 0
- PostLoginPlaceholderScreen.kt + PostLoginViewModel.kt source files preserved on disk
- Wave 0 ALL test stubs un-ignored across the phase
</verification>

<success_criteria>
1. ShellModule.kt registers 7 ViewModels (ShellViewModel, 4 tab VMs, 2 Search VMs) and 1 GlassBackend single resolved via resolveGlassBackend(get<Settings>(), isDebugBuild, default = GlassBackend.Liquid).
2. AppModule.kt's `includes(...)` pulls in shellModule alongside authModule + userModule.
3. RecipeTheme.kt provides LocalGlassBackend via koinInject<GlassBackend>() at the same level as other Recipe locals; the MaterialTheme(...) wrapper is preserved (Open Questions Q3 RESOLVED — legacy auth screens keep working).
4. RootNavHost.kt's four TabHomePlaceholder stubs are replaced with real `koinViewModel<*ViewModel>(viewModelStoreOwner = parent)` lookups followed by `*Screen(viewModel = vm)` calls (RESEARCH § Pattern 2). The placeholder helper is deleted.
5. App.kt routes `Authenticated + currentUser != null` → `AppShell()` via the extracted pure `resolveRootRoute(...)` helper. `LaunchedEffect(authSession) { initialize() }` and `currentUser == null → SplashScreen()` arms are preserved. PostLoginPlaceholderScreen / PostLoginViewModel source files stay on disk per CONTEXT line 101.
6. V-04 anchor: AppShellGateTest passes 5 assertions covering all AuthState × hasCurrentUser combinations.
7. No @Ignore'd tests remain anywhere in commonTest — all Wave-0 stubs are now backed by real assertions (V-01..V-07).
8. Full `./gradlew :composeApp:check` green.
9. UI-09 final closure: signed-in user lands in the real shell with all four tabs accessible; default landing tab is Planner (D-03); each tab renders its anticipatory empty state (D-10/D-11); search affordance visible only on Recipes + Pantry (D-06).
</success_criteria>

<output>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-08-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record:
- Whether `Settings` was already registered in Koin commonMain (Phase 2 wiring) or whether shellModule had to register it.
- The final exact form of the RecipeTheme.kt edit (which `provides` line was added; preserved structure).
- Confirmation that PostLoginPlaceholderScreen.kt and PostLoginViewModel.kt source files remain on disk (logout-bridge per Open Questions Q3 RESOLVED).
- Manual smoke test results from V-08 / V-09 / V-10 / V-11 (iOS simulator runbook): default Planer landing, tab back-stack preservation across reselect, search affordance scoped to Recipes + Pantry, Liquid dock animation visible (or flat fallback if Liquid did not resolve on the device path).
</output>
