--- phase: 02.1 plan: 05 type: execute wave: 4 depends_on: ["02.1-03", "02.1-04", "02.1-06"] files_modified: - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt autonomous: true requirements: [UI-03, UI-04, UI-09] tags: [kotlin, compose-multiplatform, shell, dock, viewmodel, glass, compose-unstyled, accessibility, navigation] must_haves: truths: - "AppShell is the authenticated root composable; takes no params; consumes koinViewModel() and rememberNavController()" - "ShellViewModel exposes ShellState(activeTab, searchOpen) via StateFlow with method-per-action: openSearch / closeSearch / onTabChanged; per-tab query state stays in RecipesSearchViewModel / PantrySearchViewModel from plan 02.1-06" - "closeSearch() sets searchOpen=false and AppShell also closes/clears the active tab's SearchViewModel (D-08)" - "DockBar renders 4 tabs (icon + label always shown — D-02) when collapsed=false; renders single circular icon-only toggle when collapsed=true (D-05)" - "DockBar collapse animation is a single coordinated motion using Modifier.animateContentSize() + AnimatedContent at 250ms FastOutSlowInEasing (UI-SPEC line 198)" - "FloatingSearchButton renders a 44dp circular GlassSurface(cornerRadius = 22.dp) with Icons.Outlined.Search; visible only when !searchOpen && activeTab.hasSearch" - "AppShell applies GlassBackdropSource behind RootNavHost so Liquid/Haze chrome samples the screen body through the shared LocalGlassBackdropState" - "SearchPill reads/writes the active tab SearchViewModel: RecipesSearchViewModel on Recipes, PantrySearchViewModel on Pantry; ShellViewModel only coordinates shell visibility and active tab" - "Bottom chrome consumes WindowInsets.navigationBars explicitly; AppShell does NOT use safeContentPadding() to avoid double-inset (Pitfall F)" - "Direct Liquid / Haze API imports stay confined to ui/components/glass/ — DockBar / FloatingSearchButton / SearchPill consume GlassSurface only" - "Material 3 imports ZERO in any new file (CLAUDE.md / UI-SPEC line 31)" artifacts: - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt" provides: "ShellViewModel + ShellState data class" contains: "class ShellViewModel" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt" provides: "AppShell() composable — authenticated root" contains: "fun AppShell" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt" provides: "DockBar composable with collapse-on-search animation" contains: "fun DockBar" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt" provides: "FloatingSearchButton composable — 44dp circular glass button" contains: "fun FloatingSearchButton" key_links: - from: "ui/screens/shell/AppShell.kt" to: "ui/screens/shell/ShellViewModel.kt" via: "val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle()" pattern: "ShellViewModel" - from: "ui/screens/shell/AppShell.kt" to: "navigation/RootNavHost.kt" via: "RootNavHost(navController) renders as the body" pattern: "RootNavHost" - from: "ui/screens/shell/AppShell.kt" to: "ui/components/dock/DockBar.kt" via: "renders DockBar(... collapsed = ui.searchOpen, onCollapsedTap = { closeActiveSearch() })" pattern: "DockBar" - from: "ui/screens/shell/AppShell.kt" to: "ui/components/dock/FloatingSearchButton.kt" via: "conditional render when !ui.searchOpen && activeTab.hasSearch" pattern: "FloatingSearchButton" - from: "ui/components/dock/DockBar.kt" to: "ui/components/glass/GlassSurface.kt" via: "GlassSurface(cornerRadius = 28.dp / 22.dp) substrate per UI-SPEC line 253" pattern: "GlassSurface" --- Build the four core shell composables — `ShellViewModel` (state machine for activeTab + searchOpen only), `AppShell` (authenticated root composable hosting RootNavHost + bottom chrome overlay), `DockBar` (4-tab Liquid-glass pill that collapses to a single circular icon toggle when search opens — D-05 single coordinated motion), and `FloatingSearchButton` (44dp circular glass button visible only on Recipes + Pantry — D-06). All chrome consumes the `GlassSurface` primitive from plan 02.1-03; layout follows RESEARCH § Code Example 2 (lines 514-565). The dock-collapse-on-search transition is a single `animateContentSize() + AnimatedContent` block driven by `ShellState.searchOpen`. `SearchPill` is NOT part of this plan — it is owned by plan 02.1-06, and this plan depends on 02.1-06 so `AppShell` can import it directly without temporary stubs. `AppShell` wires that pill to the active tab's search ViewModel (RecipesSearchViewModel or PantrySearchViewModel) rather than duplicating query state in ShellViewModel. Per CONTEXT D-04 there is no top app bar — tab title is rendered inline by each tab screen (plan 02.1-07). AppShell is purely chrome + NavHost. Purpose: UI-03 + UI-04 — the floating Liquid-glass dock with bottom-anchored chrome is the visible identity of this phase. UI-09 — the shell exists, replacing the placeholder, so empty states have a place to render (plan 02.1-08 makes the swap; this plan creates the destination composable). Output: 4 new commonMain files. Build is green; no automated tests added (visible chrome is verified in V-09 / V-11 manual smokes — VALIDATION.md line 54-56). @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt After Wave 3 (plan 02.1-06 plus its prerequisites 02.1-03/04) lands: From plan 02.1-03 (`ui/components/glass/`): ```kotlin package dev.ulfrx.recipe.ui.components.glass @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, ) enum class GlassBackend { Liquid, Haze, Flat } val LocalGlassBackend: ProvidableCompositionLocal fun resolveGlassBackend(settings: Settings, isDebug: Boolean, default: GlassBackend): GlassBackend expect val isDebugBuild: Boolean ``` From plan 02.1-04 (`navigation/`): ```kotlin package dev.ulfrx.recipe.navigation @Serializable data object PlannerGraph @Serializable data object RecipesGraph @Serializable data object PantryGraph @Serializable data object ShoppingGraph enum class BottomBarDestination( val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector, val hasSearch: Boolean, val searchPlaceholder: StringResource?, ) { Planner, Recipes, Pantry, Shopping; companion object { val Default: BottomBarDestination = Planner } } @Composable fun RootNavHost(navController: NavHostController, modifier: Modifier = Modifier) fun NavHostController.navigateToTab(graphRoute: Any) ``` From plan 02.1-02 (`ui/theme/`): ```kotlin object RecipeTheme { val colors: RecipeColors @Composable @ReadOnlyComposable get() val typography: RecipeTypography @Composable @ReadOnlyComposable get() val spacing: RecipeSpacing @Composable @ReadOnlyComposable get() val shapes: RecipeShapes @Composable @ReadOnlyComposable get() val glass: RecipeGlass @Composable @ReadOnlyComposable get() } // RecipeColors: background, surface, surfaceGlass, content, contentMuted, accent, separator, borderCard, destructive (all Color) // RecipeTypography: display, title, body, label (all TextStyle) // RecipeSpacing: xs (4dp), sm (8dp), lg (16dp), xl (24dp), `2xl` (32dp), `3xl` (48dp) — accessor names use Kotlin valid identifiers (likely `xs`, `sm`, `lg`, `xl`, `xxl`, `xxxl` or backticked — verify exact names from RecipeSpacing.kt) ``` NOTE: Verify RecipeSpacing accessor names by reading the file before use. UI-SPEC § Spacing names them `xs/sm/lg/xl/2xl/3xl` but Kotlin identifiers cannot start with a digit, so plan 02.1-02 must have remapped `2xl` → `xxl` (or backticked them). Treat the canonical accessor names as whatever plan 02.1-02 produced; UI-SPEC's friendly names are a contract on VALUES, not on identifier names. LoginViewModel pattern (`LoginViewModel.kt:37-55`) — mirror this shape: ```kotlin class XxxViewModel(...) : ViewModel() { private val _state = MutableStateFlow(XxxState()) val state: StateFlow = _state.asStateFlow() fun action() { _state.update { ... } } } ``` LoginScreen pattern (`LoginScreen.kt:39-43`) — mirror VM observation: ```kotlin @Composable fun XxxScreen(viewModel: XxxViewModel) { val state by viewModel.state.collectAsStateWithLifecycle() // ... } ``` Compose Unstyled API (`com.composables:composeunstyled:1.49.9`) — used by DockBar: - `TabGroup` renderless primitive — explore the artifact's exports; if a `TabGroup`-equivalent does not exist in 1.49, fall back to a `Row { ... }` with `Modifier.semantics { role = Role.Tab; selected = isActive }` per UI-SPEC line 220. Compose Unstyled's exact `TabGroup` shape is API-specific and the artifact should be inspected at implementation time. RESEARCH § Standard Stack line 137 names the artifact but does not pin the specific `TabGroup` API; UI-SPEC line 180 says "TabGroup-equivalent" — meaning either the library's primitive OR a custom `Row + Tab` shape is acceptable provided the a11y semantics are correct. Compose Unstyled `Button` (UI-SPEC line 181) — used by FloatingSearchButton. Same pragmatic note: use the primitive if available; otherwise `Modifier.clickable()` on a `Box`. Task 1: Create ShellViewModel + ShellState (pure StateFlow + method-per-action) composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt — LoginViewModel.kt:37-55 — analog VM shape (StateFlow + method-per-action) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § ShellViewModel (lines 151-179) — ShellState fields + methods - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-08 (line 35) — closing search clears query; AppShell delegates query clearing to the active tab SearchViewModel from plan 02.1-06 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — for BottomBarDestination.Default Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt`: ```kotlin package dev.ulfrx.recipe.ui.screens.shell import androidx.lifecycle.ViewModel import dev.ulfrx.recipe.navigation.BottomBarDestination import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update /** * Immutable UI state for [AppShell]. The shell tracks three things: * - [activeTab] which tab is currently selected (mirrors NavHost back-stack head). * - [searchOpen] whether the search affordance is open (D-06: only valid when * [activeTab].hasSearch is true). * * Query text deliberately lives in the active tab's SearchViewModel * (RecipesSearchViewModel or PantrySearchViewModel from plan 02.1-06). This keeps * Phase 5's extension hook connected to the UI that the user actually sees. */ data class ShellState( val activeTab: BottomBarDestination = BottomBarDestination.Default, val searchOpen: Boolean = false, ) /** * Active-tab + search state machine for the shell. Pure synchronous state * transitions — no I/O, no viewModelScope.launch. Mirrors [LoginViewModel]'s * VM+StateFlow+method-per-action shape (CLAUDE.md project convention). * * Note: per-tab Search VMs (Recipes, Pantry — plan 02.1-06) own query and clear * behavior. ShellViewModel mirrors search OPEN status here so the dock and floating * button can react synchronously. */ class ShellViewModel : ViewModel() { private val _state = MutableStateFlow(ShellState()) val state: StateFlow = _state.asStateFlow() /** D-05 / D-06: open the search affordance on the active tab. No-op if the * active tab has no search (defensive — UI is supposed to gate the call). */ fun openSearch() { _state.update { current -> if (!current.activeTab.hasSearch) current else current.copy(searchOpen = true) } } /** D-08 shell half: closing hides search. AppShell also calls activeSearchVm.close(). */ fun closeSearch() { _state.update { it.copy(searchOpen = false) } } /** Tab change — also closes any open search per D-08 (closing on tab switch is * the same semantic: search state does not persist across tab switch). */ fun onTabChanged(dest: BottomBarDestination) { _state.update { ShellState(activeTab = dest, searchOpen = false) } } } ``` NOTE: this VM is registered in Koin's `shellModule` by plan 02.1-08 — not here. This plan only declares the type so AppShell (next task) can reference it. ./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `grep -c 'data class ShellState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1 - `grep -c 'class ShellViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1 - `grep -c 'val state: StateFlow' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1 - All 3 shell actions defined: `grep -cE 'fun (openSearch|closeSearch|onTabChanged)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 3 - ShellState has no query field: `grep -c 'val query' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0 - ShellViewModel has no onQueryChange/clearQuery methods: `grep -cE 'fun (onQueryChange|clearQuery)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0 - Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0 - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 ShellViewModel mirrors the LoginViewModel pattern with StateFlow + 3 method-per-action signatures; query state stays in the tab SearchViewModels from plan 02.1-06; onTabChanged resets search visibility on tab switch. Task 2: Create DockBar.kt + FloatingSearchButton.kt — chrome composables consuming GlassSurface composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt — public API just landed in plan 02.1-03 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — enum shape from plan 02.1-04 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — for token accessor verification (RecipeTheme.spacing/typography/colors) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 180 (DockBar shape) + line 181 (FloatingSearchButton) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Interaction Contracts (lines 192-216) — collapse animation contract - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Glass / Liquid contract (lines 248-256) — corner radius 28dp dock / 22dp collapsed / 22dp button - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Accessibility (lines 219-226) — Role.Tab + contentDescription - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-01 / D-02 / D-05 / D-06 — dock geometry + collapse contract - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § DockBar lines 317-327 + § FloatingSearchButton lines 332-337 Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt`: ```kotlin package dev.ulfrx.recipe.ui.components.dock import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.unit.dp import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource /** * Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180. * * - Expanded (collapsed=false): all 4 tabs, icon + label always shown (D-02), active * tab visually emphasized (wider cell + accent foreground per UI-SPEC § Color * "Accent reserved for"). Capsule shape: 28dp corner radius, 56dp height. * * - Collapsed (collapsed=true): single circular cell showing only the active tab's * icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes * [onCollapsedTap] which closes the search per D-05. * * Single coordinated animation per D-05: the entire dock animates as one block via * [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with * [FastOutSlowInEasing] per UI-SPEC line 198. Phase 10 may tune timing on real * device. * * Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid/Haze API calls are * forbidden here per RESEARCH § Anti-Patterns and CLAUDE.md non-negotiable #10. * * Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224). */ @Composable fun DockBar( destinations: List, active: BottomBarDestination, collapsed: Boolean, onTabSelect: (BottomBarDestination) -> Unit, onCollapsedTap: () -> Unit, modifier: Modifier = Modifier, ) { val cornerRadius = if (collapsed) 22.dp else 28.dp val height = if (collapsed) 44.dp else 56.dp GlassSurface( modifier = modifier .height(height) .animateContentSize(animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)), cornerRadius = cornerRadius, ) { AnimatedContent( targetState = collapsed, transitionSpec = { androidx.compose.animation.fadeIn(tween(250, easing = FastOutSlowInEasing)) togetherWith androidx.compose.animation.fadeOut(tween(250, easing = FastOutSlowInEasing)) }, label = "DockBar collapse", ) { isCollapsed -> if (isCollapsed) { CollapsedDockToggle( active = active, onTap = onCollapsedTap, ) } else { ExpandedDockTabs( destinations = destinations, active = active, onTabSelect = onTabSelect, ) } } } } @Composable private fun ExpandedDockTabs( destinations: List, active: BottomBarDestination, onTabSelect: (BottomBarDestination) -> Unit, ) { Row( modifier = Modifier.padding(horizontal = RecipeTheme.spacing.sm), horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), verticalAlignment = Alignment.CenterVertically, ) { destinations.forEach { dest -> val isActive = dest == active DockTabCell( destination = dest, isActive = isActive, onClick = { onTabSelect(dest) }, ) } } } @Composable private fun DockTabCell( destination: BottomBarDestination, isActive: Boolean, onClick: () -> Unit, ) { val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted val labelText = stringResource(destination.labelRes) Row( modifier = Modifier .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) .clip(RoundedCornerShape(22.dp)) .clickableNoRipple(onClick = onClick) .padding(horizontal = RecipeTheme.spacing.sm, vertical = RecipeTheme.spacing.xs) .semantics { role = Role.Tab selected = isActive contentDescription = labelText + (if (isActive) ", aktywna" else "") }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs), ) { androidx.compose.foundation.Image( painter = rememberVectorPainter(image = destination.icon), contentDescription = null, colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(tint), modifier = Modifier.size(20.dp), ) BasicText( text = labelText, style = RecipeTheme.typography.label.copy(color = tint), ) } } @Composable private fun CollapsedDockToggle( active: BottomBarDestination, onTap: () -> Unit, ) { val a11yLabel = stringResource(recipe.composeapp.generated.resources.Res.string.search_close_a11y) Box( modifier = Modifier .size(44.dp) .clip(RoundedCornerShape(22.dp)) .clickableNoRipple(onClick = onTap) .semantics { contentDescription = a11yLabel }, contentAlignment = Alignment.Center, ) { androidx.compose.foundation.Image( painter = rememberVectorPainter(image = active.icon), contentDescription = null, colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(RecipeTheme.colors.accent), modifier = Modifier.size(22.dp), ) } } /** * Internal helper — clickable without ripple (we're inside a glass substrate; ripple * is provided by Material 3 which is forbidden in shell code per UI-SPEC line 31). * Phase 10 may add a custom Liquid-aware press indication. */ @Composable private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = this.then( Modifier.semantics(mergeDescendants = false) {} ).then( // foundation.clickable provides press semantics + a11y without forcing Material ripple. androidx.compose.foundation.clickable( interactionSource = androidx.compose.foundation.interaction.MutableInteractionSource(), indication = null, onClick = onClick, ) ) ``` Implementation note 1: the `clickableNoRipple` extension above sketches the intent but the API used inside `then(Modifier.foundation.clickable(...))` is invalid Kotlin syntax — the executor must conform to the actual `Modifier.clickable(...)` extension (it is itself a Modifier extension, not a standalone Modifier). Recommended actual implementation: ```kotlin @Composable private fun Modifier.tabClickable(onClick: () -> Unit): Modifier { val interactionSource = remember { MutableInteractionSource() } return this.clickable( interactionSource = interactionSource, indication = null, onClick = onClick, ) } ``` Required imports: `androidx.compose.foundation.clickable`, `androidx.compose.foundation.interaction.MutableInteractionSource`, `androidx.compose.runtime.remember`. Implementation note 2: the `search_close_a11y` resource key is added by plan 02.1-04. This plan must only verify the key exists; do not edit strings.xml in plan 02.1-05. Implementation note 3: `Compose Unstyled TabGroup` was the spec'd primitive (UI-SPEC line 180). If the artifact's `TabGroup` API does not match the shape used here (separate cells with `Modifier.semantics { role = Role.Tab }`), use the artifact's primitive instead. The only contract that MUST hold: each cell has `role = Role.Tab`, `selected = isActive`, and a meaningful `contentDescription`. PATTERNS.md § DockBar line 326 confirms either path is acceptable. Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt`: ```kotlin package dev.ulfrx.recipe.ui.components.dock import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.search_open_a11y /** * 44dp circular Liquid-glass button per UI-SPEC line 181. * * Visible only on Recipes + Pantry tabs (D-06 — gated by AppShell, not here). * Hidden when search is open (also gated by AppShell — see plan 02.1-05 AppShell.kt). * * Substrate: [GlassSurface] cornerRadius=22dp = full-circle at 44dp. * Icon: [Icons.Outlined.Search] tinted [RecipeTheme.colors.content]. * Accessibility: [contentDescription] = stringResource(search_open_a11y) per UI-SPEC line 221. */ @Composable fun FloatingSearchButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val a11y = stringResource(Res.string.search_open_a11y) GlassSurface( modifier = modifier .size(44.dp) .clickable( interactionSource = interactionSource, indication = null, onClick = onClick, ) .semantics { contentDescription = a11y }, cornerRadius = 22.dp, ) { Box( modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center, ) { Image( painter = rememberVectorPainter(image = Icons.Outlined.Search), contentDescription = null, colorFilter = ColorFilter.tint(RecipeTheme.colors.content), modifier = Modifier.size(20.dp), ) } } } ``` Implementation note 4: `search_open_a11y` resource key is also owned by plan 02.1-04. This plan must only verify the key exists; do not edit strings.xml in plan 02.1-05. Material 3 boundary: NEITHER file may import `androidx.compose.material3.*`. Use `androidx.compose.material.icons.outlined.*` (icons-extended is fine — it's the icon set artifact, not Material 3 components). ./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `grep -c 'fun DockBar' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns 1 - `grep -c 'animateContentSize' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c 'AnimatedContent' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c 'FastOutSlowInEasing' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c 'durationMillis = 250' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c 'role = Role.Tab' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c 'selected = isActive' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c '28.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c '22.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c '56.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c '44.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1 - `grep -c 'fun FloatingSearchButton' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns 1 - `grep -c 'GlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns at least 1 - `grep -c 'Icons.Outlined.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns 1 - `grep -c 'search_open_a11y' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns at least 1 - Material 3 boundary in dock package: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/` returns 0 - Direct Liquid / Haze imports forbidden in dock package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/ | wc -l` returns 0 - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 DockBar renders 4-tab expanded form (icon + label) and collapses to a single circular toggle on the active tab; transition is one coordinated animateContentSize + AnimatedContent block at 250ms FastOutSlowInEasing. FloatingSearchButton is 44dp circular GlassSurface with the search icon. Both consume GlassSurface only — no direct Liquid/Haze imports. Task 3: Create AppShell.kt — authenticated root composable hosting RootNavHost + bottom chrome overlay composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt — just-created - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt — just-created - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt — just-created - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt — from plan 02.1-04 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — from plan 02.1-04 - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Code Example 2 (lines 514-565) — verbatim AppShell skeleton - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pitfall F (lines 471-473) — inset handling: navigationBars + ime, NOT safeContentPadding - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § AppShell.kt (lines 184-203) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Layout & Safe Area (lines 268-272) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt — VM observation pattern via koinViewModel + collectAsStateWithLifecycle Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt`: ```kotlin package dev.ulfrx.recipe.ui.screens.shell import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import dev.ulfrx.recipe.navigation.BottomBarDestination import dev.ulfrx.recipe.navigation.PantryGraph import dev.ulfrx.recipe.navigation.PlannerGraph import dev.ulfrx.recipe.navigation.RecipesGraph import dev.ulfrx.recipe.navigation.RootNavHost import dev.ulfrx.recipe.navigation.ShoppingGraph import dev.ulfrx.recipe.navigation.navigateToTab import dev.ulfrx.recipe.ui.components.dock.DockBar import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton import dev.ulfrx.recipe.ui.components.search.SearchPill import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel import dev.ulfrx.recipe.ui.theme.RecipeTheme import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel /** * Authenticated root composable per RESEARCH § Code Example 2 (lines 514-565). * * Layout responsibilities: * - Background: full-screen [RecipeTheme.colors.background] under the safe area. * - Body: [RootNavHost] consumes the full screen. * - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill] * (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible). * Chrome consumes [WindowInsets.navigationBars] explicitly — Pitfall F (RESEARCH * lines 471-473): do NOT also use safeContentPadding() at this layer; tab body * consumes top inset (status bars) inside each tab screen. * - [FloatingSearchButton] aligned [Alignment.BottomEnd], visible only when * !ui.searchOpen && active.hasSearch (D-06). * * Active-tab tracking: derived from the NavHost's current back stack entry's route. * The shell's [ShellViewModel] mirrors active tab so chrome can react synchronously * to tab switches even before NavHost navigation completes. */ @Composable fun AppShell(modifier: Modifier = Modifier) { val navController = rememberNavController() val backStack by navController.currentBackStackEntryAsState() val activeTab = remember(backStack) { backStack?.toBottomBarDestination() ?: BottomBarDestination.Default } val vm: ShellViewModel = koinViewModel() val ui by vm.state.collectAsStateWithLifecycle() val recipesSearchVm: RecipesSearchViewModel = koinViewModel() val recipesSearch by recipesSearchVm.state.collectAsStateWithLifecycle() val pantrySearchVm: PantrySearchViewModel = koinViewModel() val pantrySearch by pantrySearchVm.state.collectAsStateWithLifecycle() fun closeActiveSearch() { when (activeTab) { BottomBarDestination.Recipes -> recipesSearchVm.close() BottomBarDestination.Pantry -> pantrySearchVm.close() else -> Unit } vm.closeSearch() } // Sync ShellViewModel.activeTab with NavHost-derived activeTab for // back-button + deep-link cases (NavHost is the source of truth on tab change // when navigation goes through navigateToTab; this sync handles all other paths). if (ui.activeTab != activeTab) { // Idempotent — onTabChanged also clears any open search per D-08. vm.onTabChanged(activeTab) } Box( modifier = modifier .fillMaxSize() .background(RecipeTheme.colors.background), ) { // Body — RootNavHost fills the available space and is the shared source layer // for Liquid/Haze chrome sampling via GlassBackdropSource (plan 02.1-03). GlassBackdropSource(modifier = Modifier.fillMaxSize()) { RootNavHost( navController = navController, modifier = Modifier.fillMaxSize(), ) } // Bottom chrome overlay — Column anchored to bottom-center. Column( modifier = Modifier .align(Alignment.BottomCenter) .windowInsetsPadding(WindowInsets.navigationBars) .imePadding() // UI-SPEC line 271 — search pill rides above keyboard .padding(bottom = RecipeTheme.spacing.sm), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm), ) { if (ui.searchOpen && activeTab.hasSearch) { val placeholderRes = activeTab.searchPlaceholder if (placeholderRes != null) { val activeSearch = when (activeTab) { BottomBarDestination.Recipes -> recipesSearch BottomBarDestination.Pantry -> pantrySearch else -> null } val activeSearchVm = when (activeTab) { BottomBarDestination.Recipes -> recipesSearchVm BottomBarDestination.Pantry -> pantrySearchVm else -> null } SearchPill( query = activeSearch?.query.orEmpty(), onQueryChange = { activeSearchVm?.onQueryChange(it) }, onClear = { activeSearchVm?.clear() }, onClose = { closeActiveSearch() }, placeholder = stringResource(placeholderRes), ) } } DockBar( destinations = BottomBarDestination.entries, active = activeTab, collapsed = ui.searchOpen, onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) vm.onTabChanged(dest) }, onCollapsedTap = { closeActiveSearch() }, ) } // Floating search button — adjacent to dock per D-06, visible only on // tabs that have search and only when search is closed. if (!ui.searchOpen && activeTab.hasSearch) { FloatingSearchButton( onClick = { when (activeTab) { BottomBarDestination.Recipes -> recipesSearchVm.open() BottomBarDestination.Pantry -> pantrySearchVm.open() else -> Unit } vm.openSearch() }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding(WindowInsets.navigationBars) .padding(end = RecipeTheme.spacing.lg, bottom = RecipeTheme.spacing.sm), ) } } } /** * Maps a [androidx.navigation.NavBackStackEntry]'s current route hierarchy to a * [BottomBarDestination]. Reads the *parent graph* route on the back stack, since * each tab is a nested graph. */ private fun androidx.navigation.NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? { if (this == null) return null // Inspect the destination hierarchy for the parent graph route. // CMP nav-compose 2.9.2: NavDestination.hierarchy yields parent-to-child sequence. val hierarchy = destination.hierarchy return when { hierarchy.any { it.hasRoute(PlannerGraph::class) } -> BottomBarDestination.Planner hierarchy.any { it.hasRoute(RecipesGraph::class) } -> BottomBarDestination.Recipes hierarchy.any { it.hasRoute(PantryGraph::class) } -> BottomBarDestination.Pantry hierarchy.any { it.hasRoute(ShoppingGraph::class) } -> BottomBarDestination.Shopping else -> null } } ``` The `hasRoute(PlannerGraph::class)` API is the type-safe destination matcher in nav-compose 2.9.x. If the precise extension is unavailable, fall back to comparing `destination.route` strings (the string-form route is the FQN of the @Serializable type). Required imports for the helper at the bottom: ```kotlin import androidx.navigation.NavBackStackEntry // for receiver type import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hierarchy ``` Implementation note: this plan depends on 02.1-06, so `SearchPill`, `RecipesSearchViewModel`, and `PantrySearchViewModel` already exist before AppShell compiles. Do not create local stubs. ./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `grep -c 'fun AppShell' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1 - `grep -c 'rememberNavController' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1 - `grep -c 'RootNavHost' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'koinViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'collectAsStateWithLifecycle' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 3 - `grep -c 'DockBar' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'FloatingSearchButton' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'SearchPill' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'GlassBackdropSource' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'RecipesSearchViewModel\\|PantrySearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 2 - `grep -c 'activeSearchVm?.onQueryChange' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1 - `grep -c 'fun closeActiveSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1 - `grep -c 'onCollapsedTap = { closeActiveSearch() }' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1 - `grep -c 'WindowInsets.navigationBars' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'imePadding' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'safeContentPadding' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 0 (Pitfall F — must NOT use safeContentPadding here) - `grep -c 'navigateToTab' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - `grep -c 'collapsed = ui.searchOpen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1 - Conditional render of FloatingSearchButton: `grep -c '!ui.searchOpen && activeTab.hasSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - Conditional render of SearchPill: `grep -c 'ui.searchOpen && activeTab.hasSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1 - Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 0 - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 AppShell hosts RootNavHost as body inside GlassBackdropSource + DockBar / FloatingSearchButton / SearchPill as bottom chrome overlay; consumes navigationBars + ime insets explicitly per Pitfall F; renders FloatingSearchButton only on tabs where activeTab.hasSearch is true and searchOpen is false; SearchPill reads/writes the active tab SearchViewModel. - iOS K/N compile green (after prerequisite plans 02.1-03 + 02.1-04 + 02.1-06 have landed): - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 - iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0 - Material 3 boundary preserved across all 4 new files: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/` returns 0 - Liquid / Haze imports confined to glass package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/ | wc -l` returns 0 - ShellViewModel state machine semantics: closeSearch hides the search surface; AppShell delegates close/clear/query changes to the active tab SearchViewModel; onTabChanged resets shell search visibility on tab switch. - AppShell uses navigationBars + ime padding explicitly; safeContentPadding() is NOT used at AppShell layer. - V-09 + V-11 manual smoke prerequisites in place: dock collapse animation can be observed; Liquid backend renders chrome (when build resolves Liquid for the target). 1. ShellViewModel mirrors LoginViewModel's StateFlow + method-per-action shape with 3 shell actions: openSearch / closeSearch / onTabChanged. Query state lives in RecipesSearchViewModel / PantrySearchViewModel from plan 02.1-06. 2. DockBar renders 4 tabs (icon + label always — D-02) when expanded, collapses to single circular icon-only toggle on the active tab when search opens (D-05). Single coordinated animation: animateContentSize + AnimatedContent at 250ms FastOutSlowInEasing. Each tab cell has Role.Tab + selected + contentDescription (UI-SPEC line 220). 3. FloatingSearchButton is a 44dp circular GlassSurface(cornerRadius = 22.dp) with Icons.Outlined.Search and search_open_a11y description. 4. AppShell hosts RootNavHost inside GlassBackdropSource (body) + DockBar (always-present chrome) + FloatingSearchButton (visible only when !searchOpen && activeTab.hasSearch) + SearchPill (rendered conditionally and wired to the active tab SearchViewModel from plan 02.1-06). 5. AppShell consumes WindowInsets.navigationBars + imePadding() explicitly; safeContentPadding() is NOT used (Pitfall F). 6. Direct Liquid / Haze imports zero in the shell + dock packages — chrome consumes GlassSurface only. 7. Material 3 boundary preserved: zero `androidx.compose.material3` imports in any of the 4 new files. After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-05-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record: - Whether AppShell's active-tab SearchViewModel wiring covered both Recipes and Pantry paths in the final implementation. - Whether `Compose Unstyled TabGroup` API was used in DockBar or the Row + semantics fallback. - Whether `hasRoute(*Graph::class)` worked or the string-route comparison was needed for the activeTab derivation in AppShell. - Final touch-target measurements for the dock cells (≥ 44dp confirmed by visual inspection in iOS sim during 02.1-08's manual smoke).