Files

52 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02.1 05 execute 4
02.1-03
02.1-04
02.1-06
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
true
UI-03
UI-04
UI-09
kotlin
compose-multiplatform
shell
dock
viewmodel
glass
compose-unstyled
accessibility
navigation
truths artifacts key_links
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)
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt ShellViewModel + ShellState data class class ShellViewModel
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt AppShell() composable — authenticated root fun AppShell
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt DockBar composable with collapse-on-search animation fun DockBar
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt FloatingSearchButton composable — 44dp circular glass button fun FloatingSearchButton
from to via pattern
ui/screens/shell/AppShell.kt ui/screens/shell/ShellViewModel.kt val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle() ShellViewModel
from to via pattern
ui/screens/shell/AppShell.kt navigation/RootNavHost.kt RootNavHost(navController) renders as the body RootNavHost
from to via pattern
ui/screens/shell/AppShell.kt ui/components/dock/DockBar.kt renders DockBar(... collapsed = ui.searchOpen, onCollapsedTap = { closeActiveSearch() }) DockBar
from to via pattern
ui/screens/shell/AppShell.kt ui/components/dock/FloatingSearchButton.kt conditional render when !ui.searchOpen && activeTab.hasSearch FloatingSearchButton
from to via pattern
ui/components/dock/DockBar.kt ui/components/glass/GlassSurface.kt GlassSurface(cornerRadius = 28.dp / 22.dp) substrate per UI-SPEC line 253 GlassSurface
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).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 After Wave 3 (plan 02.1-06 plus its prerequisites 02.1-03/04) lands:

From plan 02.1-03 (ui/components/glass/):

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/):

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/):

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 2xlxxl (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:

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:

@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.

Task 1: Create ShellViewModel + ShellState (pure StateFlow + method-per-action) composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt - 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 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.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `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' 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 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. Task 2: Create DockBar.kt + FloatingSearchButton.kt — chrome composables consuming GlassSurface composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt - 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 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).
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `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 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. Task 3: Create AppShell.kt — authenticated root composable hosting RootNavHost + bottom chrome overlay composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt - 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 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.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `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 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. - 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).

<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>
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).