Files
recipe/.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md

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 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 </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/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.*

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:

[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}
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:

  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

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

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

Secondary (MEDIUM confidence)

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)