---
phase: 02.1
plan: 05
subsystem: ui-shell
tags: [kotlin, compose-multiplatform, shell, dock, viewmodel, glass, accessibility, navigation]
requires:
  - 02.1-03  # GlassSurface + GlassBackdropSource
  - 02.1-04  # BottomBarDestination + RootNavHost + navigateToTab + a11y string keys
  - 02.1-06  # SearchPill + RecipesSearchViewModel + PantrySearchViewModel
provides:
  - "ShellViewModel + ShellState (StateFlow + method-per-action)"
  - "AppShell() — authenticated root composable"
  - "DockBar() — collapsible 4-tab Liquid-glass dock"
  - "FloatingSearchButton() — 44dp circular glass button"
affects:
  - "Empty placeholder app target now has the destination composable for the post-auth shell (plan 02.1-08 wires it in)."
tech-stack:
  added: []
  patterns:
    - "VM + StateFlow + method-per-action (mirrors LoginViewModel)"
    - "animateContentSize + AnimatedContent single-block animation at 250ms FastOutSlowInEasing"
    - "Type-safe NavBackStackEntry → BottomBarDestination derivation via hasRoute(*Graph::class)"
key-files:
  created:
    - 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
  modified: []
decisions:
  - "ShellViewModel holds activeTab + searchOpen only; query state lives in per-tab Search VMs (RecipesSearchViewModel, PantrySearchViewModel) so Phase 5's extension hook stays connected to the UI."
  - "DockBar uses Row + Modifier.semantics{role=Role.Tab; selected; contentDescription} (the UI-SPEC-line-180 'TabGroup-equivalent fallback') instead of Compose Unstyled's TabGroup primitive — the renderless TabGroup did not match the desired per-cell semantics shape; PATTERNS.md § DockBar line 326 explicitly accepts this path."
  - "Active tab derivation uses type-safe hasRoute(*Graph::class) on the destination hierarchy — no string-route fallback was needed."
  - "ShellViewModel ↔ NavHost sync uses a LaunchedEffect(activeTab) instead of an inline if-state-check, to avoid composition-side-effect pitfalls."
metrics:
  completed: 2026-05-08
  duration: ~25 minutes
---

# Phase 02.1 Plan 05: App Shell Composables Summary

Built the four core authenticated-shell composables — `ShellViewModel`, `AppShell`, `DockBar`, `FloatingSearchButton` — wiring RootNavHost (02.1-04) inside a GlassBackdropSource (02.1-03) and overlaying a bottom chrome column with the SearchPill (02.1-06), DockBar, and FloatingSearchButton.

## What Was Built

1. **ShellViewModel + ShellState** — pure synchronous state machine with three method-per-action signatures (`openSearch`, `closeSearch`, `onTabChanged`). State is `(activeTab, searchOpen)` only — no query field; per-tab query state lives in `RecipesSearchViewModel` / `PantrySearchViewModel`. Mirrors LoginViewModel's StateFlow shape.

2. **DockBar** — Liquid-glass capsule rendering 4 tabs (icon + label always shown, D-02) when expanded (28dp corner, 56dp tall) and collapsing to a single circular icon-only toggle on the active tab when search opens (22dp corner, 44dp tall, D-05). The collapse is one coordinated motion: `animateContentSize` on the GlassSurface modifier plus `AnimatedContent` with a fade `togetherWith` transition, both at 250ms FastOutSlowInEasing per UI-SPEC line 198. Each tab cell exposes `Role.Tab + selected + contentDescription` semantics; cells satisfy ≥44dp touch targets via `defaultMinSize(44dp, 44dp)`.

3. **FloatingSearchButton** — 44dp `GlassSurface(cornerRadius = 22.dp)` with `Icons.Outlined.Search` tinted `RecipeTheme.colors.content`. Carries `search_open_a11y` contentDescription. Visibility (only when `!searchOpen && activeTab.hasSearch`) is gated by AppShell, not the button itself.

4. **AppShell** — authenticated root composable. Wraps `RootNavHost` in `GlassBackdropSource` so Liquid/Haze backends sample the body through the shared `LocalGlassBackdropState`. Bottom chrome is a `Column` aligned `BottomCenter` with `windowInsetsPadding(WindowInsets.navigationBars) + imePadding()` only — no `safeContentPadding()` per Pitfall F. Conditionally renders a `SearchPill` wired to the active tab's SearchViewModel (Recipes or Pantry — both paths covered) above the always-present `DockBar`. The `FloatingSearchButton` is overlaid at `BottomEnd`. Active-tab tracking derives from `NavBackStackEntry.destination.hierarchy` via type-safe `hasRoute(*Graph::class)`; a `LaunchedEffect(activeTab)` keeps `ShellViewModel.activeTab` in sync for back-button and deep-link cases. Tab selection navigates via `navigateToTab(dest.graphRoute)` and notifies `vm.onTabChanged(dest)`.

## Plan Output Questions Answered

- **Both Recipes and Pantry SearchViewModel paths covered?** Yes — `AppShell` has explicit `when (activeTab)` branches for both `BottomBarDestination.Recipes` and `BottomBarDestination.Pantry` for SearchPill rendering and FloatingSearchButton onClick. `Planner` and `Shopping` are no-ops because `hasSearch = false` already gates the surfaces.
- **Compose Unstyled TabGroup vs Row + semantics?** Used the `Row + Modifier.semantics { role = Role.Tab; selected; contentDescription }` fallback per UI-SPEC line 180 / PATTERNS.md § DockBar line 326. The renderless TabGroup did not offer a cleaner per-cell shape than direct semantics modifiers.
- **`hasRoute(*Graph::class)` worked?** Yes — nav-compose 2.9.2 exposes `NavDestination.Companion.hasRoute` and `NavDestination.Companion.hierarchy`. No string-route fallback was needed; iOS K/N compile + linkDebugFrameworkIosSimulatorArm64 both green.
- **Touch targets:** Code-level confirmation — DockTabCell uses `defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)`, CollapsedDockToggle is `size(44.dp)`, FloatingSearchButton is `size(44.dp)`. Visual sim confirmation deferred to plan 02.1-08's manual smoke (V-09 / V-11 in VALIDATION.md).

## Deviations from Plan

None — plan executed exactly as written. The plan's "Implementation note 1" pre-flagged an invalid `clickableNoRipple` sketch and recommended the `MutableInteractionSource + clickable(indication = null)` pattern, which is what the final code uses inline at each click site. No autoFix Rules 1–3 needed.

## Verification

- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` → exits 0 (silent).
- `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` → exits 0 (only an unrelated bundle-ID warning).
- Material 3 boundary preserved: zero `androidx.compose.material3` imports in any of the 4 new files.
- Direct Liquid / Haze imports zero in `ui/screens/shell/` and `ui/components/dock/`.
- `safeContentPadding()` not present in AppShell.

## Commits

| Task | Commit | Files |
| --- | --- | --- |
| 1 — ShellViewModel | `5e0aaf9` | ShellViewModel.kt |
| 2 — DockBar + FloatingSearchButton | `78bb90d` | DockBar.kt, FloatingSearchButton.kt |
| 3 — AppShell | `fb4301e` | AppShell.kt |

## Self-Check: PASSED

- Files exist:
  - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt
  - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt
  - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt
  - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt
- Commits exist: FOUND 5e0aaf9, 78bb90d, fb4301e.
- iOS compile + link both green.
