---
phase: 02.1
plan: 06
type: execute
wave: 3
depends_on: ["02.1-03", "02.1-04"]
files_modified:
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt
  - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt
  - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt
autonomous: true
requirements: [UI-10]
tags: [kotlin, compose-multiplatform, search, viewmodel, compose-unstyled, glass, accessibility, ime, phase-5-extension-hook]

must_haves:
  truths:
    - "RecipesSearchViewModel and PantrySearchViewModel each expose state: StateFlow<SearchState> with open() / close() / onQueryChange(q) / clear() methods (RESEARCH § Pattern 4)"
    - "close() clears the query and sets isOpen=false: SearchState(isOpen=false, query=\"\") — D-08"
    - "clear() resets only query, keeps isOpen=true: state.copy(query=\"\") — D-07"
    - "Both VMs accept a nullable searchSource: SearchSource? = null constructor parameter — Phase 5 extension point per RESEARCH § Pattern 4 line 410"
    - "SearchPill is a 44dp-height pill consuming GlassSurface(cornerRadius=22.dp) per UI-SPEC line 182 + 253"
    - "SearchPill uses Modifier.imePadding() so the pill rides above the soft keyboard (UI-SPEC line 271 / Pitfall F)"
    - "SearchPill leading icon = Icons.Outlined.Search; trailing clear button visible ONLY when query.isNotEmpty(); a11y descriptions: search_clear_a11y for clear, search_close_a11y for close"
    - "V-05 + V-06 (RecipesSearchViewModelTest) and V-07 (PantrySearchViewModelTest) replace @Ignore stubs with real assertions covering open / onQueryChange / close / clear semantics"
  artifacts:
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt"
      provides: "RecipesSearchViewModel + SearchState + SearchSource interface placeholder"
      contains: "class RecipesSearchViewModel"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt"
      provides: "PantrySearchViewModel"
      contains: "class PantrySearchViewModel"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt"
      provides: "SearchPill composable — inline bottom search input"
      contains: "fun SearchPill"
  key_links:
    - from: "ui/components/search/SearchPill.kt"
      to: "ui/components/glass/GlassSurface.kt"
      via: "GlassSurface(cornerRadius = 22.dp) substrate"
      pattern: "GlassSurface"
    - from: "commonTest/.../RecipesSearchViewModelTest.kt"
      to: "ui/screens/recipes/RecipesSearchViewModel.kt"
      via: "instantiates VM and asserts SearchState transitions"
      pattern: "RecipesSearchViewModel"
    - from: "commonTest/.../PantrySearchViewModelTest.kt"
      to: "ui/screens/pantry/PantrySearchViewModel.kt"
      via: "instantiates VM and asserts SearchState transitions"
      pattern: "PantrySearchViewModel"
---

<objective>
Build the search foundation — two per-tab Search ViewModels (RecipesSearchViewModel, PantrySearchViewModel) following RESEARCH § Pattern 4 with the locked SearchState shape and 4 method-per-action signatures, plus the SearchPill composable that renders the inline bottom search input on a 44dp-height GlassSurface pill (UI-SPEC line 182). The two VMs each accept a nullable `searchSource: SearchSource? = null` constructor parameter — Phase 5's extension hook per RESEARCH § Pattern 4 line 410.

Replace the @Ignore'd Wave-0 stubs in RecipesSearchViewModelTest.kt (V-05 + V-06) and PantrySearchViewModelTest.kt (V-07) with real assertions covering open() → onQueryChange("foo") → close() → SearchState(isOpen=false, query="") (D-08) and clear() → SearchState(isOpen=true, query="") (D-07).

Both Search VMs are pure-state — no I/O this phase. The SearchSource type is declared as a placeholder interface in RecipesSearchViewModel.kt's package; Phase 5 implements it. Why declare the type now? So plan 02.1-08's ShellModule registers VMs with `viewModel { RecipesSearchViewModel(searchSource = null) }` cleanly.

Purpose: UI-10 hard-coded — search affordance functional before catalog data exists; open/close + query echo + clear/close work; no-results state is deliberate (renders nothing in the search-surface body — D-07).
Output: 3 new commonMain files (2 VMs + SearchPill); 2 commonTest files un-ignored with real assertions covering V-05 / V-06 / V-07.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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
@.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
@composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt
@composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt

<interfaces>
After Wave 2 (plans 02.1-03, 02.1-04) lands:

From plan 02.1-03 (`ui/components/glass/`):
```kotlin
@Composable fun GlassSurface(
    modifier: Modifier = Modifier,
    tint: Color = RecipeTheme.colors.surfaceGlass,
    cornerRadius: Dp = 28.dp,
    border: BorderStroke? = ...,
    content: @Composable BoxScope.() -> Unit,
)
```

LoginViewModel pattern (analog from `LoginViewModel.kt:37-55`) — mirror this shape:
```kotlin
class XxxViewModel : ViewModel() {
    private val _state = MutableStateFlow(XxxState())
    val state: StateFlow<XxxState> = _state.asStateFlow()
    fun action() { _state.update { ... } }
}
```

Compose Unstyled TextField (renderless primitive, `com.composables:composeunstyled:1.49.9`) — used by SearchPill per UI-SPEC line 182. The expected API is a `TextField` composable with slot-based styling. If the artifact's exact shape differs, the fallback is `androidx.compose.foundation.text.BasicTextField` from `compose-foundation` — NOT `androidx.compose.material3.TextField` (Material 3 forbidden in shell code). BasicTextField is a renderless equivalent and provides the same a11y / IME plumbing.

Resource keys to be used (added by plan 02.1-04 before this plan runs):
- `Res.string.search_clear_a11y` ("Wyczyść")
- `Res.string.search_close_a11y` ("Zamknij wyszukiwanie")
- `Res.string.search_placeholder_recipes` ("Szukaj przepisów…") — from plan 02.1-04, already present
- `Res.string.search_placeholder_pantry` ("Szukaj w spiżarni…") — from plan 02.1-04, already present

The placeholder text is passed in as a `String` parameter (not a StringResource) so the SearchPill stays decoupled from per-tab resource keys. AppShell (plan 02.1-05) resolves the placeholder via `stringResource(activeTab.searchPlaceholder)` and hands it to SearchPill.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create RecipesSearchViewModel.kt + PantrySearchViewModel.kt + SearchSource placeholder interface</name>
  <files>
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt,
    composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt
  </files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt — analog VM shape (LoginViewModel.kt:37-55)
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pattern 4 (lines 390-410) — verbatim SearchState + VM shape
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md line 410 — Phase 5 extension hook: nullable searchSource parameter
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-07 + D-08 (lines 33-35) — close() clears query; clear() preserves isOpen
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § ShellViewModel (lines 151-179) — SearchState semantics also described here
  </read_first>
  <action>
    Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt`:

    ```kotlin
    package dev.ulfrx.recipe.ui.screens.recipes

    import androidx.lifecycle.ViewModel
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.StateFlow
    import kotlinx.coroutines.flow.asStateFlow
    import kotlinx.coroutines.flow.update

    /**
     * Per-tab search state for [RecipesSearchViewModel] and [PantrySearchViewModel]
     * (RESEARCH § Pattern 4, lines 390-410).
     *
     *  - [isOpen] — whether the search affordance is open on this tab.
     *  - [query] — the current query echo (D-07: just an echo this phase; results
     *    plumbing arrives in Phase 5 / 8 for Recipes / Pantry respectively).
     */
    data class SearchState(
        val isOpen: Boolean = false,
        val query: String = "",
    )

    /**
     * Phase 5 (Recipes) and Phase 8 (Pantry) implement and inject a real
     * [SearchSource]; Phase 2.1 leaves it null. The Search VMs accept a nullable
     * source today so Phase 5 / 8 only inject a dependency, not refactor the VM.
     *
     * Defined here (in `recipes/` package) as a marker — Phase 5 introduces the
     * Recipes-specific implementation; Phase 8 may either reuse or shadow with its
     * own version. Either way, this phase does NOT call into [SearchSource].
     */
    interface SearchSource {
        // Phase 5 / 8 add: fun observe(query: String): Flow<List<*>>
    }

    /**
     * RecipesSearchViewModel per RESEARCH § Pattern 4. Pure state machine; no I/O
     * this phase (the [searchSource] parameter is the Phase 5 extension hook —
     * RESEARCH line 410). Constructor parameter has a default so Koin can register
     * with `viewModel { RecipesSearchViewModel() }` and Phase 5 swaps to
     * `viewModel { RecipesSearchViewModel(searchSource = get()) }`.
     */
    class RecipesSearchViewModel(
        @Suppress("UNUSED_PARAMETER")
        private val searchSource: SearchSource? = null,
    ) : ViewModel() {
        private val _state = MutableStateFlow(SearchState())
        val state: StateFlow<SearchState> = _state.asStateFlow()

        /** Open the search affordance. */
        fun open() {
            _state.update { it.copy(isOpen = true) }
        }

        /** D-08: closing clears the query — reopening starts blank. */
        fun close() {
            _state.value = SearchState(isOpen = false, query = "")
        }

        /** Query echo. Phase 5 will plumb `searchSource.observe(...)` here. */
        fun onQueryChange(q: String) {
            _state.update { it.copy(query = q) }
        }

        /** D-07: clear() resets only the query and keeps isOpen=true. */
        fun clear() {
            _state.update { it.copy(query = "") }
        }
    }
    ```

    Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt`:

    ```kotlin
    package dev.ulfrx.recipe.ui.screens.pantry

    import androidx.lifecycle.ViewModel
    import dev.ulfrx.recipe.ui.screens.recipes.SearchSource
    import dev.ulfrx.recipe.ui.screens.recipes.SearchState
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.StateFlow
    import kotlinx.coroutines.flow.asStateFlow
    import kotlinx.coroutines.flow.update

    /**
     * PantrySearchViewModel — semantic parity with [RecipesSearchViewModel]. Both
     * VMs share [SearchState] and [SearchSource] from `ui.screens.recipes` (the
     * canonical home for the search-state shape).
     *
     * Phase 8 (Pantry) injects a Pantry-specific SearchSource. This phase: pure echo.
     * Constructor parameter has a default so Koin can register without a source today.
     */
    class PantrySearchViewModel(
        @Suppress("UNUSED_PARAMETER")
        private val searchSource: SearchSource? = null,
    ) : ViewModel() {
        private val _state = MutableStateFlow(SearchState())
        val state: StateFlow<SearchState> = _state.asStateFlow()

        fun open() {
            _state.update { it.copy(isOpen = true) }
        }

        /** D-08: closing clears the query. */
        fun close() {
            _state.value = SearchState(isOpen = false, query = "")
        }

        fun onQueryChange(q: String) {
            _state.update { it.copy(query = q) }
        }

        /** D-07: clear() resets only the query, preserves isOpen. */
        fun clear() {
            _state.update { it.copy(query = "") }
        }
    }
    ```

    Note: `SearchState` and `SearchSource` are declared once in `ui.screens.recipes` and
    re-imported by `ui.screens.pantry`. This avoids drift between the two VMs and
    matches the RESEARCH § Pattern 4 contract that both have the same shape.
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'data class SearchState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
    - `grep -c 'interface SearchSource' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
    - `grep -c 'class RecipesSearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
    - `grep -c 'searchSource: SearchSource? = null' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 1
    - All 4 actions on Recipes VM: `grep -cE 'fun (open|close|onQueryChange|clear)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt` returns 4
    - close() resets isOpen and query: `awk '/fun close/,/^    }$/' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt | grep -c 'isOpen = false, query = ""'` returns 1
    - clear() does not touch isOpen: `awk '/fun clear/,/^    }$/' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt | grep -c 'isOpen'` returns 0
    - `grep -c 'class PantrySearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 1
    - `grep -c 'searchSource: SearchSource? = null' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 1
    - All 4 actions on Pantry VM: `grep -cE 'fun (open|close|onQueryChange|clear)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 4
    - PantrySearchViewModel imports SearchState and SearchSource from `ui.screens.recipes`: `grep -c 'import dev.ulfrx.recipe.ui.screens.recipes.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns at least 2
    - Material 3 boundary: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt` returns 0
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>Two SearchViewModels with identical 4-action API and SearchState shape; SearchState + SearchSource declared once in recipes package and reused by pantry. Phase 5/8 extension hook (nullable searchSource) is in place. Build is green.</done>
</task>

<task type="auto">
  <name>Task 2: Create SearchPill.kt — inline bottom search pill on GlassSurface substrate</name>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt</files>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt — public API from plan 02.1-03
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — token accessors from plan 02.1-02
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 182 — SearchPill shape
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Glass / Liquid contract lines 248-256 — corner radius 22dp, height 44dp
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Layout & Safe Area line 271 — imePadding for keyboard avoidance
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Accessibility line 223 — clear button only when query non-empty; contentDescription = search_clear_a11y
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § SearchPill (lines 341-348)
    - composeApp/src/commonMain/composeResources/values/strings.xml — verify search_clear_a11y / search_close_a11y already exist from plan 02.1-04; do not edit this file in plan 02.1-06
  </read_first>
  <action>
    Step 1 — verify resource-key prerequisites from plan 02.1-04:
    ```bash
    grep -c 'search_clear_a11y\|search_close_a11y' composeApp/src/commonMain/composeResources/values/strings.xml
    ```
    The count MUST be 2. If it is not, stop and repair/re-run plan 02.1-04; do not add
    keys here because plan 02.1-06 has no strings.xml ownership.

    Step 2 — create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt`:

    ```kotlin
    package dev.ulfrx.recipe.ui.components.search

    import androidx.compose.foundation.Image
    import androidx.compose.foundation.clickable
    import androidx.compose.foundation.interaction.MutableInteractionSource
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.Row
    import androidx.compose.foundation.layout.fillMaxWidth
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.layout.size
    import androidx.compose.foundation.text.BasicTextField
    import androidx.compose.foundation.text.KeyboardOptions
    import androidx.compose.foundation.text.input.KeyboardCapitalization
    import androidx.compose.foundation.text.input.ImeAction
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.outlined.Close
    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.SolidColor
    import androidx.compose.ui.graphics.vector.rememberVectorPainter
    import androidx.compose.ui.semantics.contentDescription
    import androidx.compose.ui.semantics.semantics
    import androidx.compose.ui.text.input.TextFieldValue
    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_clear_a11y
    import recipe.composeapp.generated.resources.search_close_a11y

    /**
     * Inline bottom search pill per CONTEXT D-09 + UI-SPEC line 182.
     *
     * Geometry: 44dp height, 22dp corner radius (full-pill at 44dp).
     * Substrate: [GlassSurface] with [RecipeTheme.colors.surfaceGlass] tint.
     *
     * Layout (left → right):
     *  - Leading [Icons.Outlined.Search] icon, tinted [RecipeTheme.colors.contentMuted].
     *  - [BasicTextField] for query input (renderless — Material 3 forbidden in shell
     *    code per UI-SPEC line 31; Compose Unstyled `TextField` was the spec'd primitive
     *    but `BasicTextField` is a clean equivalent that ships with compose-foundation).
     *  - Trailing clear icon — visible ONLY when [query] is non-empty (UI-SPEC line 223).
     *  - Trailing close icon — always visible; tap dismisses the search per D-08.
     *
     * Keyboard avoidance: `Modifier.imePadding()` is applied by the caller (AppShell —
     * plan 02.1-05) at the chrome Column level, NOT here, to keep the pill geometry
     * decoupled from inset handling.
     *
     * Accessibility: clear button has [search_clear_a11y]; close button has
     * [search_close_a11y]. The text field itself is a standard BasicTextField, so its
     * VoiceOver semantics work out of the box.
     */
    @Composable
    fun SearchPill(
        query: String,
        onQueryChange: (String) -> Unit,
        onClear: () -> Unit,
        onClose: () -> Unit,
        placeholder: String,
        modifier: Modifier = Modifier,
    ) {
        val clearLabel = stringResource(Res.string.search_clear_a11y)
        val closeLabel = stringResource(Res.string.search_close_a11y)

        GlassSurface(
            modifier = modifier
                .fillMaxWidth()
                .height(44.dp)
                .padding(horizontal = RecipeTheme.spacing.lg),
            cornerRadius = 22.dp,
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = RecipeTheme.spacing.lg),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
            ) {
                // Leading search icon.
                Image(
                    painter = rememberVectorPainter(image = Icons.Outlined.Search),
                    contentDescription = null,
                    colorFilter = ColorFilter.tint(RecipeTheme.colors.contentMuted),
                    modifier = Modifier.size(20.dp),
                )

                // Query input — fills available width.
                Box(modifier = Modifier.weight(1f)) {
                    BasicTextField(
                        value = query,
                        onValueChange = onQueryChange,
                        textStyle = RecipeTheme.typography.body.copy(color = RecipeTheme.colors.content),
                        cursorBrush = SolidColor(RecipeTheme.colors.accent),
                        singleLine = true,
                        modifier = Modifier.fillMaxWidth(),
                        decorationBox = { innerField ->
                            if (query.isEmpty()) {
                                BasicTextWithStyle(
                                    text = placeholder,
                                    color = RecipeTheme.colors.contentMuted,
                                    style = RecipeTheme.typography.body,
                                )
                            }
                            innerField()
                        },
                    )
                }

                // Trailing clear icon — only when query is non-empty.
                if (query.isNotEmpty()) {
                    val clearInteraction = remember { MutableInteractionSource() }
                    Image(
                        painter = rememberVectorPainter(image = Icons.Outlined.Close),
                        contentDescription = null,
                        colorFilter = ColorFilter.tint(RecipeTheme.colors.contentMuted),
                        modifier = Modifier
                            .size(20.dp)
                            .clickable(
                                interactionSource = clearInteraction,
                                indication = null,
                                onClick = onClear,
                            )
                            .semantics { contentDescription = clearLabel },
                    )
                }

                // Trailing close icon — always visible inside the pill.
                val closeInteraction = remember { MutableInteractionSource() }
                Image(
                    painter = rememberVectorPainter(image = Icons.Outlined.Close),
                    contentDescription = null,
                    colorFilter = ColorFilter.tint(RecipeTheme.colors.content),
                    modifier = Modifier
                        .size(20.dp)
                        .clickable(
                            interactionSource = closeInteraction,
                            indication = null,
                            onClick = onClose,
                        )
                        .semantics { contentDescription = closeLabel },
                )
            }
        }
    }

    /**
     * Internal helper — placeholder text rendered when the BasicTextField is empty.
     * Plain text in [RecipeTheme.typography.body] tinted [RecipeTheme.colors.contentMuted].
     */
    @Composable
    private fun BasicTextWithStyle(
        text: String,
        color: androidx.compose.ui.graphics.Color,
        style: androidx.compose.ui.text.TextStyle,
    ) {
        androidx.compose.foundation.text.BasicText(
            text = text,
            style = style.copy(color = color),
        )
    }
    ```

    Implementation note: the close button visually duplicates the trailing clear icon
    (both are X glyphs). UI-SPEC § Accessibility line 223 distinguishes them by
    contentDescription only. If a future revision wants distinct glyphs (e.g. arrow-down
    for close), that's a Phase 10 polish concern — this phase ships functional parity
    with the spec. The clear button is OPTIONAL (visible only when query non-empty); the
    close button is ALWAYS visible inside the pill. The user can dismiss the search by
    tapping either the close button OR the dock's collapsed toggle (which is OUTSIDE the
    pill, owned by DockBar from plan 02.1-05).

    Implementation note 2: Compose Unstyled's `TextField` API was the originally
    specified primitive. If the artifact's API at 1.49.9 does not expose a renderless
    `TextField` that delegates to `BasicTextField` cleanly, use `BasicTextField` directly
    as above — `compose-foundation` provides it and that's already on the classpath.
    `BasicTextField` is itself renderless (no Material 3 chrome). Document the chosen
    primitive in the SUMMARY.

    Material 3 boundary check: NO `androidx.compose.material3.*` imports.
    `androidx.compose.material.icons.outlined.*` is fine — it's the icon set, not
    Material 3 components.
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c 'fun SearchPill' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 1
    - SearchPill signature: takes query, onQueryChange, onClear, onClose, placeholder — `grep -c 'query: String\|onQueryChange: (String)\|onClear: () -> Unit\|onClose: () -> Unit\|placeholder: String' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 5
    - GlassSurface substrate: `grep -c 'GlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 1
    - 22dp corner radius: `grep -c 'cornerRadius = 22.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 1
    - 44dp height: `grep -c '44.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 1
    - Conditional clear: `grep -c 'query.isNotEmpty' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 1
    - A11y descriptions: `grep -c 'search_clear_a11y\|search_close_a11y' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns at least 2
    - Leading Search icon: `grep -c 'Icons.Outlined.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 1
    - Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 0
    - Liquid / Haze imports forbidden in search package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/ | wc -l` returns 0
    - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
  </acceptance_criteria>
  <done>SearchPill renders an inline 44dp-height GlassSurface pill with leading search icon, BasicTextField for query input, conditional clear button, and always-visible close button. A11y descriptions resolve via stringResource. Material 3 zero imports.</done>
</task>

<task type="auto">
  <name>Task 3: Replace @Ignore stubs in RecipesSearchViewModelTest + PantrySearchViewModelTest with real assertions covering V-05 / V-06 / V-07</name>
  <files>
    composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt,
    composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt
  </files>
  <read_first>
    - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt — current Wave-0 stub
    - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt — current Wave-0 stub
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt — just created
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt — just created
    - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/LoginViewModelTest.kt — kotlin.test pattern shape
    - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md § Per-Task Verification Map V-05 / V-06 / V-07 (lines 50-52)
  </read_first>
  <action>
    Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` with:

    ```kotlin
    package dev.ulfrx.recipe.ui.screens.recipes

    import kotlin.test.Test
    import kotlin.test.assertEquals
    import kotlinx.coroutines.test.runTest

    /**
     * V-05 + V-06 — UI-10 — RecipesSearchViewModel state-machine semantics
     * (RESEARCH § Pattern 4 + CONTEXT D-07 / D-08).
     *
     * V-05: open() → onQueryChange("foo") → close() leaves SearchState(isOpen=false, query="").
     * V-06: clear() resets only query, keeps isOpen=true.
     */
    class RecipesSearchViewModelTest {
        @Test
        fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() = runTest {
            val vm = RecipesSearchViewModel()
            vm.open()
            vm.onQueryChange("foo")
            assertEquals(SearchState(isOpen = true, query = "foo"), vm.state.value)
            vm.close()
            assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
        }

        @Test
        fun clear_resetsQueryButKeepsIsOpenTrue() = runTest {
            val vm = RecipesSearchViewModel()
            vm.open()
            vm.onQueryChange("foo")
            vm.clear()
            assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
        }

        @Test
        fun open_setsIsOpenTrueWithoutTouchingQuery() = runTest {
            val vm = RecipesSearchViewModel()
            assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
            vm.open()
            assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
        }

        @Test
        fun onQueryChange_doesNotAffectIsOpen() = runTest {
            val vm = RecipesSearchViewModel()
            vm.onQueryChange("foo")
            assertEquals(SearchState(isOpen = false, query = "foo"), vm.state.value)
        }

        @Test
        fun closeFromAlreadyClosed_isIdempotent() = runTest {
            val vm = RecipesSearchViewModel()
            vm.close()
            assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
            vm.close()
            assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
        }
    }
    ```

    Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` with:

    ```kotlin
    package dev.ulfrx.recipe.ui.screens.pantry

    import dev.ulfrx.recipe.ui.screens.recipes.SearchState
    import kotlin.test.Test
    import kotlin.test.assertEquals
    import kotlinx.coroutines.test.runTest

    /**
     * V-07 — UI-10 — PantrySearchViewModel parity with RecipesSearchViewModel
     * (open / close / clear semantics — CONTEXT D-07 / D-08).
     */
    class PantrySearchViewModelTest {
        @Test
        fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() = runTest {
            val vm = PantrySearchViewModel()
            vm.open()
            vm.onQueryChange("mleko")
            assertEquals(SearchState(isOpen = true, query = "mleko"), vm.state.value)
            vm.close()
            assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
        }

        @Test
        fun clear_resetsQueryButKeepsIsOpenTrue() = runTest {
            val vm = PantrySearchViewModel()
            vm.open()
            vm.onQueryChange("mleko")
            vm.clear()
            assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
        }

        @Test
        fun open_setsIsOpenTrueWithoutTouchingQuery() = runTest {
            val vm = PantrySearchViewModel()
            assertEquals(SearchState(isOpen = false, query = ""), vm.state.value)
            vm.open()
            assertEquals(SearchState(isOpen = true, query = ""), vm.state.value)
        }
    }
    ```

    Both files MUST drop the `@Ignore` import + annotation. Both use `kotlin.test` only.
  </action>
  <verify>
    <automated>./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns 0
    - `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` returns 0
    - V-05 covered: `grep -c 'openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns 1
    - V-06 covered: `grep -c 'clear_resetsQueryButKeepsIsOpenTrue' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns 1
    - V-07 covered: `grep -c 'openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` returns 1
    - Recipes test has at least 5 @Test functions: `grep -c '@Test' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` returns at least 5
    - Pantry test has at least 3 @Test functions: `grep -c '@Test' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` returns at least 3
    - `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q` exits 0
  </acceptance_criteria>
  <done>RecipesSearchViewModelTest contains 5 passing assertions covering V-05 + V-06 + edge cases; PantrySearchViewModelTest contains 3 passing assertions covering V-07; @Ignore is gone from both files. UI-10 has its core unit-test coverage.</done>
</task>

</tasks>

<verification>
- iOS K/N compile green: `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
- Search VM tests pass: `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModelTest" --tests "dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModelTest" -q` exits 0
- iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0
- Material 3 boundary preserved across all 3 new common files: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt` returns 0
- Liquid / Haze imports zero outside glass package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModel.kt | wc -l` returns 0
</verification>

<success_criteria>
1. RecipesSearchViewModel.kt declares SearchState (data class) + SearchSource (placeholder interface) + RecipesSearchViewModel class with 4 actions (open / close / onQueryChange / clear).
2. PantrySearchViewModel.kt declares PantrySearchViewModel class with the same 4-action API; imports SearchState + SearchSource from `ui.screens.recipes` package.
3. Both VMs accept nullable searchSource: SearchSource? = null constructor parameter (Phase 5 / 8 extension hook per RESEARCH § Pattern 4 line 410).
4. close() clears query (D-08) on both VMs; clear() preserves isOpen (D-07) on both VMs.
5. SearchPill renders 44dp-height pill on GlassSurface(cornerRadius = 22.dp) with leading search icon, BasicTextField input, conditional clear button (visible only when query non-empty per UI-SPEC line 223), and always-visible close button. A11y descriptions resolve from `search_clear_a11y` / `search_close_a11y`.
6. V-05 anchor: RecipesSearchViewModelTest passes 5 assertions.
7. V-06 anchor: covered by RecipesSearchViewModelTest (`clear_resetsQueryButKeepsIsOpenTrue`).
8. V-07 anchor: PantrySearchViewModelTest passes 3 assertions.
9. Material 3 boundary preserved: zero `androidx.compose.material3` imports in any new file.
</success_criteria>

<output>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-06-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record:
- Whether Compose Unstyled's `TextField` was used or BasicTextField was the fallback in SearchPill, and why.
- Whether `search_clear_a11y` / `search_close_a11y` were present from plan 02.1-04 before SearchPill compilation.
- Whether the SearchSource placeholder interface declaration is in `recipes/` package as planned, or moved (and why).
- Plan 02.1-05 (AppShell) dependency handoff: confirm AppShell consumed this plan's SearchPill and per-tab Search ViewModels directly, with no local stubs.
</output>
