# Phase 2.1: App Shell, Navigation & Search Foundation — Pattern Map **Mapped:** 2026-05-08 **Files analyzed:** ~28 new + 3 modified **Analogs found:** 18 with strong analog / 13 greenfield (no in-repo analog yet — first occurrence of theme tokens, glass primitive, navigation graph) --- ## File Classification ### Modified files | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | app entry / auth gate router | reactive state → composition switch | self (extend) | self-modify | | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | Koin app aggregator | DI wiring | self (extend `includes(...)` list) | self-modify | | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` | theme entry / CompositionLocal scaffold | reactive (system dark mode) → token provision | self (rewrite — currently a thin Material 3 wrapper) | self-rewrite (preserve `MaterialTheme(...)` call so legacy auth screens keep working) | | `composeApp/src/commonMain/composeResources/values/strings.xml` | resource bundle | static lookup | self (extend with `shell_*`/`empty_*`/`search_*` keys) | self-modify | | `gradle/libs.versions.toml` | version catalog | static config | self (extend) | self-modify | | `composeApp/build.gradle.kts` | Gradle config | static config | self (extend `commonMain.dependencies`) | self-modify | ### New files — Navigation | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `navigation/Routes.kt` | route definitions | static `@Serializable` types | none in repo | greenfield — first nav graph | | `navigation/BottomBarDestination.kt` | tab enum binding routes ↔ resources ↔ icons | static config | none in repo (`AuthState.kt` is the only enum-style sealed type) | greenfield | | `navigation/RootNavHost.kt` | nested NavHost host | composition tree | none in repo | greenfield (RESEARCH.md § Pattern 1 + Code Example 1 lock the API) | ### New files — Theme tokens | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `ui/theme/RecipeColors.kt` | semantic color tokens (light/dark) | static data class + selection | `RecipeTheme.kt` (`LightColors`/`DarkColors` private vals) | partial-match (extend pattern from 2 colors → 9 semantic roles) | | `ui/theme/RecipeTypography.kt` | typography tokens | static data class | none — no typography file exists yet | greenfield | | `ui/theme/RecipeSpacing.kt` | spacing tokens | static data class | none | greenfield | | `ui/theme/RecipeShapes.kt` | shape tokens (pill/circle radii) | static data class | none | greenfield | | `ui/theme/RecipeGlass.kt` | glass-surface token defaults | static data class | none | greenfield | ### New files — Glass primitive | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `ui/components/glass/GlassSurface.kt` | layered chrome substrate primitive | composition (backend dispatch) | none in repo | greenfield (RESEARCH.md § Pattern 3 locks the API) | | `ui/components/glass/GlassBackend.kt` | enum + `LocalGlassBackend` | static + CompositionLocal | none | greenfield | | `ui/components/glass/LiquidGlassSurface.kt` | Liquid backend impl | composition | none — first Liquid use | greenfield | | `ui/components/glass/HazeGlassSurface.kt` | Haze backend impl | composition | none — first Haze use | greenfield | | `ui/components/glass/FlatGlassSurface.kt` | flat translucent fallback | composition | none | greenfield | ### New files — Shell + chrome composables | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `ui/screens/shell/AppShell.kt` | authenticated root composable | reactive (StateFlow → composition) | `LoginScreen.kt` + `App.kt` | partial-match (consumes `koinViewModel`, observes `StateFlow.collectAsStateWithLifecycle()`) | | `ui/screens/shell/ShellViewModel.kt` | active-tab + search-open state machine | StateFlow + method-per-action | `LoginViewModel.kt`, `PostLoginViewModel.kt` | exact (same VM+StateFlow+method-per-action shape) | | `ui/components/dock/DockBar.kt` | floating pill with 4 tabs + collapse-on-search | composition + `Modifier.animateContentSize` | none — first Compose Unstyled `TabGroup` consumer | greenfield | | `ui/components/dock/FloatingSearchButton.kt` | adjacent floating circular icon button | composition | none — first Compose Unstyled `Button` consumer | greenfield | | `ui/components/search/SearchPill.kt` | inline bottom search pill (renderless TextField) | composition + StateFlow input echo | `LoginScreen.kt` (TextField + button styling pattern, but Material 3) | role-match (gleaned from auth screen layout style only — input semantics are new) | | `ui/components/empty/EmptyState.kt` | reusable empty-state composable | static composition | `LoginScreen.kt` Column-Center pattern | role-match (same Column / Arrangement.Center / horizontalAlignment skeleton) | ### New files — Tab screens & ViewModels | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `ui/screens/planner/PlannerScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact (same `Surface { Column { Text(stringResource(...)) } }` skeleton, but rebuilt on `RecipeTheme` instead of MaterialTheme) | | `ui/screens/planner/PlannerViewModel.kt` | screen VM | StateFlow + method-per-action | `LoginViewModel.kt` | exact | | `ui/screens/recipes/RecipesScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact | | `ui/screens/recipes/RecipesViewModel.kt` | screen VM | StateFlow | `LoginViewModel.kt` | exact | | `ui/screens/recipes/RecipesSearchViewModel.kt` | search state machine | StateFlow + method-per-action | `LoginViewModel.kt` | exact (shape mirrors; semantics from RESEARCH.md § Pattern 4) | | `ui/screens/pantry/PantryScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact | | `ui/screens/pantry/PantryViewModel.kt` | screen VM | StateFlow | `LoginViewModel.kt` | exact | | `ui/screens/pantry/PantrySearchViewModel.kt` | search state machine | StateFlow | `LoginViewModel.kt` | exact | | `ui/screens/shopping/ShoppingScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact | | `ui/screens/shopping/ShoppingViewModel.kt` | screen VM | StateFlow | `LoginViewModel.kt` | exact | ### New files — DI | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `di/ShellModule.kt` (or rolled into `AppModule`) | Koin module — VMs + glass backend factory | DI wiring | `auth/AuthModule.kt`, `user/UserModule.kt` | exact | ### New files — Tests | File | Role | Data Flow | Closest Analog | Match Quality | |------|------|-----------|----------------|---------------| | `commonTest/.../navigation/NavigationTest.kt` | nav extension unit test | pure function assertion | `LoginViewModelTest.kt` | role-match (same `kotlin.test` + `runTest` skeleton; subject under test is a NavOptions builder lambda) | | `commonTest/.../ui/components/glass/GlassBackendTest.kt` | backend selection unit test | pure | `LoginViewModelTest.kt` | role-match | | `commonTest/.../ui/components/glass/GlassBackendOverrideTest.kt` | debug-toggle test using `MapSettings` | pure | `LoginViewModelTest.kt` (fakes pattern) | role-match | | `commonTest/.../ui/screens/shell/AppShellGateTest.kt` | App.kt routing assertion | reactive | `AuthSessionTest.kt` | exact (shape: `runTest` + state-flow observation + assert branches) | | `commonTest/.../ui/screens/recipes/RecipesSearchViewModelTest.kt` | search VM unit test | StateFlow assertion | `LoginViewModelTest.kt` | exact | | `commonTest/.../ui/screens/pantry/PantrySearchViewModelTest.kt` | search VM unit test | StateFlow assertion | `LoginViewModelTest.kt` | exact | --- ## Pattern Assignments ### `App.kt` (modified — auth gate router) **Analog:** self — current `App.kt:43-58` has the `when (authState)` switch and the `Authenticated + currentUser` two-layer gate. **Pattern to preserve** (`App.kt:43-58`): ```kotlin when (authState) { AuthState.Loading -> SplashScreen() AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel()) AuthState.Authenticated -> { val user = currentUser if (user == null) { SplashScreen() } else { PostLoginPlaceholderScreen( user = user, viewModel = koinViewModel(), ) } } } ``` **Modification:** replace the `PostLoginPlaceholderScreen(...)` call with `AppShell()` (which internally hosts `RootNavHost` and consumes its own `koinViewModel()`). The `currentUser == null → SplashScreen()` arm stays. Do NOT change the `LaunchedEffect(authSession) { initialize() }` block (`App.kt:39-41`) — still load-bearing. Do NOT delete `PostLoginPlaceholderScreen` / `PostLoginViewModel` yet — RESEARCH.md § Open Question 3 + CONTEXT line 101 keep them as a logout-bridge possibility; if unused after wiring, retire them in a separate task with explicit confirmation. --- ### `RecipeTheme.kt` (rewritten — theme entry + CompositionLocal scaffold) **Analog:** self — current shape (lines 18-35) is the structural template; the body is rewritten. **Pattern to extend** (current `RecipeTheme.kt:18-35`): ```kotlin private val LightColors = lightColorScheme(primary = Color(0xFF3B6939)) private val DarkColors = darkColorScheme(primary = Color(0xFFA2D597)) @Composable fun RecipeTheme(content: @Composable () -> Unit) { val colors = if (isSystemInDarkTheme()) DarkColors else LightColors MaterialTheme(colorScheme = colors, content = content) } ``` **New shape** (RESEARCH.md § Pattern 3 + UI-SPEC § Color/Typography/Spacing/Glass): - Keep `MaterialTheme(colorScheme = ..., content = ...)` wrapping the inner block so legacy auth screens (`LoginScreen.kt:46`, `LoginScreen.kt:59` — `MaterialTheme.colorScheme.surface`, `MaterialTheme.typography.displaySmall`) keep resolving (Open Question 3, recommended resolution). - Inside the `MaterialTheme { ... }`, wrap a `CompositionLocalProvider(LocalRecipeColors provides ..., LocalRecipeTypography provides ..., LocalRecipeSpacing provides ..., LocalRecipeShapes provides ..., LocalRecipeGlass provides ..., LocalGlassBackend provides ...) { content() }`. - Public read site: `RecipeTheme.colors`, `RecipeTheme.typography`, `RecipeTheme.spacing`, `RecipeTheme.shapes`, `RecipeTheme.glass` — implement as a `companion object`-style `object RecipeTheme { val colors: RecipeColors @Composable @ReadOnlyComposable get() = LocalRecipeColors.current ... }` per the standard MaterialTheme idiom. **Color values:** UI-SPEC § Color (lines 84-92) — verbatim hex. No mockup port. --- ### `ui/screens/shell/ShellViewModel.kt` (new — VM analog: `LoginViewModel`) **Analog:** `ui/screens/auth/LoginViewModel.kt:37-55` **State + method-per-action pattern** (`LoginViewModel.kt:37-55`): ```kotlin class LoginViewModel( private val authSession: AuthSession, ) : ViewModel() { private val _state = MutableStateFlow(LoginScreenState()) val state: StateFlow = _state.asStateFlow() fun onSignInClick(browser: AuthBrowser): Job { _state.value = LoginScreenState(isLoading = true, errorKey = null) return viewModelScope.launch { val result = authSession.login(browser) _state.value = LoginScreenState(isLoading = false, errorKey = result.toErrorKeyOrNull()) } } } ``` **Apply to `ShellViewModel`:** - `data class ShellState(val activeTab: BottomBarDestination, val searchOpen: Boolean = false, val query: String = "")` — single source of truth. - `private val _state = MutableStateFlow(ShellState(activeTab = BottomBarDestination.Planner))`; expose `state: StateFlow = _state.asStateFlow()`. - Method-per-action: `fun openSearch()`, `fun closeSearch()` (D-08: clears query), `fun onQueryChange(q: String)`, `fun clearQuery()`, `fun onTabChanged(dest: BottomBarDestination)`. - No `viewModelScope.launch` needed — pure synchronous state updates (no I/O this phase). **Same pattern for** `PlannerViewModel`, `RecipesViewModel`, `PantryViewModel`, `ShoppingViewModel`, `RecipesSearchViewModel`, `PantrySearchViewModel`. The two `*SearchViewModel`s use `data class SearchState(val isOpen: Boolean = false, val query: String = "")` per RESEARCH.md § Pattern 4 (lines 395-405). Phase 5 extension hook: leave a nullable `searchSource: SearchSource? = null` constructor param — RESEARCH.md line 410. --- ### `ui/screens/shell/AppShell.kt` (new — composable analog: `LoginScreen`) **Analog:** `ui/screens/auth/LoginScreen.kt:39-93` for the shape (Composable observing a VM + `collectAsStateWithLifecycle`); the actual layout follows RESEARCH.md § Code Example 2 (lines 514-565). **ViewModel observation pattern** (`LoginScreen.kt:39-42`): ```kotlin @Composable fun LoginScreen(viewModel: LoginViewModel) { val state by viewModel.state.collectAsStateWithLifecycle() // ... } ``` **Apply to `AppShell`:** - Take no params (it lives behind the auth gate). Inside: `val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle()`. - Acquire `navController = rememberNavController()`; render `RootNavHost(navController)` as the body. - Bottom chrome is an `Align(BottomCenter)` overlay column: `if (ui.searchOpen && activeTab.hasSearch) SearchPill(...); DockBar(active=activeTab, collapsed=ui.searchOpen, ...)`. - `FloatingSearchButton` aligned `BottomEnd`, visible only when `!ui.searchOpen && activeTab.hasSearch`. **Inset handling** (avoid Pitfall F, RESEARCH.md lines 471-473): `Modifier.windowInsetsPadding(WindowInsets.navigationBars)` on the chrome column; screen bodies use `WindowInsets.statusBars` for top inset only. Do NOT use `safeContentPadding()` on AppShell — that's `LoginScreen.kt:52`'s pattern, but only because `LoginScreen` has no chrome overlay. AppShell has chrome, so it must consume insets explicitly. --- ### `ui/screens/{planner,recipes,pantry,shopping}/{Tab}Screen.kt` (new — analog: `PostLoginPlaceholderScreen`) **Analog:** `ui/screens/auth/PostLoginPlaceholderScreen.kt:32-62` **Skeleton to mirror** (`PostLoginPlaceholderScreen.kt:38-61`): ```kotlin Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface, ) { Column( modifier = Modifier .fillMaxSize() .safeContentPadding() .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = stringResource(Res.string.auth_welcome_format, user.displayName), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, ) // ... } } ``` **Adapt for tab screens:** - Replace `Surface(... color = MaterialTheme.colorScheme.surface)` with `Box(Modifier.fillMaxSize().background(RecipeTheme.colors.background))`. UI-SPEC line 184: tab body background is `RecipeColors.background`, NOT a Material `Surface`. Also, do NOT import `androidx.compose.material3.*` in new screen code (CLAUDE.md / UI-SPEC line 31 / RESEARCH.md anti-pattern at line 419). - Replace `MaterialTheme.typography.headlineSmall` with `RecipeTheme.typography.title` for the inline tab title (UI-SPEC line 64). - Replace hardcoded `padding(horizontal = 16.dp)` with `padding(horizontal = RecipeTheme.spacing.lg)` (UI-SPEC § Spacing). - The body region: inline title at top with `RecipeTheme.spacing.xl` top inset, then `EmptyState(icon = ..., title = stringResource(Res.string.empty__title), subtitle = stringResource(Res.string.empty__subtitle))` centered. - Each `*Screen(vm: *ViewModel)` takes its VM as a parameter so the composable is testable / previewable in isolation; the call site in `RootNavHost`'s `composable<*Home>` block does the `koinViewModel(viewModelStoreOwner = parentEntry)` retrieval (RESEARCH.md § Pattern 2, lines 351-357). --- ### `ui/components/empty/EmptyState.kt` (new — analog: `LoginScreen` column skeleton) **Analog:** `ui/screens/auth/LoginScreen.kt:48-92` for the centered Column pattern; full target shape locked by RESEARCH.md § Code Example 3 (lines 571-605) and UI-SPEC line 183. **Centered Column pattern from analog** (`LoginScreen.kt:48-56`): ```kotlin Column( modifier = Modifier .fillMaxSize() .safeContentPadding() .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { /* ... */ } ``` **Apply to `EmptyState`:** - Signature locked by D-13 / UI-SPEC line 183: `EmptyState(icon: ImageVector, title: String, subtitle: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null)`. - Replace `safeContentPadding()` with explicit horizontal `RecipeTheme.spacing.xl` (UI-SPEC line 183 sets the body inset and screen-level safe-area inset is owned by the screen, not the empty-state). - Tint: `Icon(... tint = RecipeTheme.colors.contentMuted, modifier = Modifier.size(48.dp))` — UI-SPEC line 183. - Spacing rhythm: icon → `Spacer(Modifier.height(RecipeTheme.spacing.sm))` → headline (`RecipeTheme.typography.display`, color `RecipeTheme.colors.content`) → `Spacer(... .lg)` → subline (`RecipeTheme.typography.body`, color `RecipeTheme.colors.contentMuted`) → if `action != null`, `Spacer(... .xl)` then `action()`. - Wrap the Column in `Modifier.semantics(mergeDescendants = true) {}` (UI-SPEC line 226; one-announce VoiceOver reading). --- ### `di/ShellModule.kt` (new — analog: `auth/AuthModule.kt`) **Analog:** `auth/AuthModule.kt:9-25` and `user/UserModule.kt:10-23`. **Module + viewModel registration pattern** (`AuthModule.kt:9-25`): ```kotlin val authModule = module { single { SecureAuthStateStore(get()) } single { OidcClient(get()) } single { AuthSession(oidcClient = get(), store = get()) } single { AuthHttpClient.create(get()) } viewModel() viewModel() } ``` **Apply to `shellModule`:** - `viewModel()`, `viewModel()`, `viewModel()`, `viewModel()`, `viewModel()`, `viewModel()`, `viewModel()`. - A `single { resolveGlassBackend(get()) }` if the debug-toggle resolution is materialized at module level. Settings comes from `multiplatform-settings` (RESEARCH.md A5 — already wired from Phase 2). - Same imports: `import org.koin.dsl.module`, `import org.koin.plugin.module.dsl.viewModel`. --- ### `di/AppModule.kt` (modified) **Analog:** self. **Pattern to extend** (`AppModule.kt:8-11`): ```kotlin val appModule = module { includes(authModule, userModule) } ``` **Modification:** add `shellModule` to the `includes(...)` list. One-line change. The comment on line 7 should be updated to reflect Phase 2.1 addition. --- ### `composeResources/values/strings.xml` (modified) **Analog:** self — current file has the `auth_*` keys. **Pattern to extend** (full current file shown above — `strings.xml:7-15`). Add the `shell_*`, `empty_*`, `search_*` resource keys per UI-SPEC § Copywriting Contract (lines 121-158) and RESEARCH.md § Code Example 4 (lines 615-637). Preserve all existing `auth_*` keys; only append. --- ### `ui/components/dock/DockBar.kt` (new — greenfield) **Analog:** none in repo. **Stylistic reference:** `LoginScreen.kt:62-80` (button structure with conditional content via `if (state.isLoading)`). **Key API contract** (locked by UI-SPEC line 180 + CONTEXT D-01 through D-05): - Signature: `DockBar(destinations: List, active: BottomBarDestination, collapsed: Boolean, onTabSelect: (BottomBarDestination) -> Unit, onCollapsedTap: () -> Unit, modifier: Modifier = Modifier)`. - Substrate: `GlassSurface(cornerRadius = 28.dp, ...)` for expanded; `GlassSurface(cornerRadius = 22.dp, ...)` for collapsed (UI-SPEC line 253). - Built on Compose Unstyled `TabGroup` primitive (UI-SPEC line 180; RESEARCH.md line 137 — `com.composables:composeunstyled:1.49.9`). - Animation: `Modifier.animateContentSize()` for expanded↔collapsed size + `AnimatedContent` for icon/label visibility crossfade. 250ms `FastOutSlowInEasing` per UI-SPEC line 198. Single coordinated motion (D-05). - Each cell: `Modifier.semantics { role = Role.Tab; selected = isActive }` (UI-SPEC line 220). - Touch target ≥ 44dp on iOS / 48dp on Android (UI-SPEC line 52, 224). --- ### `ui/components/dock/FloatingSearchButton.kt` (new — greenfield) **Analog:** none. UI-SPEC line 181. - Signature: `FloatingSearchButton(onClick: () -> Unit, modifier: Modifier = Modifier)`. - Built on Compose Unstyled `Button`, wrapping a `GlassSurface(cornerRadius = 22.dp)` (44dp full-circle). - Icon: `Icons.Outlined.Search`, tinted `RecipeTheme.colors.content`. - `contentDescription = stringResource(Res.string.search_open_a11y)`. --- ### `ui/components/search/SearchPill.kt` (new — greenfield) **Analog:** stylistic only — nothing equivalent in repo. UI-SPEC line 182. - Signature: `SearchPill(query: String, onQueryChange: (String) -> Unit, onClear: () -> Unit, onClose: () -> Unit, placeholder: String, modifier: Modifier = Modifier)`. - Built on Compose Unstyled `TextField` renderless primitive — apply local styling, do NOT roll a Material `OutlinedTextField`. - 44dp height, 22dp corner radius, `surfaceGlass` substrate (UI-SPEC line 253). - Leading search icon, trailing clear button visible only when `query.isNotEmpty()`. - `imePadding()` so the pill rides above the soft keyboard (UI-SPEC line 271; Pitfall F). --- ### `ui/components/glass/GlassSurface.kt` + backends (new — all greenfield) **Analog:** none. RESEARCH.md § Pattern 3 (lines 367-388) is the API lock. ```kotlin @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, ) ``` - Backend selected via `LocalGlassBackend.current` (CompositionLocal set once at `RecipeTheme`/`AppShell` startup). - Compile-time per target via `expect/actual` of an `expect val defaultGlassBackend: GlassBackend` in `commonMain` with `actual`s in `iosMain` (Liquid) and `androidMain` (Liquid). If targets emerge where Liquid does not compile, the `actual` returns `Haze`. - Debug runtime override: `multiplatform-settings` key `"debug.glass_backend"` checked at `RecipeTheme` init, in DEBUG builds only (gate via an `expect val isDebugBuild: Boolean`). Production binaries compile out the override path. - Liquid path uses `rememberLiquidState()` + `Modifier.liquefiable(state)` on the screen-body backdrop (set at `AppShell` level — Pitfall C, RESEARCH.md lines 454-458) and `Modifier.liquid(state)` on the chrome (`DockBar`, `SearchPill`, `FloatingSearchButton` interiors). --- ### `navigation/Routes.kt`, `BottomBarDestination.kt`, `RootNavHost.kt` (new — all greenfield) **Analog:** none. RESEARCH.md § Pattern 1 (lines 304-339) and § Code Example 1 (lines 487-510) lock the shape verbatim. Key contracts: - `@Serializable data object PlannerGraph; @Serializable data object PlannerHome; ...` — type-safe routing. - `enum class BottomBarDestination(val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector, val hasSearch: Boolean, val searchPlaceholder: StringResource?)`. The `hasSearch` flag drives D-06 (search visibility per tab). - `NavHostController.navigateToTab(graphRoute: Any)` extension applies `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true`. This is the unit under test in `NavigationTest.kt`. - Per-tab VM scoping: in each `composable<*Home>` block, `val parent = remember(entry) { navController.getBackStackEntry(*Graph) }; val vm: *ViewModel = koinViewModel(viewModelStoreOwner = parent)` (RESEARCH.md § Pattern 2). Set this pattern now even with a single screen per graph — Phase 5 inherits cleanly. --- ### Test files (new) **Analog:** `commonTest/.../ui/screens/auth/LoginViewModelTest.kt:21-77` for VM tests; `commonTest/.../auth/AuthSessionTest.kt:11-29` for state-flow gate tests. **Pattern from `LoginViewModelTest.kt`** (lines 22-32): ```kotlin class LoginViewModelTest { @Test fun cancelledAuthFailureMapsToCancelledStringResource() = runTest { val session = newSession(loginResult = OidcResult.Cancelled) val viewModel = LoginViewModel(session) viewModel.onSignInClick(NoopBrowser).join() assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) assertEquals(false, viewModel.state.value.isLoading) } } ``` **Apply to `RecipesSearchViewModelTest` / `PantrySearchViewModelTest`:** - `runTest { ... }` block; no fakes needed (VMs are pure — no I/O). - Cover: open() → `isOpen=true`; onQueryChange("foo") → `query="foo"`; close() → `isOpen=false, query=""` (D-08); clear() → `query="", isOpen=true` (UI-SPEC line 206 + CONTEXT D-08). **Apply to `AppShellGateTest`** — mirror `AuthSessionTest.kt:13-23` shape (state-machine assertion via `runTest`). Drives `App()` indirectly by stubbing `AuthSession` + `UserRepository` via Koin test container, asserts `Authenticated + currentUser != null` resolves to AppShell rather than the placeholder. Plan to inject test doubles via Koin `startKoin { modules(...) }` per `Koin.kt:7-11` shape. **Apply to `NavigationTest`** — assert the `navigateToTab(...)` extension's `NavOptionsBuilder` lambda flips the four flags. If `TestNavHostController` is unavailable in CMP commonTest, assert by capturing a fake builder. Mark this as an investigation point in Wave 0. **Apply to `GlassBackendTest` / `GlassBackendOverrideTest`** — pure-function tests over the `resolveGlassBackend(settings: Settings, isDebug: Boolean, default: GlassBackend)` function. Use `MapSettings` (multiplatform-settings test impl) per RESEARCH.md line 731. --- ## Shared Patterns ### Externalized strings (UI-01, CLAUDE.md #9) **Source:** `composeResources/values/strings.xml` + `recipe.composeapp.generated.resources.Res`. **Apply to:** every new screen, every new component that displays user-facing text. Zero hardcoded literals. **Reference call site** (`LoginScreen.kt:28-31, 58, 78`): ```kotlin import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.auth_app_name // ... Text(text = stringResource(Res.string.auth_app_name), ...) ``` **ViewModel-side resource handles** — when a VM needs to surface a string to the screen but stay locale-agnostic, return a `StringResource` (not a `String`). See `LoginViewModel.kt:13, 24, 57-63`: ```kotlin import org.jetbrains.compose.resources.StringResource // ... data class LoginScreenState(val isLoading: Boolean = false, val errorKey: StringResource? = null) ``` This phase: search VM state holds the raw `query: String` (it's user input, not a localized message). The `placeholder` for the search pill is resolved via the per-tab `searchPlaceholder: StringResource` on `BottomBarDestination`. ### ViewModel + StateFlow + method-per-action (CLAUDE.md convention) **Source:** `LoginViewModel.kt:37-55`, `PostLoginViewModel.kt:15-23`. **Apply to:** `ShellViewModel`, `PlannerViewModel`, `RecipesViewModel`, `RecipesSearchViewModel`, `PantryViewModel`, `PantrySearchViewModel`, `ShoppingViewModel`. Universal shape: - `private val _state = MutableStateFlow(())` - `val state: StateFlow = _state.asStateFlow()` - Each action is a method on the VM that calls `_state.update { ... }` or `_state.value = ...`. - No `LiveData`, no `mutableStateOf` for primary state — `StateFlow` only. ### Screen → VM observation **Source:** `App.kt:33-34`, `LoginScreen.kt:40`. **Pattern:** ```kotlin import androidx.lifecycle.compose.collectAsStateWithLifecycle // ... val state by viewModel.state.collectAsStateWithLifecycle() ``` **Apply to:** every new screen and to `AppShell`. Use `collectAsStateWithLifecycle` not `collectAsState` so iOS/Android lifecycle-aware suspension works. ### Koin VM injection at composition **Source:** `App.kt:46, 55`, `AuthModule.kt:23-24`. **Pattern:** - Module: `viewModel<*ViewModel>()`. - Call site: `val vm: *ViewModel = koinViewModel<*ViewModel>()` for non-tab-scoped, OR `val parent = remember(entry) { navController.getBackStackEntry(*Graph) }; val vm: *ViewModel = koinViewModel(viewModelStoreOwner = parent)` for tab-graph-scoped (RESEARCH.md § Pattern 2 — set the scoping pattern from day one). ### iOS-safe inset handling **Apply to:** `AppShell` (chrome insets), every screen body (top inset). - Chrome bottom: `Modifier.windowInsetsPadding(WindowInsets.navigationBars)` (or `.union(WindowInsets.ime)` for the search pill). - Body top: respect `WindowInsets.statusBars` via padding. - Do NOT layer `safeContentPadding()` on both AppShell and screens — Pitfall F. ### Material 3 boundary **Source:** UI-SPEC line 31; CLAUDE.md project decision; RESEARCH.md anti-pattern at line 419. **Apply to:** every new file outside `ui/screens/auth/`. **No `androidx.compose.material3.*` imports** in new code. Tab screens replace `Surface(... color = MaterialTheme.colorScheme.surface)` with `Box(Modifier.background(RecipeTheme.colors.background))`. Replace `MaterialTheme.typography.*` with `RecipeTheme.typography.*`. Use Compose Unstyled primitives where a renderless analog exists. The legacy auth screens (`LoginScreen.kt`, `PostLoginPlaceholderScreen.kt`, `SplashScreen.kt`) keep their Material 3 imports — explicit user discretion in CONTEXT line 52, default "leave auth screens as-is". ### Glass on chrome only **Source:** CLAUDE.md non-negotiable #10; PITFALLS Pitfall 5/12. **Apply to:** `GlassSurface` is consumed by `DockBar`, `FloatingSearchButton`, `SearchPill` exclusively. Tab body / EmptyState / future list rows render flat. Lint discipline per Pitfall E — any direct Liquid/Haze API import outside `ui/components/glass/` is a bug. --- ## No Analog Found (greenfield, lean on RESEARCH.md / UI-SPEC) | File | Role | Why no analog | Locked by | |------|------|---------------|-----------| | `navigation/Routes.kt` + `RootNavHost.kt` + `BottomBarDestination.kt` | nav graph | First nav graph in repo (Phase 2 used a `when (authState)` switch in App.kt) | RESEARCH.md § Pattern 1, Code Example 1 | | `ui/theme/RecipeColors.kt` (full token set) | semantic color scaffold | Current `RecipeTheme.kt` only has a 2-color seed | UI-SPEC § Color (lines 84-92) | | `ui/theme/RecipeTypography.kt` | typography scale | None exists | UI-SPEC § Typography (lines 60-72) | | `ui/theme/RecipeSpacing.kt` | spacing tokens | None exists | UI-SPEC § Spacing (lines 36-54) | | `ui/theme/RecipeShapes.kt` | shape tokens | None exists | UI-SPEC § Glass (line 253) | | `ui/theme/RecipeGlass.kt` | glass token defaults | None exists | UI-SPEC § Glass (lines 248-256) | | `ui/components/glass/GlassSurface.kt` + 3 backends | layered glass primitive | First Liquid/Haze use in repo | RESEARCH.md § Pattern 3, Liquid README | | `ui/components/dock/DockBar.kt` | floating tab pill with collapse animation | First Compose Unstyled `TabGroup` consumer; first animated chrome | UI-SPEC line 180; RESEARCH.md § Code Example 2 | | `ui/components/dock/FloatingSearchButton.kt` | floating circular icon button | First Compose Unstyled `Button` consumer | UI-SPEC line 181 | | `ui/components/search/SearchPill.kt` | inline bottom search input | First Compose Unstyled `TextField` consumer; first IME-aware chrome | UI-SPEC line 182; RESEARCH.md § Pattern 4 | | `ui/components/empty/EmptyState.kt` | reusable empty-state | First component in `ui/components/` | UI-SPEC line 183; RESEARCH.md § Code Example 3 | For these files, the planner should: 1. Reference the locked API in UI-SPEC (signatures, dimensions, tokens). 2. Reference the implementation patterns in RESEARCH.md (code examples + library APIs). 3. Apply the **shared patterns** above (strings externalized, RecipeTheme tokens, no Material 3, glass-only-on-chrome) verbatim — these are not greenfield even when the file is. --- ## Metadata **Analog search scope:** `composeApp/src/{commonMain,commonTest,iosMain,androidMain}/kotlin/dev/ulfrx/recipe/**` — full client tree. **Files scanned:** ~45 source files (entire current `composeApp` Kotlin tree post-Phase-2). **Strongest analogs identified:** `LoginViewModel.kt`, `PostLoginPlaceholderScreen.kt`, `AuthModule.kt`, `RecipeTheme.kt` (current), `LoginViewModelTest.kt`, `App.kt`. **Pattern extraction date:** 2026-05-08