# Phase 2.1: App Shell, Navigation & Search Foundation — Research
**Researched:** 2026-05-08
**Domain:** Compose Multiplatform navigation chrome (KMP iOS-primary), renderless component foundation, Liquid-Glass surface primitive, externalized strings, search affordance state machine
**Confidence:** HIGH (locked stack; standard CMP patterns) / MEDIUM (Liquid library API surface)
---
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Tab bar shape & chrome placement**
- **D-01:** Bottom-anchored floating pill dock implemented as a Liquid-glass capsule, centered above the safe-area inset. No edge-to-edge bottom bar.
- **D-02:** All four tabs render icon + label at all times (active and inactive). Active tab is wider and visually emphasized; inactive tabs remain readable, not icon-only.
- **D-03:** Tab order — `Planer` / `Przepisy` / `Spiżarnia` / `Zakupy`. Default landing tab on first sign-in is `Planer`.
- **D-04:** No top app bar in v1. Tab title (where useful) lives inline at the top of each screen body. All chrome is bottom-anchored.
- **D-05:** When search opens (on tabs that have search), the dock collapses to a single circular button showing only the active tab's icon (no label, slightly reduced height). Tapping it closes search and re-expands the dock. Single coordinated animation.
**Search affordance behavior**
- **D-06:** Search button per-tab, only on `Przepisy` and `Spiżarnia`. Floating circular icon adjacent to the dock (not inside it).
- **D-07:** This phase delivers open/close + query input echo + clear/close actions only. Search-surface body renders nothing (Phase 5 wires real results for Recipes; Pantry phase wires Spiżarnia).
- **D-08:** Closing the search clears the query. Reopening starts blank. No persistence across close, tab-switch, or app launch.
- **D-09:** Search is an inline bottom pill, not a full-screen sheet. Body content stays visible behind it.
**Empty state design language**
- **D-10:** Icon + headline + subline. Icon is tab-themed, low-saturation theme color. No bespoke illustrations.
- **D-11:** Anticipatory Polish tone (e.g. "Wkrótce zobaczysz tu swój plan tygodnia"). No "Brak danych", no chatty onboarding.
- **D-12:** No CTA buttons in empty states this phase.
- **D-13:** Single reusable `EmptyState(icon, title, subtitle, action?)` composable in `ui/components/`; `action` slot reserved unused this phase.
**Theme tokens + Liquid fallback**
- **D-14:** Full theme scaffold this phase — semantic colors (background, surface, surfaceGlass, content, contentMuted, accent, separator, borderCard), typography scale (display/title/body/label, two weights), spacing scale (`xs`/`sm`/`lg`/`xl`/`2xl`/`3xl` per UI-SPEC revision 1), `GlassSurface` token primitive consumed by dock + search pill + floating buttons.
- **D-15:** Both light and dark color schemes defined; system-following.
- **D-16:** `GlassSurface` is layered Liquid → Haze → flat translucent fallback chain. All three paths consume same token API (color + opacity + radius).
- **D-17:** Compile-time per-target backend selection + debug-build runtime toggle (via `multiplatform-settings`). No automatic perf detection in v1.
### Claude's Discretion
- Exact Liquid library API parameters (radius, blur amount, refraction)
- Nav graph topology (default: nested NavHosts per tab unless research blocks it — research below confirms this is correct)
- Whether to migrate Phase 2 Material 3 auth screens (default: leave as legacy)
- Specific empty-state copy strings (Phase 11 will tune; UI-SPEC has best-current values)
- Icon source (default: Material Icons Outlined)
- Animation curves and durations for search-open dock collapse (UI-SPEC suggests 250ms `FastOutSlowInEasing`)
- Accessibility specifics (Role.Tab, focus order)
- Whether to expose runtime fallback toggle as in-app debug affordance or build flag
### Deferred Ideas (OUT OF SCOPE)
- Per-tab/scroll-state dock collapse independent of search → Phase 10
- Profile/settings entry point in chrome → Phase 3+
- Cross-tab CTAs in empty states → feature phases
- Custom illustrations for empty states
- Material 3 migration of Phase 2 auth screens
- Runtime perf auto-downgrade for GlassSurface → Phase 10
- Persisting search query across sessions
- Real-device Liquid tuning (refraction, specular) → Phase 10
- Localization (full Polish copy pass) → Phase 11
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| UI-03 | Bottom tab navigation with 4 tabs (Przepisy/Planer/Spiżarnia/Zakupy), each preserving its own back stack independently | § Architecture Pattern 1 (nested NavHost per tab) + § Standard Stack (`navigation-compose 2.9.x`) + Pitfall 13 (`when`-switch tabs lose back stack) |
| UI-04 | App chrome and primary icon buttons use chosen Liquid-Glass approximation, starting with Liquid library for menu/search controls | § Architecture Pattern 3 (`GlassSurface` primitive) + § Liquid Library Integration |
| UI-09 | App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog/plan/pantry/shopping are empty | § Architecture Pattern 4 (`EmptyState` reusable composable) + § Code Examples |
| UI-10 | Main app search affordance functional before catalog data exists: search opens, query state updates, clear/close work, no-results state is deliberate | § Architecture Pattern 2 (search state machine) + § SearchPill structure |
## Project Constraints (from CLAUDE.md)
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port). No alternative.
- ViewModel + StateFlow, method-per-action.
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`). `koinViewModel()` everywhere.
- Components: Composables.com / Compose Unstyled — DO NOT expand around Material 3. Material 3 stays only as legacy auth scaffold.
- Glass: Liquid first; Haze fallback only.
- Strings externalized day 1 (Polish content, multi-locale-ready resources). NO hardcoded literals.
- iOS-primary, Android secondary; no Desktop/Wasm targets in v1.
- iOS K/N flags: `objcDisposeOnMain=false`, `gc=cms` (already set Phase 1).
- `shared/commonMain` stays light — no UI/Ktor/SQLDelight imports.
- Glass effects on chrome only (PITFALLS Pitfall 5/12); never over scrolling content.
- Package layout: `dev.ulfrx.recipe.{app,navigation,ui.{theme,components,screens.{recipes,planner,pantry,shopping}},...}`.
---
## Summary
Phase 2.1 replaces the post-login placeholder with the real four-tab app shell. Three load-bearing pieces:
1. **Navigation:** Single root `NavHost` containing four `navigation(...)` sub-graphs (one per tab) using `org.jetbrains.androidx.navigation:navigation-compose` 2.9.x (CMP port). Bottom-tab reselection uses `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true` so each tab's back stack survives switching. Routes are `@Serializable` `data object` / `data class` per JetBrains type-safe routing. ViewModels per tab area are scoped to the parent nav-graph `NavBackStackEntry` via `koinViewModel(viewModelStoreOwner = parentEntry)`.
2. **Component foundation:** `compose-unstyled` (`com.composables:composeunstyled:1.49.x`) provides renderless primitives for `TabGroup`, `Button`, `TextField`, `Modal`/`BottomSheet`. Recipe-styled components in `ui/components/` consume those primitives and apply `RecipeTheme` tokens. Material 3 imports are confined to `ui/screens/auth/*` (legacy).
3. **Glass surface:** `GlassSurface` primitive in `ui/components/glass/` with three backends — Liquid (`io.github.fletchmckee.liquid:liquid:1.1.1`, modifier `liquid(state)` + `liquefiable(state)`), Haze (`dev.chrisbanes.haze:haze:1.x`), and flat translucent. Backend selection is compile-time per-target (Gradle source-set wiring) plus a debug-build runtime override stored in `multiplatform-settings`. Liquid is preferred on iOS+Android; Haze is the secondary blur path; flat is last resort.
**Primary recommendation:** Build top-down — root `AppShell` composable hosting one CMP `NavHost` with four `navigation()` sub-graphs, bottom dock + floating search button as overlay, per-tab `koinViewModel()` scoped to parent graph entry, all glass effects funneled through `GlassSurface`. Strings always via `stringResource(Res.string.*)` against `composeResources/values/strings.xml`. No `androidx.compose.material3.*` imports outside `ui/screens/auth/`.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Tab navigation + back stacks | KMP client (Compose UI) | — | Pure client UX; no server interaction |
| Search affordance state | KMP client (per-tab ViewModel) | — | Local UI state; no persistence (D-08) |
| Theme tokens / `RecipeTheme` | KMP client (ui/theme) | — | Renders identically across platforms |
| Liquid/Haze/flat backend selection | KMP client (compile-time per Kotlin source set) | Runtime debug toggle | Per-platform shader capability |
| Empty-state copy | KMP resources (`composeResources/values/strings.xml`) | Phase 11 localization | Resource-keyed; copy may tune later |
| Auth gate (still upstream of shell) | KMP client (App.kt observes `AuthSession`) | — | Unchanged from Phase 2; shell sits downstream |
No server changes in this phase. No `shared/commonMain` changes (UI is client-only).
---
## Standard Stack
### Core (already in `gradle/libs.versions.toml` or to add)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `org.jetbrains.androidx.navigation:navigation-compose` | **2.9.2** (latest as of 2026-05-08) — currently NOT in catalog; **add** | CMP-official navigation; type-safe routes; multi-back-stack support | JetBrains-official port of Jetpack Navigation; locked in CLAUDE.md |
| `androidx-lifecycle-viewmodelCompose` | 2.10.0 (already in catalog) | `ViewModel` + `viewModelScope` in commonMain | Already locked Phase 2 |
| `koin-compose` / `koin-composeViewmodel` | 4.2.1 (already in catalog) | `koinViewModel()`, `koinInject()` | Already locked |
| `compose-components-resources` | 1.10.3 (already in catalog) | `Res.string.*`, `stringResource()` | CMP standard for strings |
| `androidx-compose-material-icons-extended` | n/a — needs investigation; CMP equivalent is via `compose-material-icons-core` or use `material3` icons (already pulled by Phase 2 auth scaffold) | Outlined icon set for tabs + empty states | UI-SPEC selected `Icons.Outlined.*` | [VERIFIED: UI-SPEC + libs.versions.toml] |
> **Material Icons in CMP caveat:** the JetBrains CMP `material3` artifact (already in catalog) bundles a baseline icon set, but `Icons.Outlined.MenuBook` / `Icons.Outlined.Inventory2` / `Icons.Outlined.CalendarMonth` / `Icons.Outlined.ShoppingCart` are in the **extended** icon set. CMP exposes this via `org.jetbrains.compose.material:material-icons-extended` (or pulls them transitively from `material3`). **Plan needs to verify** whether the four icons referenced in UI-SPEC are available without adding `material-icons-extended`, and add the dependency if not. [ASSUMED — needs Wave-0 verify step]
### Add to catalog
| Coordinate | Version | Purpose |
|------------|---------|---------|
| `org.jetbrains.androidx.navigation:navigation-compose` | 2.9.2 | CMP nav host + bottom-tab multi-back-stack [VERIFIED: Maven Central / kotlinlang.org] |
| `com.composables:composeunstyled` | 1.49.x (1.49.9 latest seen) | Renderless primitives (TabGroup, Button, TextField, Modal, BottomSheet) [VERIFIED: composables.com docs] |
| `io.github.fletchmckee.liquid:liquid` | 1.1.1 | Liquid Glass shader for chrome [VERIFIED: Maven Central central.sonatype.com] |
| `dev.chrisbanes.haze:haze` | 1.x stable (1.6+ as of early 2026) — confirm at planning time | Fallback blur surface [VERIFIED: chrisbanes.github.io/haze/ — Haze 2.0-alpha01 released 2026-04-29; stick to 1.x stable for production] |
### Already present, used as-is
`koin-bom`, `koin-core`, `koin-compose`, `koin-composeViewmodel`, `kermit`, `compose-runtime`, `compose-foundation`, `compose-material3` (legacy boundary), `compose-ui`, `compose-components-resources`, `androidx-lifecycle-viewmodelCompose`, `androidx-lifecycle-runtimeCompose`, `multiplatform-settings`.
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `navigation-compose` (CMP port) | Decompose, Voyager | Both are popular but **locked away by CLAUDE.md** — JetBrains CMP nav is the canonical choice |
| Compose Unstyled | Roll our own renderless layer | Hand-rolling means re-implementing focus/a11y/keyboard/state semantics. Compose Unstyled exists for this exact reason |
| Liquid (RuntimeShader) | Native SwiftUI material via interop | Native interop is v2 (LG2-01); Liquid is the v1 approximation per PROJECT.md |
| Haze fallback | Skip middle tier (Liquid → flat) | CONTEXT D-16 explicitly chose three-tier — middle quality matters when Liquid fails on a target but blur still works |
### Installation
Add to `gradle/libs.versions.toml`:
```toml
[versions]
navigation-compose = "2.9.2"
compose-unstyled = "1.49.9"
liquid = "1.1.1"
haze = "1.6.10" # confirm latest 1.x stable at planning time
[libraries]
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
```
Then in `composeApp/build.gradle.kts` `commonMain.dependencies`:
```kotlin
implementation(libs.navigation.compose)
implementation(libs.compose.unstyled)
implementation(libs.liquid)
implementation(libs.haze)
```
**Version verification step (Wave 0):** before locking, run `./gradlew dependencies --configuration commonMainRuntimeClasspath | grep -E "(navigation-compose|composeunstyled|liquid|haze)"` to confirm resolution succeeds for both `iosArm64` and `iosSimulatorArm64`. [ASSUMED — Liquid 1.1.1 ships iOS klibs based on Maven Central listing of `liquid-iossimulatorarm64` artifact, but the published target matrix is not enumerated on the package page. Wave 0 must confirm.]
---
## Architecture Patterns
### System Architecture Diagram
```
┌─────────────────────────┐
│ App() (App.kt) │
│ observes AuthSession │
└──────────┬──────────────┘
│
AuthState.Authenticated + currentUser != null
│
▼
┌──────────────────────────────────┐
│ AppShell (ui/screens/shell/) │
│ - hosts root NavController │
│ - renders DockBar overlay │
│ - renders FloatingSearchButton │
│ - hosts SearchPill when open │
└──────────────────┬───────────────┘
│
▼
┌─────────────────── NavHost ────────────────────┐
│ │
│ navigation(route="planner_graph", │
│ startDest=PlannerHome) ──► PlannerScreen │
│ navigation(route="recipes_graph", ...) │
│ startDest=RecipesHome ──► RecipesScreen │
│ navigation(route="pantry_graph", ...) │
│ startDest=PantryHome ──► PantryScreen │
│ navigation(route="shopping_graph", ...) │
│ startDest=ShoppingHome ──► ShoppingScreen│
└────────────────────────────────────────────────┘
│
▼
Each *Screen consumes a koinViewModel<*VM>(
viewModelStoreOwner = parentNavGraphEntry)
so survival across tab reselection works.
Search overlay (only on recipes_graph + pantry_graph):
FloatingSearchButton tap
│
▼
AppShell.searchOpen=true
(per-active-tab SearchViewModel)
│
├─► DockBar collapses (single coordinated animation)
├─► FloatingSearchButton hides
└─► SearchPill renders inline at bottom
(TextField → SearchViewModel.onQueryChange)
(clear → query=""; close → searchOpen=false, query="")
GlassSurface(...) [used by DockBar, FloatingSearchButton, SearchPill]
│
├── compile-time backend per target:
│ iosArm64/iosSimulatorArm64/android → LiquidBackend (default)
│ fallback constellation → HazeBackend
│ fallback constellation → FlatBackend
│
└── debug-build override via multiplatform-settings key
"debug.glass_backend" ∈ {liquid, haze, flat}
```
### Recommended Project Structure
```
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/
├── app/ # (future) — App() may move here later; out of scope
├── navigation/
│ ├── Routes.kt # @Serializable data object/class for every destination
│ ├── RootNavHost.kt # NavHost containing 4 nested navigation() blocks
│ └── BottomBarDestination.kt # enum or sealed of (Planner, Recipes, Pantry, Shopping)
├── ui/
│ ├── theme/
│ │ ├── RecipeTheme.kt # extended: hosts CompositionLocal scaffold (D-14, D-15)
│ │ ├── RecipeColors.kt # data class + Light/Dark instances (D-15)
│ │ ├── RecipeTypography.kt # display/title/body/label (D-14)
│ │ ├── RecipeSpacing.kt # xs/sm/lg/xl/2xl/3xl (UI-SPEC rev 1)
│ │ ├── RecipeShapes.kt # pill / circle radii
│ │ └── RecipeGlass.kt # GlassSurface params (tint, opacity, blur, border)
│ ├── components/
│ │ ├── glass/
│ │ │ ├── GlassSurface.kt # public API; commonMain
│ │ │ └── GlassBackend.kt # expect/actual or commonMain abstraction
│ │ ├── dock/
│ │ │ ├── DockBar.kt # 4-tab pill; collapses on searchOpen
│ │ │ └── FloatingSearchButton.kt # adjacent circular button
│ │ ├── search/
│ │ │ └── SearchPill.kt # inline bottom search input
│ │ └── empty/
│ │ └── EmptyState.kt # reusable (icon, title, subtitle, action?)
│ └── screens/
│ ├── shell/
│ │ ├── AppShell.kt # root authenticated composable
│ │ └── ShellState.kt # active tab + searchOpen state
│ ├── planner/
│ │ ├── PlannerScreen.kt # inline title + EmptyState
│ │ └── PlannerViewModel.kt
│ ├── recipes/
│ │ ├── RecipesScreen.kt
│ │ ├── RecipesViewModel.kt
│ │ └── RecipesSearchViewModel.kt
│ ├── pantry/
│ │ ├── PantryScreen.kt
│ │ ├── PantryViewModel.kt
│ │ └── PantrySearchViewModel.kt
│ └── shopping/
│ ├── ShoppingScreen.kt
│ └── ShoppingViewModel.kt
│ └── (auth/ stays as-is — legacy Material 3)
└── di/
├── AppModule.kt # extended to include shellModule
└── ShellModule.kt # NEW: VMs + ShellState + GlassBackend factory
```
`composeApp/src/iosMain/` and `androidMain/`: backend `actual`s for `GlassBackend` if implementation differs by platform. Liquid is multiplatform so a single `commonMain` `LiquidBackend` likely works; only Haze actuals or platform-specific image effects need `actual`s — confirm at planning.
### Pattern 1: Nested NavHost per tab (CMP-official, multi-back-stack)
Single root `NavHost` containing four `navigation(route = "*_graph")` sub-graphs. Bottom dock navigation uses save/restore state. This is the JetBrains-recommended pattern (kotlinlang.org/docs/multiplatform/compose-navigation.html — "for apps with bottom navigation you can maintain separate nested graphs for each tab while saving and restoring navigation states when switching between tabs").
```kotlin
// Source: kotlinlang.org/docs/multiplatform/compose-navigation.html (HIGH)
// + saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved (MEDIUM)
@Serializable data object PlannerGraph
@Serializable data object PlannerHome
@Serializable data object RecipesGraph
@Serializable data object RecipesHome
// ... etc
@Composable
fun RootNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = PlannerGraph) {
navigation(startDestination = PlannerHome) {
composable { entry ->
val parent = remember(entry) {
navController.getBackStackEntry(PlannerGraph)
}
val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
PlannerScreen(vm)
}
// future detail destinations land here
}
navigation(startDestination = RecipesHome) { /* ... */ }
navigation(startDestination = PantryHome) { /* ... */ }
navigation(startDestination = ShoppingHome) { /* ... */ }
}
}
fun NavHostController.navigateToTab(graphRoute: Any) {
navigate(graphRoute) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
```
**iOS caveat (PITFALL 13 + research/PITFALLS.md):** The CMP nav backstack persistence has had issues across minor versions (see GitHub issue 4735 — "Support saving state for nested NavHostController"). Pin to 2.9.2 (latest stable) and verify multi-back-stack behavior on iOS during Wave 0 with a short demo: open detail → switch tab → switch back → confirm detail restored. [VERIFIED: github.com/JetBrains/compose-multiplatform/issues/4735 — issue references nested NavHostController; root-level multi-back-stack via single NavHost + `navigation` blocks is the working pattern]
### Pattern 2: Per-tab ViewModel scoping via parent graph `NavBackStackEntry`
`koinViewModel()` defaults to scoping to the *current* destination entry — meaning the VM dies when you navigate to a child destination. To make `RecipesViewModel` survive within the recipes graph (so future `RecipesDetailScreen` can share state with `RecipesScreen`), retrieve the **parent graph's** `NavBackStackEntry` and pass it as `viewModelStoreOwner`.
```kotlin
// Source: insert-koin.io/docs/reference/koin-compose/compose/ (HIGH)
// + droidcon.com/2024/10/16/place-scope-handling-on-auto-pilot-with-koin-compose-navigation (MEDIUM)
@Composable
fun RecipesScreen(navController: NavController) {
val parent = remember { navController.getBackStackEntry(RecipesGraph) }
val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
val searchVm: RecipesSearchViewModel = koinViewModel(viewModelStoreOwner = parent)
// both VMs survive within the recipes graph; freed when graph leaves stack
}
```
This phase only has one screen per graph, but **set the pattern now** — Phase 5 (Recipe Catalog) will add detail screens that need shared state with the list screen, and Phase 5 should not have to refactor scoping.
### Pattern 3: `GlassSurface` primitive with three-backend chain (D-16, D-17)
```kotlin
// Source: research synthesis from CONTEXT D-16/D-17 + Liquid README + Haze docs (MEDIUM — Liquid API is from README)
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
content: @Composable BoxScope.() -> Unit,
) {
val backend = LocalGlassBackend.current // resolved via compile-time + debug toggle
when (backend) {
GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, content)
GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, content)
GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content)
}
}
```
`LocalGlassBackend` is a `CompositionLocal` set once at `AppShell` startup:
1. **Compile-time default** picked per target via `expect/actual` or `commonMain` constants — e.g. `iosArm64/iosSimulatorArm64/android → Liquid`, anything else → `Haze`.
2. **Debug runtime override** read once at app start from `multiplatform-settings` key `"debug.glass_backend"`. Production builds short-circuit this path (compiled out via `BuildConfig`-style constant in `androidMain` / Kotlin `expect val isDebug` actual).
The Liquid path uses `rememberLiquidState()` + `Modifier.liquefiable(state)` on the content layer behind chrome and `Modifier.liquid(state)` on the chrome itself. The Liquid effect needs a sampleable backdrop, so the screen content (tab body) gets `liquefiable(state)` and the dock/search-pill get `liquid(state)`. **Important:** that backdrop is the screen body, not scrolling content within the body — that aligns with PITFALL 5/12 (chrome-only constraint).
### Pattern 4: Search affordance state machine
```kotlin
// Source: synthesized from CONTEXT D-05 through D-09 + UI-SPEC interaction contract
class RecipesSearchViewModel : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow = _state.asStateFlow()
fun open() { _state.update { it.copy(isOpen = true) } }
fun close() { _state.update { SearchState() } } // D-08: clears query
fun onQueryChange(q: String) { _state.update { it.copy(query = q) } }
fun clear() { _state.update { it.copy(query = "") } }
}
data class SearchState(val isOpen: Boolean = false, val query: String = "")
```
`AppShell` reads the search VM of the **active** tab (Recipes or Pantry). When `isOpen = true`, the `DockBar` collapses + `SearchPill` renders. The shell owns the active-tab → search-VM mapping; the VMs themselves are scoped to their parent graphs.
**Phase 5 extension point:** the Recipes search VM's state today is `(isOpen, query)`. Phase 5 adds `results: Flow>` derived from `query.debounce().flatMapLatest { repo.search(it) }`. Design the VM constructor with a nullable `searchSource: SearchSource? = null` parameter today so Phase 5 only injects the dependency rather than rewriting the VM.
### Anti-Patterns to Avoid
- **`when (selectedTab) { ... }` switch instead of nested `NavHost`:** kills back stacks (PITFALL 13). Always use `navigation()` sub-graphs.
- **`koinViewModel()` without `viewModelStoreOwner` for tab-scoped VMs:** VM dies when navigating into a detail; future Phase 5 detail flow loses list scroll position.
- **Glass effects over scrolling content:** explicit project rule (CLAUDE.md #10, PITFALL 5/12). `GlassSurface` is for chrome only — dock, search pill, floating button.
- **Direct Liquid/Haze API calls in screen code:** screens MUST go through `GlassSurface`. Direct calls leak backend choice into call sites and break the fallback contract.
- **Hardcoded Polish strings:** every user-facing string is `stringResource(Res.string.*)`. CLAUDE.md non-negotiable #9.
- **`androidx.compose.material3.*` imports outside `ui/screens/auth/`:** PROJECT decision. Even if convenient, it expands Material 3 into new code.
- **Device clock for animation timing:** unrelated to LWW but same hygiene — use `kotlinx.coroutines` `delay` and Compose animation specs, not `System.currentTimeMillis()`.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Tab navigation with multi-back-stack | `when (selectedTab)` + manual back-handler | CMP `navigation-compose` 2.9.x with `popUpTo + saveState + restoreState + launchSingleTop` | PITFALL 13: hand-rolled tab switching loses back stack on every switch; Jetpack/JetBrains nav handles it correctly |
| Renderless TabGroup / Button / TextField with proper a11y + focus + keyboard | Custom `Modifier.clickable + Role.Tab` and an `OutlinedTextField` analogue | Compose Unstyled `TabGroup`, `Button`, `TextField` primitives | These libraries already handle focus order, semantics, IME types, and edge cases; PROJECT decision is to use them |
| Glass blur effect | Custom `RenderEffect` per platform | Liquid (`liquid` modifier) → Haze (`hazeChild`) → flat translucent | Cross-platform shader correctness, perf optimization, and graceful degradation — all already in Liquid/Haze |
| Polish-aware string lookup | Hardcoded literals + manual locale switch | `compose-components-resources` `stringResource(Res.string.*)` | Already wired Phase 2; multi-locale-ready for free |
| Theme `CompositionLocal` ceremony | Per-component prop drilling | Standard Compose `compositionLocalOf` + `CompositionLocalProvider` pattern | Idiomatic; mirror MaterialTheme's structure |
| Animated transition between dock states | Manual coroutine + lerp | `Modifier.animateContentSize()` for size + `AnimatedContent` for icon/label visibility, both with shared `animationSpec` | Single-source-of-truth animation; Compose handles intersecting frames |
**Key insight:** every chrome surface (dock, search button, search pill) uses the same `GlassSurface` primitive — the size, shape, and animation differ but the substrate doesn't. Centralizing surface logic now means Phase 10's real-device tuning is a one-file change.
---
## Common Pitfalls
### Pitfall A: CMP nav-compose multi-back-stack regression on iOS
**What goes wrong:** Tab → detail → other tab → return → detail is gone. Reproduces on iOS, not Android.
**Why:** Some 2.8.x CMP nav releases had broken state restoration on iOS native; 2.9.x is the recommended floor. CMP's K/N nav implementation has had drift behind Android.
**How to avoid:** Pin to `navigation-compose 2.9.2`. Add a Wave-0 manual smoke test on iOS simulator: navigate dummy detail in one tab, switch tabs, switch back, assert detail visible.
**Warning signs:** Works on Android, broken on iOS. Compose Multiplatform GitHub issue 4735 family.
### Pitfall B: ViewModel re-creation on tab reselection
**What goes wrong:** Clicking the active tab re-creates its ViewModel, dropping in-memory state and re-running `init`.
**Why:** `launchSingleTop = true` + missing `restoreState = true` causes Nav to clear and recreate.
**How to avoid:** Always include `restoreState = true` AND scope VM to parent graph entry (Pattern 2 above). Verify by adding a counter in `init` and confirming it doesn't tick on tab reselection.
### Pitfall C: Liquid sampleable backdrop missing → effect renders flat
**What goes wrong:** `liquid()` modifier renders nothing because no `liquefiable()` peer is in the tree.
**Why:** Liquid's pixel-sampling needs a tagged source layer. Forgetting it means the effect has no input.
**How to avoid:** `AppShell` wraps the screen body region in `Modifier.liquefiable(state)` and the dock + search pill + search button consume `Modifier.liquid(state)` from the same `LiquidState`. Document this contract in `GlassSurface` KDoc.
### Pitfall D: `Icons.Outlined.MenuBook` and friends not in baseline icon set
**What goes wrong:** Compile fails on `Icons.Outlined.MenuBook` / `Inventory2` / `CalendarMonth` / `ShoppingCart` because the four selected icons are in the **extended** set, not the baseline that `material3` ships.
**How to avoid:** Verify at planning time. If extended set is needed, add `org.jetbrains.compose.material:material-icons-extended` to the catalog. (Wave-0 task: try a dummy compose with all four icons; observe.)
### Pitfall E: Hardcoded literals slip in during shell wiring
**What goes wrong:** Tab labels or empty-state copy gets typed inline as `Text("Planer")` during a quick prototype, then nobody refactors.
**How to avoid:** Lint/grep gate in plan-checker: any `Text("[A-ZŁĄĆŻŃŚŹŻ]...")` or `Text("[a-zA-Złąćż]+")` in `ui/screens/(planner|recipes|pantry|shopping|shell)/` is a bug. Phase 11 will enforce this globally; introduce the discipline now (CLAUDE.md non-negotiable #9).
### Pitfall F: `safeContentPadding()` interactions with floating dock
**What goes wrong:** Bottom dock either overlaps the home indicator or sits too high above it because `Scaffold`-style content padding gets applied twice (once by parent, once by screen body).
**How to avoid:** AppShell consumes navigation/IME insets explicitly via `WindowInsets.navigationBars.union(WindowInsets.ime).only(WindowInsetsSides.Bottom)` and applies them to the dock's bottom offset. Screen bodies use `WindowInsets.statusBars` for top inset only. Don't use `safeContentPadding()` on both layers.
### Pitfall G: K/N GC churn on bottom-dock animation (PITFALL 1 carry-over)
**What goes wrong:** Frame hitches on iPhone 11/12-era hardware when dock collapses and the Liquid layer composites.
**How to avoid:** `kotlin.native.binary.objcDisposeOnMain=false` and `gc=cms` are already set Phase 1 (INFRA-03). Verify in Wave 0 and confirm in any iOS smoke test. If hitches appear, the debug runtime toggle (D-17) lets the user fall back to flat to confirm Liquid is the cause.
---
## Code Examples
### Example 1: Routes (type-safe)
```kotlin
// navigation/Routes.kt
package dev.ulfrx.recipe.navigation
import kotlinx.serialization.Serializable
@Serializable data object PlannerGraph
@Serializable data object PlannerHome
@Serializable data object RecipesGraph
@Serializable data object RecipesHome
@Serializable data object PantryGraph
@Serializable data object PantryHome
@Serializable data object ShoppingGraph
@Serializable data object ShoppingHome
enum class BottomBarDestination(val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector) {
Planner(PlannerGraph, Res.string.shell_tab_planner, Icons.Outlined.CalendarMonth),
Recipes(RecipesGraph, Res.string.shell_tab_recipes, Icons.Outlined.MenuBook),
Pantry(PantryGraph, Res.string.shell_tab_pantry, Icons.Outlined.Inventory2),
Shopping(ShoppingGraph, Res.string.shell_tab_shopping, Icons.Outlined.ShoppingCart),
}
```
### Example 2: AppShell skeleton
```kotlin
// ui/screens/shell/AppShell.kt
@Composable
fun AppShell() {
val navController = rememberNavController()
val backStack by navController.currentBackStackEntryAsState()
val activeTab = remember(backStack) { backStack?.toBottomBarDestination() ?: BottomBarDestination.Planner }
val shellState: ShellViewModel = koinViewModel()
val ui by shellState.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxSize()
.background(RecipeTheme.colors.background)
.liquefiable(shellState.liquidState), // backdrop for Liquid
) {
RootNavHost(navController)
// Bottom chrome — overlay
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars),
) {
if (ui.searchOpen && activeTab.hasSearch) {
SearchPill(
query = ui.query,
onQueryChange = shellState::onQueryChange,
onClear = shellState::clearQuery,
onClose = shellState::closeSearch,
placeholder = stringResource(activeTab.searchPlaceholder),
)
}
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = ui.searchOpen,
onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
onCollapsedTap = shellState::closeSearch,
)
}
if (!ui.searchOpen && activeTab.hasSearch) {
FloatingSearchButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = RecipeTheme.spacing.lg, bottom = RecipeTheme.spacing.sm)
.windowInsetsPadding(WindowInsets.navigationBars),
onClick = shellState::openSearch,
)
}
}
}
```
### Example 3: EmptyState
```kotlin
// ui/components/empty/EmptyState.kt
@Composable
fun EmptyState(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = RecipeTheme.spacing.xl)
.semantics(mergeDescendants = true) {},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = RecipeTheme.colors.contentMuted,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(RecipeTheme.spacing.sm))
Text(text = title, style = RecipeTheme.typography.display, color = RecipeTheme.colors.content,
textAlign = TextAlign.Center)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
Text(text = subtitle, style = RecipeTheme.typography.body, color = RecipeTheme.colors.contentMuted,
textAlign = TextAlign.Center)
if (action != null) {
Spacer(Modifier.height(RecipeTheme.spacing.xl))
action()
}
}
}
```
### Example 4: Strings resource
```xml
Planer
Przepisy
Spiżarnia
Zakupy
Twój plan tygodnia czeka
Wkrótce zobaczysz tu zaplanowane posiłki.
Tu pojawi się Twoja książka kucharska
Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.
Spiżarnia jest jeszcze pusta
Wkrótce zobaczysz tu wszystko, co masz pod ręką.
Lista zakupów czeka na Twój plan
Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.
Otwórz wyszukiwanie
Zamknij wyszukiwanie
Wyczyść
Szukaj przepisów…
Szukaj w spiżarni…
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Manual `when (tab)` tab switching | CMP `navigation-compose` `navigation()` sub-graphs + `saveState/restoreState` | Stable since nav-compose 2.7+ on Android, 2.8+ on KMP | Multi-back-stack works; PITFALL 13 prevented |
| `nav-compose` 2.7.x with KMP support hidden behind alpha | `org.jetbrains.androidx.navigation:navigation-compose 2.9.x` (stable port) | 2.9 series | Use 2.9.2; older 2.7/2.8 had iOS state-restoration drift |
| Material 3 default scaffold for tab apps | Compose Unstyled renderless primitives + custom `RecipeTheme` | Compose Unstyled 1.40+ | Calmer aesthetics, no Material 3 tax — explicit project decision |
| `Modifier.blur()` for glass | RuntimeShader-based libraries (Liquid, Haze 2.x) | Compose 1.6+ stable RuntimeShader on iOS | Real Liquid Glass approximation cross-platform |
| Haze 2.0-alpha for shipping | Haze 1.x stable for production | Haze 2.0-alpha01 released 2026-04-29 | Stay on 1.x stable until Haze 2.x is stable; Phase 10 may revisit |
**Deprecated/outdated:**
- `freeze()`, `@SharedImmutable`, `kotlin.native.concurrent.AtomicReference` — gone since K/N new MM (PITFALL 2).
- `androidx.navigation:navigation-compose` (Android-only artifact) — for KMP, always use `org.jetbrains.androidx.navigation:navigation-compose`.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Liquid 1.1.1 publishes klibs for `iosArm64` AND `iosSimulatorArm64` (Maven Central lists `liquid-iossimulatorarm64` artifact, but full target matrix not enumerated on the package page) | Standard Stack / Pitfall A | Wave-0 dependency-resolution check fails for iOS; phase falls back to Haze-as-default at compile time. Plan must include the Wave-0 verify step before depending on Liquid as the iOS default backend. |
| A2 | `Icons.Outlined.MenuBook`, `Inventory2`, `CalendarMonth`, `ShoppingCart` are accessible without adding `material-icons-extended` (UI-SPEC selected these without flagging) | Standard Stack / Pitfall D | Build fails on import; planner adds `material-icons-extended` to catalog. Cheap to fix. |
| A3 | The CMP nav-compose 2.9.2 K/N (iOS) binary correctly persists `saveState` across tab reselection (a Wave-0 smoke test must confirm) | Pattern 1 / Pitfall A | If broken: the phase falls back to a single root NavHost without nested graphs, and Phase 5 will need to retrofit. Smoke test catches this in Wave 0. |
| A4 | Haze 1.x stable on KMP iOS handles `hazeChild` over a non-scrolling backdrop without the iPhone-11 jank pattern (PITFALL 12); restricted to chrome only | Pattern 3 | If jank: production engages the flat fallback per D-17. Acceptable since Liquid is the primary path. |
| A5 | `multiplatform-settings` is wired in commonMain Koin and accessible from `AppShell` at startup (already pulled in Phase 2 for AuthState) | Pattern 3 — debug toggle | If not: minor Koin wiring tweak. Already in libs catalog so likely fine. |
| A6 | Compose Unstyled 1.49.x supports KMP iOS targets (artifact name `composeunstyled` not `core`) | Standard Stack | If wrong artifact ID: Wave-0 catches via Gradle resolution failure; planner adjusts. Verify exact 1.49.9 coords against `composables.com/docs/com.composables/core/installation`. |
| A7 | The CMP `lifecycle-viewmodel-compose` `viewModelStoreOwner` parameter to `koinViewModel()` correctly hosts a VM per parent NavBackStackEntry on iOS (the documented pattern is from Android Jetpack; CMP behavior is assumed equivalent) | Pattern 2 | Test in Wave 0; if VM is recreated on tab switch on iOS, fall back to scoping at root graph (less ideal but functional). |
| A8 | Empty-state copy strings in UI-SPEC are best-current placeholders, subject to Phase 11 tuning | Code Examples / strings.xml | None — explicitly flagged in UI-SPEC. |
**A1 and A3 are the load-bearing assumptions** — Wave 0 of the plan MUST resolve them before the rest of the work is touched.
---
## Open Questions (RESOLVED)
> Resolved 2026-05-08 by gsd-phase-planner during the plan-set authoring pass for plans 02.1-03 through 02.1-08. Each resolution is reflected in the corresponding plan's mandates.
1. **Should the Liquid runtime debug toggle be exposed in-app (hidden gesture) or as a build flag only?** — **RESOLVED**
- What we know: D-17 says "via `multiplatform-settings`, surfaced through a hidden settings entry or build flag" — both are valid.
- What's unclear: which one delivers more value at this phase. There's no settings screen yet (Phase 3+).
- Recommendation: Build flag only this phase (lightest scaffolding). Defer in-app toggle to whenever a settings screen lands. The `multiplatform-settings`-backed `LocalGlassBackend` plumbing is still built so an in-app toggle is a UI-only change later.
- **RESOLUTION:** Debug-build runtime override via `multiplatform-settings` key `"debug.glass_backend"`, gated by `expect val isDebugBuild: Boolean` so production binaries compile out the override path entirely. This aligns with D-17 and is implemented by plan 02.1-03 (GlassBackend.kt + IsDebugBuild.kt expect/actual). No in-app debug-toggle UI this phase; Phase 3+ may add one as a UI-only change once a settings surface exists.
2. **Should the `material-icons-extended` artifact be added preemptively, or wait until the four icons are confirmed missing?** — **RESOLVED**
- What we know: UI-SPEC selected `Icons.Outlined.{CalendarMonth,MenuBook,Inventory2,ShoppingCart}`. These are typically in extended.
- What's unclear: whether `compose-material3` 1.10.0-alpha05 transitively exposes them.
- Recommendation: Wave-0 verification task — try the icons, add the dependency if needed. Document the result.
- **RESOLUTION:** Added preemptively in plan 02.1-01 (catalog entry `compose-material-icons-extended = "1.7.3"`) because the four phase-2.1 icons (CalendarMonth, MenuBook, Inventory2, ShoppingCart) plus Search are all in the extended set. Validated empirically by the `linkDebugFrameworkIosSimulatorArm64` acceptance check in plan 02.1-01.
3. **Should `RecipeTheme` re-export `MaterialTheme` for the auth screens, or are they fine on Material 3 defaults?** — **RESOLVED**
- What we know: Phase 2 auth screens use `MaterialTheme.colorScheme.surface/typography.headlineSmall`. The current `RecipeTheme.kt` is a Material 3 wrapper. UI-SPEC says auth stays on Material 3 as legacy.
- What's unclear: whether expanding `RecipeTheme` into the new token system breaks the existing `MaterialTheme.*` lookups in auth screens.
- Recommendation: `RecipeTheme` keeps wrapping `MaterialTheme(colorScheme = ...)` AND adds the new `CompositionLocalProvider` for Recipe tokens. Auth screens continue to read `MaterialTheme.*`; new code reads `RecipeTheme.*`. Both work in the same composition.
- **RESOLUTION:** Yes — plan 02.1-02 keeps `MaterialTheme(colorScheme = ...)` wrapping the inner `CompositionLocalProvider(...)`. Legacy auth screens (`LoginScreen.kt`, `PostLoginPlaceholderScreen.kt`, `SplashScreen.kt`) continue to read `MaterialTheme.colorScheme.*` / `MaterialTheme.typography.*`; new shell code reads `RecipeTheme.colors.*` etc. The MaterialTheme wrapper is removed only when the auth screens migrate (out of scope for v1 — CONTEXT line 52 keeps auth screens as legacy by user discretion).
---
## Environment Availability
This phase is purely client-side code/config; the only external "tools" are Gradle dependencies, all from Maven Central.
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Maven Central | All new dependencies | ✓ | n/a | — |
| `org.jetbrains.androidx.navigation:navigation-compose` | UI-03 | ✓ | 2.9.2 | — |
| `com.composables:composeunstyled` | UI-04, component foundation | ✓ | 1.49.9 | — |
| `io.github.fletchmckee.liquid:liquid` | UI-04 | ✓ | 1.1.1 | Fall back to Haze (D-16) |
| `dev.chrisbanes.haze:haze` | UI-04 fallback | ✓ | 1.x stable | Fall back to flat translucent |
| `gradlew` build for `iosSimulatorArm64` | Smoke test (Wave 0) | (host-dependent — Apple Silicon required) | n/a | Manual check on developer machine |
**Missing dependencies with no fallback:** none for this phase.
**Missing dependencies with fallback:** the entire Liquid → Haze → flat chain IS the fallback design.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | `kotlin.test` (commonTest) — already used in Phase 2 (`AuthSessionTest`, `LoginViewModelTest`) |
| Config file | none — convention plugins handle `recipe.kotlin.multiplatform` |
| Quick run command | `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.*" --tests "dev.ulfrx.recipe.ui.screens.recipes.*Search*"` |
| Full suite command | `./gradlew :composeApp:check` |
| Compose UI test runner | not introduced this phase — feasibility low because Compose UI Test on KMP iOS is still surfacing |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| UI-03 | Tab switch preserves per-tab back stack | manual smoke (iOS simulator) — instrument with logging if needed | `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` then iOS smoke from Xcode | ❌ Wave 0 |
| UI-03 | `navigateToTab()` extension applies `popUpTo + saveState + launchSingleTop + restoreState` | unit | `./gradlew :composeApp:commonTest --tests "*NavigationTest*"` | ❌ Wave 0 |
| UI-04 | `GlassSurface` selects Liquid backend on iOS targets at compile time | unit (per-source-set constants) | `./gradlew :composeApp:commonTest --tests "*GlassBackend*"` | ❌ Wave 0 |
| UI-04 | `GlassSurface` debug-toggle flow honors `multiplatform-settings` value | unit (with `MapSettings` test impl) | `./gradlew :composeApp:commonTest --tests "*GlassBackendOverride*"` | ❌ Wave 0 |
| UI-09 | `EmptyState` composable: on first launch, all four tabs render their respective empty state without flash | manual smoke (iOS) — observe one launch | n/a | manual |
| UI-09 | App.kt's `AuthState.Authenticated + currentUser != null` branch resolves to `AppShell`, not `PostLoginPlaceholderScreen` | unit (via state-machine test extending `AuthSessionTest` patterns) | `./gradlew :composeApp:commonTest --tests "*AppShellGateTest*"` | ❌ Wave 0 |
| UI-10 | `RecipesSearchViewModel`: `open() → onQueryChange("foo") → close()` clears query and resets `isOpen` | unit | `./gradlew :composeApp:commonTest --tests "*SearchViewModelTest*"` | ❌ Wave 0 |
| UI-10 | `RecipesSearchViewModel`: `clear()` resets only query, keeps `isOpen=true` | unit | (same target) | ❌ Wave 0 |
| UI-10 | Search affordance is visible on Recipes + Pantry tabs only (D-06) | manual smoke + screenshot per tab | n/a | manual |
### Sampling Rate
- **Per task commit:** `./gradlew :composeApp:commonTest` (existing tests + new tests for that task)
- **Per wave merge:** `./gradlew :composeApp:check` (lint/spotless + commonTest)
- **Phase gate:** Full `./gradlew check` green AND a single iOS-simulator smoke run completed by hand: launch → land on Planer empty state → tab through Przepisy / Spiżarnia / Zakupy → open search on Recipes, type a few chars, close → confirm dock collapse animation runs → confirm navigation back stacks survive tab roundtrip (smoke script in `02.1-VALIDATION.md`)
### Wave 0 Gaps
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt` — covers UI-03 nav extension semantics (uses `TestNavHostController` if available; else asserts on the lambda built into `navigateToTab()`)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` — covers UI-04 backend selection
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` — covers UI-04 debug toggle via `MapSettings`
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` — covers UI-09 (root `App()` routes Authenticated to AppShell, not placeholder)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` — covers UI-10
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` — mirror of recipes search VM test
- [ ] iOS-simulator smoke runbook captured in `02.1-VALIDATION.md` for tab back-stack + dock-collapse manual verification (UI-03/UI-04/UI-09/UI-10 visible checks)
- [ ] No new framework install needed — `kotlin.test` already in place.
**Honest note:** automated UI tests for `Compose Multiplatform on iOS` are not solved enough at this phase to be worth the cost. The shell is a shape that benefits from human eyes (animation feel, glass aesthetic, label legibility) more than from snapshot-asserting machinery. ViewModel state machines and pure helper functions are unit-testable; the visible chrome is verified by a manual smoke runbook. Phase 10 is the right place to revisit screenshot/UI testing once the shell stabilizes.
---
## Sources
### Primary (HIGH confidence)
- [JetBrains: Navigation in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation.html) — official nav-compose guide; multi-back-stack pattern
- [Maven Central: navigation-compose 2.9.2](https://central.sonatype.com/artifact/org.jetbrains.androidx.navigation/navigation-compose/2.9.2)
- [Maven Central: io.github.fletchmckee.liquid:liquid](https://central.sonatype.com/artifact/io.github.fletchmckee.liquid/liquid) — version + iOS simulator artifact existence
- [GitHub: FletchMcKee/liquid](https://github.com/FletchMcKee/liquid) — public API: `liquid(state)`, `liquefiable(state)`, `rememberLiquidState()`
- [Compose Unstyled — Installation](https://composables.com/docs/com.composables/core/installation) — artifact `com.composables:composeunstyled:1.49.9`
- [Haze docs](https://chrisbanes.github.io/haze/) and [Haze 2.0 release post](https://chrisbanes.me/posts/haze-2.0/) — version state, platform support
- [Koin Compose docs](https://insert-koin.io/docs/reference/koin-compose/compose/) — `koinViewModel(viewModelStoreOwner = parent)` pattern
- `.planning/research/PITFALLS.md` — Pitfalls 1, 5, 12, 13 directly applicable
- `.planning/research/ARCHITECTURE.md` — Pattern 1 (StateFlow), package layout convention
### Secondary (MEDIUM confidence)
- [Saurabh Jadhav: Bottom Navigation + Nested Navigation Solved](https://saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved) — concrete `popUpTo + saveState` snippet (Android Jetpack docs; CMP port behaves equivalently per JetBrains guidance)
- [droidcon: Place Scope Handling on Auto-Pilot with Koin & Compose Navigation](https://www.droidcon.com/2024/10/16/place-scope-handling-on-auto-pilot-with-koin-compose-navigation/) — koin scope patterns with NavBackStackEntry
- [Medium: Liquid Glass Components in Compose Multiplatform (Part 1, MateeDevs)](https://medium.com/mateedevs/liquid-glass-components-in-compose-multiplatform-71b7a9ffc56d) — community usage examples
- [GitHub issue: Support saving state for nested NavHostController](https://github.com/JetBrains/compose-multiplatform/issues/4735) — historical context for nav state restoration on KMP
### Tertiary (LOW confidence — flagged for Wave-0 verification)
- Liquid library full target matrix (assumption A1 — confirmed by Maven Central listing of `liquid-iossimulatorarm64` artifact, but full README iOS-Arm64 device target list not retrieved)
- `Icons.Outlined.{MenuBook,Inventory2,CalendarMonth,ShoppingCart}` availability without `material-icons-extended` (assumption A2)
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — every library is official, on Maven Central, with verified versions as of 2026-05-08
- Architecture (nested NavHost + Koin scoping): HIGH — JetBrains-documented pattern; Pitfall 13 codified; Pattern 2 is the canonical Koin recommendation
- Liquid integration specifics: MEDIUM — public API surface read from README; iOS klibs verified to exist on Maven Central but full device-target matrix not enumerated on package page (Wave-0 dependency-resolution check resolves this)
- Theme + token scaffold structure: HIGH — standard Compose `CompositionLocal` idiom; UI-SPEC pre-locked the shape
- Empty-state composable: HIGH — trivial; signature locked by D-13
- Search state machine: HIGH — pure ViewModel + StateFlow following Phase 2's established pattern
- Validation Architecture: MEDIUM — automated coverage of pure logic is solid; visible chrome relies on manual smoke given KMP iOS UI-test maturity
**Research date:** 2026-05-08
**Valid until:** 2026-06-07 (30 days; CMP / nav-compose / Liquid all on stable cadence with no upcoming breaking releases announced)