56 KiB
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>
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 isPlaner. - 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
PrzepisyandSpiż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 inui/components/;actionslot 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/3xlper UI-SPEC revision 1),GlassSurfacetoken primitive consumed by dock + search pill + floating buttons. - D-15: Both light and dark color schemes defined; system-following.
- D-16:
GlassSurfaceis 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 </user_constraints>
<phase_requirements>
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 |
| </phase_requirements> |
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/commonMainstays 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:
-
Navigation: Single root
NavHostcontaining fournavigation(...)sub-graphs (one per tab) usingorg.jetbrains.androidx.navigation:navigation-compose2.9.x (CMP port). Bottom-tab reselection usespopUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = trueso each tab's back stack survives switching. Routes are@Serializabledata object/data classper JetBrains type-safe routing. ViewModels per tab area are scoped to the parent nav-graphNavBackStackEntryviakoinViewModel(viewModelStoreOwner = parentEntry). -
Component foundation:
compose-unstyled(com.composables:composeunstyled:1.49.x) provides renderless primitives forTabGroup,Button,TextField,Modal/BottomSheet. Recipe-styled components inui/components/consume those primitives and applyRecipeThemetokens. Material 3 imports are confined toui/screens/auth/*(legacy). -
Glass surface:
GlassSurfaceprimitive inui/components/glass/with three backends — Liquid (io.github.fletchmckee.liquid:liquid:1.1.1, modifierliquid(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 inmultiplatform-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.* |
Material Icons in CMP caveat: the JetBrains CMP
material3artifact (already in catalog) bundles a baseline icon set, butIcons.Outlined.MenuBook/Icons.Outlined.Inventory2/Icons.Outlined.CalendarMonth/Icons.Outlined.ShoppingCartare in the extended icon set. CMP exposes this viaorg.jetbrains.compose.material:material-icons-extended(or pulls them transitively frommaterial3). Plan needs to verify whether the four icons referenced in UI-SPEC are available without addingmaterial-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:
[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:
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 actuals 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 actuals — 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").
// 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<PlannerGraph>(startDestination = PlannerHome) {
composable<PlannerHome> { entry ->
val parent = remember(entry) {
navController.getBackStackEntry(PlannerGraph)
}
val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
PlannerScreen(vm)
}
// future detail destinations land here
}
navigation<RecipesGraph>(startDestination = RecipesHome) { /* ... */ }
navigation<PantryGraph>(startDestination = PantryHome) { /* ... */ }
navigation<ShoppingGraph>(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.
// 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)
// 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:
- Compile-time default picked per target via
expect/actualorcommonMainconstants — e.g.iosArm64/iosSimulatorArm64/android → Liquid, anything else →Haze. - Debug runtime override read once at app start from
multiplatform-settingskey"debug.glass_backend". Production builds short-circuit this path (compiled out viaBuildConfig-style constant inandroidMain/ Kotlinexpect val isDebugactual).
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
// Source: synthesized from CONTEXT D-05 through D-09 + UI-SPEC interaction contract
class RecipesSearchViewModel : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _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<List<RecipeCard>> 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 nestedNavHost: kills back stacks (PITFALL 13). Always usenavigation()sub-graphs.koinViewModel()withoutviewModelStoreOwnerfor 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).
GlassSurfaceis 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 outsideui/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.coroutinesdelayand Compose animation specs, notSystem.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)
// 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
// 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
// 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
<!-- composeApp/src/commonMain/composeResources/values/strings.xml — extend existing file -->
<resources>
<!-- existing auth_* keys preserved -->
<!-- Shell tab labels (UI-SPEC) -->
<string name="shell_tab_planner">Planer</string>
<string name="shell_tab_recipes">Przepisy</string>
<string name="shell_tab_pantry">Spiżarnia</string>
<string name="shell_tab_shopping">Zakupy</string>
<!-- Empty states -->
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
<string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
<string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
<string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</string>
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
<!-- Search affordance (a11y + placeholders) -->
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
<string name="search_clear_a11y">Wyczyść</string>
<string name="search_placeholder_recipes">Szukaj przepisów…</string>
<string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
</resources>
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 useorg.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.
-
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-backedLocalGlassBackendplumbing is still built so an in-app toggle is a UI-only change later. - RESOLUTION: Debug-build runtime override via
multiplatform-settingskey"debug.glass_backend", gated byexpect val isDebugBuild: Booleanso 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.
- What we know: D-17 says "via
-
Should the
material-icons-extendedartifact 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-material31.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 thelinkDebugFrameworkIosSimulatorArm64acceptance check in plan 02.1-01.
- What we know: UI-SPEC selected
-
Should
RecipeThemere-exportMaterialThemefor 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 currentRecipeTheme.ktis a Material 3 wrapper. UI-SPEC says auth stays on Material 3 as legacy. - What's unclear: whether expanding
RecipeThemeinto the new token system breaks the existingMaterialTheme.*lookups in auth screens. - Recommendation:
RecipeThemekeeps wrappingMaterialTheme(colorScheme = ...)AND adds the newCompositionLocalProviderfor Recipe tokens. Auth screens continue to readMaterialTheme.*; new code readsRecipeTheme.*. Both work in the same composition. - RESOLUTION: Yes — plan 02.1-02 keeps
MaterialTheme(colorScheme = ...)wrapping the innerCompositionLocalProvider(...). Legacy auth screens (LoginScreen.kt,PostLoginPlaceholderScreen.kt,SplashScreen.kt) continue to readMaterialTheme.colorScheme.*/MaterialTheme.typography.*; new shell code readsRecipeTheme.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).
- What we know: Phase 2 auth screens use
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 checkgreen 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 in02.1-VALIDATION.md)
Wave 0 Gaps
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt— covers UI-03 nav extension semantics (usesTestNavHostControllerif available; else asserts on the lambda built intonavigateToTab())composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt— covers UI-04 backend selectioncomposeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt— covers UI-04 debug toggle viaMapSettingscomposeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt— covers UI-09 (rootApp()routes Authenticated to AppShell, not placeholder)composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt— covers UI-10composeApp/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.mdfor tab back-stack + dock-collapse manual verification (UI-03/UI-04/UI-09/UI-10 visible checks) - No new framework install needed —
kotlin.testalready 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 — official nav-compose guide; multi-back-stack pattern
- Maven Central: navigation-compose 2.9.2
- Maven Central: io.github.fletchmckee.liquid:liquid — version + iOS simulator artifact existence
- GitHub: FletchMcKee/liquid — public API:
liquid(state),liquefiable(state),rememberLiquidState() - Compose Unstyled — Installation — artifact
com.composables:composeunstyled:1.49.9 - Haze docs and Haze 2.0 release post — version state, platform support
- Koin Compose docs —
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 — concrete
popUpTo + saveStatesnippet (Android Jetpack docs; CMP port behaves equivalently per JetBrains guidance) - droidcon: 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) — community usage examples
- GitHub issue: Support saving state for nested NavHostController — 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-iossimulatorarm64artifact, but full README iOS-Arm64 device target list not retrieved) Icons.Outlined.{MenuBook,Inventory2,CalendarMonth,ShoppingCart}availability withoutmaterial-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
CompositionLocalidiom; 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)