---
phase: 02.1
plan: 05
type: execute
wave: 4
depends_on: ["02.1-03", "02.1-04", "02.1-06"]
files_modified:
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt
autonomous: true
requirements: [UI-03, UI-04, UI-09]
tags: [kotlin, compose-multiplatform, shell, dock, viewmodel, glass, compose-unstyled, accessibility, navigation]

must_haves:
  truths:
    - "AppShell is the authenticated root composable; takes no params; consumes koinViewModel<ShellViewModel>() and rememberNavController()"
    - "ShellViewModel exposes ShellState(activeTab, searchOpen) via StateFlow with method-per-action: openSearch / closeSearch / onTabChanged; per-tab query state stays in RecipesSearchViewModel / PantrySearchViewModel from plan 02.1-06"
    - "closeSearch() sets searchOpen=false and AppShell also closes/clears the active tab's SearchViewModel (D-08)"
    - "DockBar renders 4 tabs (icon + label always shown — D-02) when collapsed=false; renders single circular icon-only toggle when collapsed=true (D-05)"
    - "DockBar collapse animation is a single coordinated motion using Modifier.animateContentSize() + AnimatedContent at 250ms FastOutSlowInEasing (UI-SPEC line 198)"
    - "FloatingSearchButton renders a 44dp circular GlassSurface(cornerRadius = 22.dp) with Icons.Outlined.Search; visible only when !searchOpen && activeTab.hasSearch"
    - "AppShell applies GlassBackdropSource behind RootNavHost so Liquid/Haze chrome samples the screen body through the shared LocalGlassBackdropState"
    - "SearchPill reads/writes the active tab SearchViewModel: RecipesSearchViewModel on Recipes, PantrySearchViewModel on Pantry; ShellViewModel only coordinates shell visibility and active tab"
    - "Bottom chrome consumes WindowInsets.navigationBars explicitly; AppShell does NOT use safeContentPadding() to avoid double-inset (Pitfall F)"
    - "Direct Liquid / Haze API imports stay confined to ui/components/glass/ — DockBar / FloatingSearchButton / SearchPill consume GlassSurface only"
    - "Material 3 imports ZERO in any new file (CLAUDE.md / UI-SPEC line 31)"
  artifacts:
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt"
      provides: "ShellViewModel + ShellState data class"
      contains: "class ShellViewModel"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt"
      provides: "AppShell() composable — authenticated root"
      contains: "fun AppShell"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt"
      provides: "DockBar composable with collapse-on-search animation"
      contains: "fun DockBar"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt"
      provides: "FloatingSearchButton composable — 44dp circular glass button"
      contains: "fun FloatingSearchButton"
  key_links:
    - from: "ui/screens/shell/AppShell.kt"
      to: "ui/screens/shell/ShellViewModel.kt"
      via: "val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle()"
      pattern: "ShellViewModel"
    - from: "ui/screens/shell/AppShell.kt"
      to: "navigation/RootNavHost.kt"
      via: "RootNavHost(navController) renders as the body"
      pattern: "RootNavHost"
    - from: "ui/screens/shell/AppShell.kt"
      to: "ui/components/dock/DockBar.kt"
      via: "renders DockBar(... collapsed = ui.searchOpen, onCollapsedTap = { closeActiveSearch() })"
      pattern: "DockBar"
    - from: "ui/screens/shell/AppShell.kt"
      to: "ui/components/dock/FloatingSearchButton.kt"
      via: "conditional render when !ui.searchOpen && activeTab.hasSearch"
      pattern: "FloatingSearchButton"
    - from: "ui/components/dock/DockBar.kt"
      to: "ui/components/glass/GlassSurface.kt"
      via: "GlassSurface(cornerRadius = 28.dp / 22.dp) substrate per UI-SPEC line 253"
      pattern: "GlassSurface"
---

<objective>
Build the four core shell composables — `ShellViewModel` (state machine for activeTab + searchOpen only), `AppShell` (authenticated root composable hosting RootNavHost + bottom chrome overlay), `DockBar` (4-tab Liquid-glass pill that collapses to a single circular icon toggle when search opens — D-05 single coordinated motion), and `FloatingSearchButton` (44dp circular glass button visible only on Recipes + Pantry — D-06). All chrome consumes the `GlassSurface` primitive from plan 02.1-03; layout follows RESEARCH § Code Example 2 (lines 514-565). The dock-collapse-on-search transition is a single `animateContentSize() + AnimatedContent` block driven by `ShellState.searchOpen`.

`SearchPill` is NOT part of this plan — it is owned by plan 02.1-06, and this plan depends on 02.1-06 so `AppShell` can import it directly without temporary stubs. `AppShell` wires that pill to the active tab's search ViewModel (RecipesSearchViewModel or PantrySearchViewModel) rather than duplicating query state in ShellViewModel.

Per CONTEXT D-04 there is no top app bar — tab title is rendered inline by each tab screen (plan 02.1-07). AppShell is purely chrome + NavHost.

Purpose: UI-03 + UI-04 — the floating Liquid-glass dock with bottom-anchored chrome is the visible identity of this phase. UI-09 — the shell exists, replacing the placeholder, so empty states have a place to render (plan 02.1-08 makes the swap; this plan creates the destination composable).
Output: 4 new commonMain files. Build is green; no automated tests added (visible chrome is verified in V-09 / V-11 manual smokes — VALIDATION.md line 54-56).
</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
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt

<interfaces>
After Wave 3 (plan 02.1-06 plus its prerequisites 02.1-03/04) lands:

From plan 02.1-03 (`ui/components/glass/`):
```kotlin
package dev.ulfrx.recipe.ui.components.glass

@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,
)

enum class GlassBackend { Liquid, Haze, Flat }
val LocalGlassBackend: ProvidableCompositionLocal<GlassBackend>
fun resolveGlassBackend(settings: Settings, isDebug: Boolean, default: GlassBackend): GlassBackend
expect val isDebugBuild: Boolean
```

From plan 02.1-04 (`navigation/`):
```kotlin
package dev.ulfrx.recipe.navigation

@Serializable data object PlannerGraph
@Serializable data object RecipesGraph
@Serializable data object PantryGraph
@Serializable data object ShoppingGraph

enum class BottomBarDestination(
    val graphRoute: Any,
    val labelRes: StringResource,
    val icon: ImageVector,
    val hasSearch: Boolean,
    val searchPlaceholder: StringResource?,
) {
    Planner, Recipes, Pantry, Shopping;
    companion object { val Default: BottomBarDestination = Planner }
}

@Composable fun RootNavHost(navController: NavHostController, modifier: Modifier = Modifier)

fun NavHostController.navigateToTab(graphRoute: Any)
```

From plan 02.1-02 (`ui/theme/`):
```kotlin
object RecipeTheme {
    val colors: RecipeColors @Composable @ReadOnlyComposable get()
    val typography: RecipeTypography @Composable @ReadOnlyComposable get()
    val spacing: RecipeSpacing @Composable @ReadOnlyComposable get()
    val shapes: RecipeShapes @Composable @ReadOnlyComposable get()
    val glass: RecipeGlass @Composable @ReadOnlyComposable get()
}
// RecipeColors: background, surface, surfaceGlass, content, contentMuted, accent, separator, borderCard, destructive (all Color)
// RecipeTypography: display, title, body, label (all TextStyle)
// RecipeSpacing: xs (4dp), sm (8dp), lg (16dp), xl (24dp), `2xl` (32dp), `3xl` (48dp) — accessor names use Kotlin valid identifiers (likely `xs`, `sm`, `lg`, `xl`, `xxl`, `xxxl` or backticked — verify exact names from RecipeSpacing.kt)
```

NOTE: Verify RecipeSpacing accessor names by reading the file before use. UI-SPEC § Spacing names them `xs/sm/lg/xl/2xl/3xl` but Kotlin identifiers cannot start with a digit, so plan 02.1-02 must have remapped `2xl` → `xxl` (or backticked them). Treat the canonical accessor names as whatever plan 02.1-02 produced; UI-SPEC's friendly names are a contract on VALUES, not on identifier names.

LoginViewModel pattern (`LoginViewModel.kt:37-55`) — mirror this shape:
```kotlin
class XxxViewModel(...) : ViewModel() {
    private val _state = MutableStateFlow(XxxState())
    val state: StateFlow<XxxState> = _state.asStateFlow()
    fun action() { _state.update { ... } }
}
```

LoginScreen pattern (`LoginScreen.kt:39-43`) — mirror VM observation:
```kotlin
@Composable
fun XxxScreen(viewModel: XxxViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    // ...
}
```

Compose Unstyled API (`com.composables:composeunstyled:1.49.9`) — used by DockBar:
- `TabGroup` renderless primitive — explore the artifact's exports; if a `TabGroup`-equivalent does not exist in 1.49, fall back to a `Row { ... }` with `Modifier.semantics { role = Role.Tab; selected = isActive }` per UI-SPEC line 220. Compose Unstyled's exact `TabGroup` shape is API-specific and the artifact should be inspected at implementation time. RESEARCH § Standard Stack line 137 names the artifact but does not pin the specific `TabGroup` API; UI-SPEC line 180 says "TabGroup-equivalent" — meaning either the library's primitive OR a custom `Row + Tab` shape is acceptable provided the a11y semantics are correct.

Compose Unstyled `Button` (UI-SPEC line 181) — used by FloatingSearchButton. Same pragmatic note: use the primitive if available; otherwise `Modifier.clickable()` on a `Box`.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create ShellViewModel + ShellState (pure StateFlow + method-per-action)</name>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt</files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt — LoginViewModel.kt:37-55 — analog VM shape (StateFlow + method-per-action)
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § ShellViewModel (lines 151-179) — ShellState fields + methods
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-08 (line 35) — closing search clears query; AppShell delegates query clearing to the active tab SearchViewModel from plan 02.1-06
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — for BottomBarDestination.Default
  </read_first>
  <action>
    Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt`:

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

    import androidx.lifecycle.ViewModel
    import dev.ulfrx.recipe.navigation.BottomBarDestination
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.StateFlow
    import kotlinx.coroutines.flow.asStateFlow
    import kotlinx.coroutines.flow.update

    /**
     * Immutable UI state for [AppShell]. The shell tracks three things:
     *  - [activeTab] which tab is currently selected (mirrors NavHost back-stack head).
     *  - [searchOpen] whether the search affordance is open (D-06: only valid when
     *    [activeTab].hasSearch is true).
     *
     * Query text deliberately lives in the active tab's SearchViewModel
     * (RecipesSearchViewModel or PantrySearchViewModel from plan 02.1-06). This keeps
     * Phase 5's extension hook connected to the UI that the user actually sees.
     */
    data class ShellState(
        val activeTab: BottomBarDestination = BottomBarDestination.Default,
        val searchOpen: Boolean = false,
    )

    /**
     * Active-tab + search state machine for the shell. Pure synchronous state
     * transitions — no I/O, no viewModelScope.launch. Mirrors [LoginViewModel]'s
     * VM+StateFlow+method-per-action shape (CLAUDE.md project convention).
     *
     * Note: per-tab Search VMs (Recipes, Pantry — plan 02.1-06) own query and clear
     * behavior. ShellViewModel mirrors search OPEN status here so the dock and floating
     * button can react synchronously.
     */
    class ShellViewModel : ViewModel() {
        private val _state = MutableStateFlow(ShellState())
        val state: StateFlow<ShellState> = _state.asStateFlow()

        /** D-05 / D-06: open the search affordance on the active tab. No-op if the
         *  active tab has no search (defensive — UI is supposed to gate the call). */
        fun openSearch() {
            _state.update { current ->
                if (!current.activeTab.hasSearch) current
                else current.copy(searchOpen = true)
            }
        }

        /** D-08 shell half: closing hides search. AppShell also calls activeSearchVm.close(). */
        fun closeSearch() {
            _state.update { it.copy(searchOpen = false) }
        }

        /** Tab change — also closes any open search per D-08 (closing on tab switch is
         *  the same semantic: search state does not persist across tab switch). */
        fun onTabChanged(dest: BottomBarDestination) {
            _state.update { ShellState(activeTab = dest, searchOpen = false) }
        }
    }
    ```

    NOTE: this VM is registered in Koin's `shellModule` by plan 02.1-08 — not here. This plan only declares the type so AppShell (next task) can reference it.
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'data class ShellState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1
    - `grep -c 'class ShellViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1
    - `grep -c 'val state: StateFlow<ShellState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1
    - All 3 shell actions defined: `grep -cE 'fun (openSearch|closeSearch|onTabChanged)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 3
    - ShellState has no query field: `grep -c 'val query' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0
    - ShellViewModel has no onQueryChange/clearQuery methods: `grep -cE 'fun (onQueryChange|clearQuery)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0
    - Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>ShellViewModel mirrors the LoginViewModel pattern with StateFlow + 3 method-per-action signatures; query state stays in the tab SearchViewModels from plan 02.1-06; onTabChanged resets search visibility on tab switch.</done>
</task>

<task type="auto">
  <name>Task 2: Create DockBar.kt + FloatingSearchButton.kt — chrome composables consuming GlassSurface</name>
  <files>
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt,
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt
  </files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt — public API just landed in plan 02.1-03
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — enum shape from plan 02.1-04
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — for token accessor verification (RecipeTheme.spacing/typography/colors)
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 180 (DockBar shape) + line 181 (FloatingSearchButton)
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Interaction Contracts (lines 192-216) — collapse animation contract
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Glass / Liquid contract (lines 248-256) — corner radius 28dp dock / 22dp collapsed / 22dp button
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Accessibility (lines 219-226) — Role.Tab + contentDescription
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-01 / D-02 / D-05 / D-06 — dock geometry + collapse contract
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § DockBar lines 317-327 + § FloatingSearchButton lines 332-337
  </read_first>
  <action>
    Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt`:

    ```kotlin
    package dev.ulfrx.recipe.ui.components.dock

    import androidx.compose.animation.AnimatedContent
    import androidx.compose.animation.core.FastOutSlowInEasing
    import androidx.compose.animation.core.tween
    import androidx.compose.animation.animateContentSize
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.Row
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.foundation.layout.defaultMinSize
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.layout.size
    import androidx.compose.foundation.layout.width
    import androidx.compose.foundation.text.BasicText
    import androidx.compose.foundation.shape.RoundedCornerShape
    import androidx.compose.material.icons.Icons
    import androidx.compose.runtime.Composable
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.draw.clip
    import androidx.compose.ui.graphics.vector.rememberVectorPainter
    import androidx.compose.ui.semantics.Role
    import androidx.compose.ui.semantics.role
    import androidx.compose.ui.semantics.selected
    import androidx.compose.ui.semantics.semantics
    import androidx.compose.ui.semantics.contentDescription
    import androidx.compose.ui.unit.dp
    import dev.ulfrx.recipe.navigation.BottomBarDestination
    import dev.ulfrx.recipe.ui.components.glass.GlassSurface
    import dev.ulfrx.recipe.ui.theme.RecipeTheme
    import org.jetbrains.compose.resources.stringResource

    /**
     * Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
     *
     * - Expanded (collapsed=false): all 4 tabs, icon + label always shown (D-02), active
     *   tab visually emphasized (wider cell + accent foreground per UI-SPEC § Color
     *   "Accent reserved for"). Capsule shape: 28dp corner radius, 56dp height.
     *
     * - Collapsed (collapsed=true): single circular cell showing only the active tab's
     *   icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes
     *   [onCollapsedTap] which closes the search per D-05.
     *
     * Single coordinated animation per D-05: the entire dock animates as one block via
     * [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
     * [FastOutSlowInEasing] per UI-SPEC line 198. Phase 10 may tune timing on real
     * device.
     *
     * Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid/Haze API calls are
     * forbidden here per RESEARCH § Anti-Patterns and CLAUDE.md non-negotiable #10.
     *
     * Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
     */
    @Composable
    fun DockBar(
        destinations: List<BottomBarDestination>,
        active: BottomBarDestination,
        collapsed: Boolean,
        onTabSelect: (BottomBarDestination) -> Unit,
        onCollapsedTap: () -> Unit,
        modifier: Modifier = Modifier,
    ) {
        val cornerRadius = if (collapsed) 22.dp else 28.dp
        val height = if (collapsed) 44.dp else 56.dp

        GlassSurface(
            modifier = modifier
                .height(height)
                .animateContentSize(animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)),
            cornerRadius = cornerRadius,
        ) {
            AnimatedContent(
                targetState = collapsed,
                transitionSpec = {
                    androidx.compose.animation.fadeIn(tween(250, easing = FastOutSlowInEasing)) togetherWith
                        androidx.compose.animation.fadeOut(tween(250, easing = FastOutSlowInEasing))
                },
                label = "DockBar collapse",
            ) { isCollapsed ->
                if (isCollapsed) {
                    CollapsedDockToggle(
                        active = active,
                        onTap = onCollapsedTap,
                    )
                } else {
                    ExpandedDockTabs(
                        destinations = destinations,
                        active = active,
                        onTabSelect = onTabSelect,
                    )
                }
            }
        }
    }

    @Composable
    private fun ExpandedDockTabs(
        destinations: List<BottomBarDestination>,
        active: BottomBarDestination,
        onTabSelect: (BottomBarDestination) -> Unit,
    ) {
        Row(
            modifier = Modifier.padding(horizontal = RecipeTheme.spacing.sm),
            horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            destinations.forEach { dest ->
                val isActive = dest == active
                DockTabCell(
                    destination = dest,
                    isActive = isActive,
                    onClick = { onTabSelect(dest) },
                )
            }
        }
    }

    @Composable
    private fun DockTabCell(
        destination: BottomBarDestination,
        isActive: Boolean,
        onClick: () -> Unit,
    ) {
        val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
        val labelText = stringResource(destination.labelRes)
        Row(
            modifier = Modifier
                .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)
                .clip(RoundedCornerShape(22.dp))
                .clickableNoRipple(onClick = onClick)
                .padding(horizontal = RecipeTheme.spacing.sm, vertical = RecipeTheme.spacing.xs)
                .semantics {
                    role = Role.Tab
                    selected = isActive
                    contentDescription = labelText + (if (isActive) ", aktywna" else "")
                },
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
        ) {
            androidx.compose.foundation.Image(
                painter = rememberVectorPainter(image = destination.icon),
                contentDescription = null,
                colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(tint),
                modifier = Modifier.size(20.dp),
            )
            BasicText(
                text = labelText,
                style = RecipeTheme.typography.label.copy(color = tint),
            )
        }
    }

    @Composable
    private fun CollapsedDockToggle(
        active: BottomBarDestination,
        onTap: () -> Unit,
    ) {
        val a11yLabel = stringResource(recipe.composeapp.generated.resources.Res.string.search_close_a11y)
        Box(
            modifier = Modifier
                .size(44.dp)
                .clip(RoundedCornerShape(22.dp))
                .clickableNoRipple(onClick = onTap)
                .semantics { contentDescription = a11yLabel },
            contentAlignment = Alignment.Center,
        ) {
            androidx.compose.foundation.Image(
                painter = rememberVectorPainter(image = active.icon),
                contentDescription = null,
                colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(RecipeTheme.colors.accent),
                modifier = Modifier.size(22.dp),
            )
        }
    }

    /**
     * Internal helper — clickable without ripple (we're inside a glass substrate; ripple
     * is provided by Material 3 which is forbidden in shell code per UI-SPEC line 31).
     * Phase 10 may add a custom Liquid-aware press indication.
     */
    @Composable
    private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier =
        this.then(
            Modifier.semantics(mergeDescendants = false) {}
        ).then(
            // foundation.clickable provides press semantics + a11y without forcing Material ripple.
            androidx.compose.foundation.clickable(
                interactionSource = androidx.compose.foundation.interaction.MutableInteractionSource(),
                indication = null,
                onClick = onClick,
            )
        )
    ```

    Implementation note 1: the `clickableNoRipple` extension above sketches the intent
    but the API used inside `then(Modifier.foundation.clickable(...))` is invalid Kotlin
    syntax — the executor must conform to the actual `Modifier.clickable(...)` extension
    (it is itself a Modifier extension, not a standalone Modifier). Recommended actual
    implementation:
    ```kotlin
    @Composable
    private fun Modifier.tabClickable(onClick: () -> Unit): Modifier {
        val interactionSource = remember { MutableInteractionSource() }
        return this.clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick,
        )
    }
    ```
    Required imports: `androidx.compose.foundation.clickable`, `androidx.compose.foundation.interaction.MutableInteractionSource`, `androidx.compose.runtime.remember`.

    Implementation note 2: the `search_close_a11y` resource key is added by plan 02.1-04.
    This plan must only verify the key exists; do not edit strings.xml in plan 02.1-05.

    Implementation note 3: `Compose Unstyled TabGroup` was the spec'd primitive (UI-SPEC
    line 180). If the artifact's `TabGroup` API does not match the shape used here
    (separate cells with `Modifier.semantics { role = Role.Tab }`), use the artifact's
    primitive instead. The only contract that MUST hold: each cell has `role = Role.Tab`,
    `selected = isActive`, and a meaningful `contentDescription`. PATTERNS.md § DockBar
    line 326 confirms either path is acceptable.

    Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt`:

    ```kotlin
    package dev.ulfrx.recipe.ui.components.dock

    import androidx.compose.foundation.Image
    import androidx.compose.foundation.clickable
    import androidx.compose.foundation.interaction.MutableInteractionSource
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.size
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.outlined.Search
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.remember
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.graphics.ColorFilter
    import androidx.compose.ui.graphics.vector.rememberVectorPainter
    import androidx.compose.ui.semantics.contentDescription
    import androidx.compose.ui.semantics.semantics
    import androidx.compose.ui.unit.dp
    import dev.ulfrx.recipe.ui.components.glass.GlassSurface
    import dev.ulfrx.recipe.ui.theme.RecipeTheme
    import org.jetbrains.compose.resources.stringResource
    import recipe.composeapp.generated.resources.Res
    import recipe.composeapp.generated.resources.search_open_a11y

    /**
     * 44dp circular Liquid-glass button per UI-SPEC line 181.
     *
     * Visible only on Recipes + Pantry tabs (D-06 — gated by AppShell, not here).
     * Hidden when search is open (also gated by AppShell — see plan 02.1-05 AppShell.kt).
     *
     * Substrate: [GlassSurface] cornerRadius=22dp = full-circle at 44dp.
     * Icon: [Icons.Outlined.Search] tinted [RecipeTheme.colors.content].
     * Accessibility: [contentDescription] = stringResource(search_open_a11y) per UI-SPEC line 221.
     */
    @Composable
    fun FloatingSearchButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
    ) {
        val interactionSource = remember { MutableInteractionSource() }
        val a11y = stringResource(Res.string.search_open_a11y)
        GlassSurface(
            modifier = modifier
                .size(44.dp)
                .clickable(
                    interactionSource = interactionSource,
                    indication = null,
                    onClick = onClick,
                )
                .semantics { contentDescription = a11y },
            cornerRadius = 22.dp,
        ) {
            Box(
                modifier = Modifier.size(44.dp),
                contentAlignment = Alignment.Center,
            ) {
                Image(
                    painter = rememberVectorPainter(image = Icons.Outlined.Search),
                    contentDescription = null,
                    colorFilter = ColorFilter.tint(RecipeTheme.colors.content),
                    modifier = Modifier.size(20.dp),
                )
            }
        }
    }
    ```

    Implementation note 4: `search_open_a11y` resource key is also owned by plan 02.1-04.
    This plan must only verify the key exists; do not edit strings.xml in plan 02.1-05.

    Material 3 boundary: NEITHER file may import `androidx.compose.material3.*`. Use
    `androidx.compose.material.icons.outlined.*` (icons-extended is fine — it's the
    icon set artifact, not Material 3 components).
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'fun DockBar' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns 1
    - `grep -c 'animateContentSize' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c 'AnimatedContent' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c 'FastOutSlowInEasing' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c 'durationMillis = 250' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c 'role = Role.Tab' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c 'selected = isActive' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c '28.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c '22.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c '56.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c '44.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
    - `grep -c 'fun FloatingSearchButton' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns 1
    - `grep -c 'GlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns at least 1
    - `grep -c 'Icons.Outlined.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns 1
    - `grep -c 'search_open_a11y' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns at least 1
    - Material 3 boundary in dock package: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/` returns 0
    - Direct Liquid / Haze imports forbidden in dock package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/ | wc -l` returns 0
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>DockBar renders 4-tab expanded form (icon + label) and collapses to a single circular toggle on the active tab; transition is one coordinated animateContentSize + AnimatedContent block at 250ms FastOutSlowInEasing. FloatingSearchButton is 44dp circular GlassSurface with the search icon. Both consume GlassSurface only — no direct Liquid/Haze imports.</done>
</task>

<task type="auto">
  <name>Task 3: Create AppShell.kt — authenticated root composable hosting RootNavHost + bottom chrome overlay</name>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt</files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt — just-created
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt — just-created
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt — just-created
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt — from plan 02.1-04
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — from plan 02.1-04
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Code Example 2 (lines 514-565) — verbatim AppShell skeleton
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pitfall F (lines 471-473) — inset handling: navigationBars + ime, NOT safeContentPadding
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § AppShell.kt (lines 184-203)
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Layout & Safe Area (lines 268-272)
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt — VM observation pattern via koinViewModel + collectAsStateWithLifecycle
  </read_first>
  <action>
    Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt`:

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

    import androidx.compose.foundation.background
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.PaddingValues
    import androidx.compose.foundation.layout.WindowInsets
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.imePadding
    import androidx.compose.foundation.layout.navigationBars
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.layout.windowInsetsPadding
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.remember
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.lifecycle.compose.collectAsStateWithLifecycle
    import androidx.navigation.compose.currentBackStackEntryAsState
    import androidx.navigation.compose.rememberNavController
    import dev.ulfrx.recipe.navigation.BottomBarDestination
    import dev.ulfrx.recipe.navigation.PantryGraph
    import dev.ulfrx.recipe.navigation.PlannerGraph
    import dev.ulfrx.recipe.navigation.RecipesGraph
    import dev.ulfrx.recipe.navigation.RootNavHost
    import dev.ulfrx.recipe.navigation.ShoppingGraph
    import dev.ulfrx.recipe.navigation.navigateToTab
    import dev.ulfrx.recipe.ui.components.dock.DockBar
    import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
    import dev.ulfrx.recipe.ui.components.search.SearchPill
    import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
    import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
    import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
    import dev.ulfrx.recipe.ui.theme.RecipeTheme
    import org.jetbrains.compose.resources.stringResource
    import org.koin.compose.viewmodel.koinViewModel

    /**
     * Authenticated root composable per RESEARCH § Code Example 2 (lines 514-565).
     *
     * Layout responsibilities:
     *  - Background: full-screen [RecipeTheme.colors.background] under the safe area.
     *  - Body: [RootNavHost] consumes the full screen.
     *  - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill]
     *    (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
     *    Chrome consumes [WindowInsets.navigationBars] explicitly — Pitfall F (RESEARCH
     *    lines 471-473): do NOT also use safeContentPadding() at this layer; tab body
     *    consumes top inset (status bars) inside each tab screen.
     *  - [FloatingSearchButton] aligned [Alignment.BottomEnd], visible only when
     *    !ui.searchOpen && active.hasSearch (D-06).
     *
     * Active-tab tracking: derived from the NavHost's current back stack entry's route.
     * The shell's [ShellViewModel] mirrors active tab so chrome can react synchronously
     * to tab switches even before NavHost navigation completes.
     */
    @Composable
    fun AppShell(modifier: Modifier = Modifier) {
        val navController = rememberNavController()
        val backStack by navController.currentBackStackEntryAsState()
        val activeTab = remember(backStack) {
            backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
        }

        val vm: ShellViewModel = koinViewModel()
        val ui by vm.state.collectAsStateWithLifecycle()
        val recipesSearchVm: RecipesSearchViewModel = koinViewModel()
        val recipesSearch by recipesSearchVm.state.collectAsStateWithLifecycle()
        val pantrySearchVm: PantrySearchViewModel = koinViewModel()
        val pantrySearch by pantrySearchVm.state.collectAsStateWithLifecycle()

        fun closeActiveSearch() {
            when (activeTab) {
                BottomBarDestination.Recipes -> recipesSearchVm.close()
                BottomBarDestination.Pantry -> pantrySearchVm.close()
                else -> Unit
            }
            vm.closeSearch()
        }

        // Sync ShellViewModel.activeTab with NavHost-derived activeTab for
        // back-button + deep-link cases (NavHost is the source of truth on tab change
        // when navigation goes through navigateToTab; this sync handles all other paths).
        if (ui.activeTab != activeTab) {
            // Idempotent — onTabChanged also clears any open search per D-08.
            vm.onTabChanged(activeTab)
        }

        Box(
            modifier = modifier
                .fillMaxSize()
                .background(RecipeTheme.colors.background),
        ) {
            // Body — RootNavHost fills the available space and is the shared source layer
            // for Liquid/Haze chrome sampling via GlassBackdropSource (plan 02.1-03).
            GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
                RootNavHost(
                    navController = navController,
                    modifier = Modifier.fillMaxSize(),
                )
            }

            // Bottom chrome overlay — Column anchored to bottom-center.
            Column(
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .windowInsetsPadding(WindowInsets.navigationBars)
                    .imePadding()  // UI-SPEC line 271 — search pill rides above keyboard
                    .padding(bottom = RecipeTheme.spacing.sm),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
            ) {
                if (ui.searchOpen && activeTab.hasSearch) {
                    val placeholderRes = activeTab.searchPlaceholder
                    if (placeholderRes != null) {
                        val activeSearch = when (activeTab) {
                            BottomBarDestination.Recipes -> recipesSearch
                            BottomBarDestination.Pantry -> pantrySearch
                            else -> null
                        }
                        val activeSearchVm = when (activeTab) {
                            BottomBarDestination.Recipes -> recipesSearchVm
                            BottomBarDestination.Pantry -> pantrySearchVm
                            else -> null
                        }
                        SearchPill(
                            query = activeSearch?.query.orEmpty(),
                            onQueryChange = { activeSearchVm?.onQueryChange(it) },
                            onClear = { activeSearchVm?.clear() },
                            onClose = { closeActiveSearch() },
                            placeholder = stringResource(placeholderRes),
                        )
                    }
                }

                DockBar(
                    destinations = BottomBarDestination.entries,
                    active = activeTab,
                    collapsed = ui.searchOpen,
                    onTabSelect = { dest ->
                        navController.navigateToTab(dest.graphRoute)
                        vm.onTabChanged(dest)
                    },
                    onCollapsedTap = { closeActiveSearch() },
                )
            }

            // Floating search button — adjacent to dock per D-06, visible only on
            // tabs that have search and only when search is closed.
            if (!ui.searchOpen && activeTab.hasSearch) {
                FloatingSearchButton(
                    onClick = {
                        when (activeTab) {
                            BottomBarDestination.Recipes -> recipesSearchVm.open()
                            BottomBarDestination.Pantry -> pantrySearchVm.open()
                            else -> Unit
                        }
                        vm.openSearch()
                    },
                    modifier = Modifier
                        .align(Alignment.BottomEnd)
                        .windowInsetsPadding(WindowInsets.navigationBars)
                        .padding(end = RecipeTheme.spacing.lg, bottom = RecipeTheme.spacing.sm),
                )
            }
        }
    }

    /**
     * Maps a [androidx.navigation.NavBackStackEntry]'s current route hierarchy to a
     * [BottomBarDestination]. Reads the *parent graph* route on the back stack, since
     * each tab is a nested graph.
     */
    private fun androidx.navigation.NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? {
        if (this == null) return null
        // Inspect the destination hierarchy for the parent graph route.
        // CMP nav-compose 2.9.2: NavDestination.hierarchy yields parent-to-child sequence.
        val hierarchy = destination.hierarchy
        return when {
            hierarchy.any { it.hasRoute(PlannerGraph::class) } -> BottomBarDestination.Planner
            hierarchy.any { it.hasRoute(RecipesGraph::class) } -> BottomBarDestination.Recipes
            hierarchy.any { it.hasRoute(PantryGraph::class) } -> BottomBarDestination.Pantry
            hierarchy.any { it.hasRoute(ShoppingGraph::class) } -> BottomBarDestination.Shopping
            else -> null
        }
    }
    ```

    The `hasRoute(PlannerGraph::class)` API is the type-safe destination matcher in
    nav-compose 2.9.x. If the precise extension is unavailable, fall back to comparing
    `destination.route` strings (the string-form route is the FQN of the @Serializable
    type).

    Required imports for the helper at the bottom:
    ```kotlin
    import androidx.navigation.NavBackStackEntry  // for receiver type
    import androidx.navigation.NavDestination.Companion.hasRoute
    import androidx.navigation.NavDestination.Companion.hierarchy
    ```

    Implementation note: this plan depends on 02.1-06, so `SearchPill`,
    `RecipesSearchViewModel`, and `PantrySearchViewModel` already exist before AppShell
    compiles. Do not create local stubs.
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'fun AppShell' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
    - `grep -c 'rememberNavController' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
    - `grep -c 'RootNavHost' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'koinViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'collectAsStateWithLifecycle' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 3
    - `grep -c 'DockBar' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'FloatingSearchButton' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'SearchPill' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'GlassBackdropSource' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'RecipesSearchViewModel\\|PantrySearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 2
    - `grep -c 'activeSearchVm?.onQueryChange' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
    - `grep -c 'fun closeActiveSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
    - `grep -c 'onCollapsedTap = { closeActiveSearch() }' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
    - `grep -c 'WindowInsets.navigationBars' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'imePadding' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'safeContentPadding' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 0 (Pitfall F — must NOT use safeContentPadding here)
    - `grep -c 'navigateToTab' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - `grep -c 'collapsed = ui.searchOpen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
    - Conditional render of FloatingSearchButton: `grep -c '!ui.searchOpen && activeTab.hasSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - Conditional render of SearchPill: `grep -c 'ui.searchOpen && activeTab.hasSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
    - Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 0
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>
    AppShell hosts RootNavHost as body inside GlassBackdropSource + DockBar / FloatingSearchButton / SearchPill as bottom chrome overlay; consumes navigationBars + ime insets explicitly per Pitfall F; renders FloatingSearchButton only on tabs where activeTab.hasSearch is true and searchOpen is false; SearchPill reads/writes the active tab SearchViewModel.
  </done>
</task>

</tasks>

<verification>
- iOS K/N compile green (after prerequisite plans 02.1-03 + 02.1-04 + 02.1-06 have landed):
  - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
- iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0
- Material 3 boundary preserved across all 4 new files: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/` returns 0
- Liquid / Haze imports confined to glass package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/ | wc -l` returns 0
- ShellViewModel state machine semantics: closeSearch hides the search surface; AppShell delegates close/clear/query changes to the active tab SearchViewModel; onTabChanged resets shell search visibility on tab switch.
- AppShell uses navigationBars + ime padding explicitly; safeContentPadding() is NOT used at AppShell layer.
- V-09 + V-11 manual smoke prerequisites in place: dock collapse animation can be observed; Liquid backend renders chrome (when build resolves Liquid for the target).
</verification>

<success_criteria>
1. ShellViewModel mirrors LoginViewModel's StateFlow + method-per-action shape with 3 shell actions: openSearch / closeSearch / onTabChanged. Query state lives in RecipesSearchViewModel / PantrySearchViewModel from plan 02.1-06.
2. DockBar renders 4 tabs (icon + label always — D-02) when expanded, collapses to single circular icon-only toggle on the active tab when search opens (D-05). Single coordinated animation: animateContentSize + AnimatedContent at 250ms FastOutSlowInEasing. Each tab cell has Role.Tab + selected + contentDescription (UI-SPEC line 220).
3. FloatingSearchButton is a 44dp circular GlassSurface(cornerRadius = 22.dp) with Icons.Outlined.Search and search_open_a11y description.
4. AppShell hosts RootNavHost inside GlassBackdropSource (body) + DockBar (always-present chrome) + FloatingSearchButton (visible only when !searchOpen && activeTab.hasSearch) + SearchPill (rendered conditionally and wired to the active tab SearchViewModel from plan 02.1-06).
5. AppShell consumes WindowInsets.navigationBars + imePadding() explicitly; safeContentPadding() is NOT used (Pitfall F).
6. Direct Liquid / Haze imports zero in the shell + dock packages — chrome consumes GlassSurface only.
7. Material 3 boundary preserved: zero `androidx.compose.material3` imports in any of the 4 new files.
</success_criteria>

<output>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-05-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record:
- Whether AppShell's active-tab SearchViewModel wiring covered both Recipes and Pantry paths in the final implementation.
- Whether `Compose Unstyled TabGroup` API was used in DockBar or the Row + semantics fallback.
- Whether `hasRoute(*Graph::class)` worked or the string-route comparison was needed for the activeTab derivation in AppShell.
- Final touch-target measurements for the dock cells (≥ 44dp confirmed by visual inspection in iOS sim during 02.1-08's manual smoke).
</output>
