---
phase: 2.1
slug: app-shell-navigation-search-foundation
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-05-08
---

# Phase 2.1 — Validation Strategy

> Per-phase validation contract for feedback sampling during execution.
> Sourced from `02.1-RESEARCH.md` § Validation Architecture.

---

## Test Infrastructure

| 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*" --tests "dev.ulfrx.recipe.navigation.*" --tests "dev.ulfrx.recipe.ui.components.glass.*"` |
| **Full suite command** | `./gradlew :composeApp:check` |
| **Estimated runtime** | ~30-90 seconds (commonTest); ~3-6 min (full check incl. iOS sim klib link) |

Compose UI Test on KMP iOS is not introduced this phase — feasibility is too low. Visible chrome is verified by a manual smoke runbook (see § Manual-Only Verifications).

---

## Sampling Rate

- **After every task commit:** Run `./gradlew :composeApp:commonTest`
- **After every plan wave:** Run `./gradlew :composeApp:check`
- **Before `/gsd-verify-work`:** Full suite green AND manual iOS-simulator smoke runbook executed
- **Max feedback latency:** ~90 seconds (commonTest)

---

## Per-Task Verification Map

> Task IDs are filled in by the planner. The rows below are the requirement-level verification anchors that any plan task must map onto via its `verify` block.

| Anchor | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|--------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| V-01 | TBD | 1 | UI-03 | — | `navigateToTab()` applies `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true` | unit | `./gradlew :composeApp:commonTest --tests "*NavigationTest*"` | ❌ W0 | ⬜ pending |
| V-02 | TBD | 1 | UI-04 | — | `GlassSurface` selects Liquid backend on iOS source set at compile time | unit | `./gradlew :composeApp:commonTest --tests "*GlassBackend*"` | ❌ W0 | ⬜ pending |
| V-03 | TBD | 1 | UI-04 | — | `GlassSurface` debug-toggle flow honors `multiplatform-settings` value via `MapSettings` test impl | unit | `./gradlew :composeApp:commonTest --tests "*GlassBackendOverride*"` | ❌ W0 | ⬜ pending |
| V-04 | TBD | 1 | UI-09 | — | App.kt `AuthState.Authenticated + currentUser != null` resolves to `AppShell`, not `PostLoginPlaceholderScreen` | unit | `./gradlew :composeApp:commonTest --tests "*AppShellGateTest*"` | ❌ W0 | ⬜ pending |
| V-05 | TBD | 1 | UI-10 | — | `RecipesSearchViewModel`: `open() → onQueryChange("foo") → close()` clears query and resets `isOpen` | unit | `./gradlew :composeApp:commonTest --tests "*RecipesSearchViewModelTest*"` | ❌ W0 | ⬜ pending |
| V-06 | TBD | 1 | UI-10 | — | `RecipesSearchViewModel`: `clear()` resets only `query`, keeps `isOpen=true` | unit | (same target) | ❌ W0 | ⬜ pending |
| V-07 | TBD | 1 | UI-10 | — | `PantrySearchViewModel`: parity with recipes (open/close/clear semantics) | unit | `./gradlew :composeApp:commonTest --tests "*PantrySearchViewModelTest*"` | ❌ W0 | ⬜ pending |
| V-08 | TBD | 1 | UI-09 / UI-03 | — | Each tab renders its own empty state on first launch without flash | manual smoke (iOS) | n/a | manual | ⬜ pending |
| V-09 | TBD | 1 | UI-03 | — | Bottom-tab reselect preserves nested back stack | manual smoke (iOS) | n/a | manual | ⬜ pending |
| V-10 | TBD | 1 | UI-10 | — | Search affordance visible on Recipes + Pantry tabs only (D-06) | manual smoke + screenshot per tab | n/a | manual | ⬜ pending |
| V-11 | TBD | 1 | UI-04 | — | Liquid dock/menu chrome animates on iOS device path; flat fallback path activates when override is set | manual smoke (iOS) | n/a | manual | ⬜ pending |

*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*

---

## Wave 0 Requirements

- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt` — stubs for V-01 (UI-03 navigateToTab semantics; uses `TestNavHostController` if available, else asserts on the option-builder lambda)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` — stubs for V-02 (UI-04 compile-time backend selection)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` — stubs for V-03 (UI-04 settings-driven debug override)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` — stubs for V-04 (UI-09 App.kt routing)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` — stubs for V-05/V-06 (UI-10)
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` — stubs for V-07 (UI-10 mirror)
- [ ] iOS-simulator smoke runbook (see § Manual-Only Verifications) committed alongside the phase artifacts so V-08…V-11 have a repeatable check
- [ ] No new framework install — `kotlin.test` is already wired through `recipe.kotlin.multiplatform` convention plugin
- [ ] Wave 0 dependency-resolution checks for the three load-bearing assumptions A1/A2/A3 (Liquid iOS klib resolves, Material Icons Outlined available without `material-icons-extended`, nav-compose 2.9.2 K/N back-stack save/restore on iOS)

---

## Manual-Only Verifications

| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Each tab's empty state renders without flash on first launch | UI-09 | Compose Multiplatform on iOS lacks mature snapshot/UI testing for chrome-level visual verification | iOS sim cold launch → land on Planer (default tab) → confirm intentional empty illustration + copy → no spinner/flash |
| Tab back-stack preserved across reselection | UI-03 | Real navigation behavior across nested NavHosts is best validated visibly on the simulator | Navigate Przepisy → tap any future stub detail nav → switch to Spiżarnia → switch back to Przepisy → expect previous state restored, not start dest |
| Search affordance is functional and scoped | UI-10 | UX of opening/closing/clearing must be felt, not just unit-asserted | On Recipes tab: tap search icon → surface opens → type "abc" → confirm query state → tap clear → query empty, surface still open → tap close → surface dismissed. Repeat on Pantry. Confirm Planer/Zakupy do NOT show search affordance. |
| Liquid dock/menu chrome on iOS device path | UI-04 | Glass aesthetic and performance can only be judged by eye | iOS sim run with default config → confirm Liquid menu/dock renders with the expected glass treatment → toggle debug override via `multiplatform-settings` storage → confirm flat fallback activates |
| Dock collapse animation on tab change | UI-04 / UI-09 | Animation feel | Tab between all four destinations → confirm dock animation runs smoothly, no jank |

---

## Validation Sign-Off

- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references (test file stubs above)
- [ ] No watch-mode flags
- [ ] Feedback latency < 90s for commonTest
- [ ] `nyquist_compliant: true` set in frontmatter

**Approval:** pending
