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

43 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02.1 03 execute 2
02.1-01
02.1-02
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt
true
UI-04
kotlin
compose-multiplatform
glass
liquid
haze
composition-local
expect-actual
multiplatform-settings
truths artifacts key_links
GlassSurface dispatches to one of three backends (Liquid / Haze / Flat) via LocalGlassBackend
resolveGlassBackend(settings, isDebug, default) returns the compile-time default when isDebug=false regardless of settings content (D-17 production short-circuit)
resolveGlassBackend honors multiplatform-settings key 'debug.glass_backend' values 'liquid' | 'haze' | 'flat' when isDebug=true (D-17 debug override)
isDebugBuild expect/actual returns true for Android debug builds and iOS Debug configs, false for release builds — production binaries compile out the override path
All three backends consume the same token API (tint Color, cornerRadius Dp, optional BorderStroke) — D-16 same API across paths
GlassBackdrop.kt exposes a shared GlassBackdropState + GlassBackdropSource wrapper so Liquid/Haze chrome samples the same source layer that AppShell applies behind RootNavHost
Direct Liquid / Haze API imports live ONLY inside ui/components/glass/* — chrome-only constraint preserved
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt enum GlassBackend, val LocalGlassBackend, fun resolveGlassBackend(...) enum class GlassBackend
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt Public GlassSurface composable that dispatches by LocalGlassBackend.current fun GlassSurface
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt Shared backdrop/source wrapper consumed by AppShell and glass backends fun GlassBackdropSource
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt Liquid backend implementation using io.github.fletchmckee.liquid internal fun LiquidGlassSurface
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt Haze backend implementation using dev.chrisbanes.haze internal fun HazeGlassSurface
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt Flat translucent fallback (no blur) using surfaceGlass token internal fun FlatGlassSurface
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt expect val isDebugBuild: Boolean — gates the multiplatform-settings override expect val isDebugBuild
from to via pattern
ui/components/glass/GlassSurface.kt ui/components/glass/GlassBackend.kt LocalGlassBackend.current dispatch + when(backend) LocalGlassBackend.current
from to via pattern
ui/components/glass/GlassSurface.kt ui/components/glass/GlassBackdrop.kt GlassSurface consumes LocalGlassBackdropState; AppShell applies GlassBackdropSource to the body LocalGlassBackdropState
from to via pattern
ui/components/glass/GlassBackend.kt com.russhwolf.settings.Settings resolveGlassBackend reads 'debug.glass_backend' key when isDebugBuild debug.glass_backend
from to via pattern
commonTest/.../GlassBackendTest.kt + GlassBackendOverrideTest.kt ui/components/glass/GlassBackend.kt calls resolveGlassBackend(MapSettings(), isDebug, default) and asserts result resolveGlassBackend
Build the layered GlassSurface primitive — a single public composable that dispatches between three backends (Liquid / Haze / Flat) via a CompositionLocal, with backend selection driven by a compile-time per-target default plus a debug-build runtime override read from multiplatform-settings. Also create the shared GlassBackdrop state/source wrapper used by AppShell so Liquid/Haze chrome samples the actual screen body instead of local isolated state. Replace the @Ignore'd Wave-0 stubs in GlassBackendTest.kt and GlassBackendOverrideTest.kt with real assertions hitting the new pure helper `resolveGlassBackend(settings, isDebug, default)`.

Purpose: Centralize all glass-effect implementation behind one API per D-16 / D-17. Direct Liquid / Haze imports stay confined to this package — chrome-only constraint preserved. The LocalGlassBackend CompositionLocal plus LocalGlassBackdropState are the seams Phase 10 tunes without touching call sites (DockBar, FloatingSearchButton, SearchPill in plans 05 + 06). Output: 6 new commonMain files in ui/components/glass/, 1 expect declaration + 2 actuals (iOS / Android) for isDebugBuild, 2 test files un-ignored with real assertions covering V-02 / V-03.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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/theme/RecipeTheme.kt @composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt @composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt After plan 02.1-02 lands, these are available: - `dev.ulfrx.recipe.ui.theme.RecipeTheme.colors.surfaceGlass: Color` — default tint. - `dev.ulfrx.recipe.ui.theme.RecipeTheme.colors.borderCard: Color` — default border.

After plan 02.1-01 lands, these libraries are on the commonMain classpath:

  • io.github.fletchmckee.liquid:liquid:1.1.1 — public API per RESEARCH § Pattern 3 lines 367-388:
    • rememberLiquidState()
    • Modifier.liquefiable(state: LiquidState) — applied at the backdrop (AppShell screen body)
    • Modifier.liquid(state: LiquidState) — applied at the chrome layer
  • dev.chrisbanes.haze:haze:1.6.10HazeState, Modifier.haze(state) (backdrop), Modifier.hazeChild(state, shape, ...) (chrome) per Haze 1.x docs.
  • com.russhwolf:multiplatform-settings:1.3.0 — already on commonMain via Phase 2; Settings interface, MapSettings (in test artifact).

Existing analog for expect/actual pattern (search the repo for):

  • composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt and its iOS / Android actuals demonstrate the expect/actual idiom used in this project.
Task 1: Create GlassBackend enum, LocalGlassBackend CompositionLocal, resolveGlassBackend pure helper, and isDebugBuild expect/actual composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt, composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pattern 3 (lines 362-388) — backend dispatch contract - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Open Questions Q1 (RESOLVED) — debug-build runtime override via multiplatform-settings key "debug.glass_backend", gated by expect val isDebugBuild - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § Glass primitive (lines 352-371) — file layout and backend selection - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-16 + D-17 (lines 46-47) — fallback chain + compile-time-per-target + debug toggle - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt (and iOS/Android actuals if visible) — repo's expect/actual idiom Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt`:
```kotlin
package dev.ulfrx.recipe.ui.components.glass

import androidx.compose.runtime.compositionLocalOf
import com.russhwolf.settings.Settings

/**
 * Three glass-effect backends per CONTEXT D-16. All three consume the same
 * token API (tint Color, cornerRadius Dp, optional BorderStroke) so chrome
 * call sites never branch on the active backend.
 */
enum class GlassBackend { Liquid, Haze, Flat }

/**
 * Set once at composition root (RecipeTheme or AppShell startup) to the
 * resolved backend for the running build. Production binaries pick the
 * compile-time default; debug builds may pick up a runtime override per D-17.
 *
 * Default to [GlassBackend.Flat] in case a consumer reads this outside a
 * provider — fail safe to the simplest visible substrate, never throw.
 */
val LocalGlassBackend = compositionLocalOf { GlassBackend.Flat }

/**
 * The multiplatform-settings key that the debug-only runtime override reads
 * (D-17, RESEARCH § Open Questions Q1 — RESOLVED). Values: "liquid", "haze", "flat".
 * Any other value → [default] is used.
 */
const val DEBUG_GLASS_BACKEND_KEY: String = "debug.glass_backend"

/**
 * Pure resolution function — unit-testable.
 *
 * - When [isDebug] is `false` (production build), returns [default] regardless
 *   of [settings] content. The override path is compiled OUT of production binaries
 *   via [isDebugBuild] so [settings] is never consulted in release.
 * - When [isDebug] is `true` (debug build), reads [DEBUG_GLASS_BACKEND_KEY] from
 *   [settings]:
 *     "liquid" → [GlassBackend.Liquid]
 *     "haze"   → [GlassBackend.Haze]
 *     "flat"   → [GlassBackend.Flat]
 *     anything else / missing → [default]
 */
fun resolveGlassBackend(
    settings: Settings,
    isDebug: Boolean,
    default: GlassBackend,
): GlassBackend {
    if (!isDebug) return default
    val raw = settings.getStringOrNull(DEBUG_GLASS_BACKEND_KEY) ?: return default
    return when (raw.lowercase()) {
        "liquid" -> GlassBackend.Liquid
        "haze" -> GlassBackend.Haze
        "flat" -> GlassBackend.Flat
        else -> default
    }
}
```

Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt`:

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

/**
 * Compile-time gate for the [resolveGlassBackend] runtime-override path
 * (CONTEXT D-17). Production binaries see `false` and the K/N / R8 dead-code
 * elimination removes the settings lookup entirely.
 */
expect val isDebugBuild: Boolean
```

Create `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt`:

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

/**
 * iOS actual: K/N exposes `Platform.isDebugBinary` via `kotlin.native.Platform`.
 * This is set by the Kotlin/Native compiler from the build config (Debug vs Release).
 */
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
actual val isDebugBuild: Boolean = kotlin.native.Platform.isDebugBinary
```

Create `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt`:

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

/**
 * Android actual: read directly from the application's BuildConfig.
 * The recipe.android.application convention plugin already enables BuildConfig
 * generation; the constant is `recipe.composeapp.BuildConfig.DEBUG` (verify the
 * generated package matches the application namespace at build time — if the
 * generated package is different, fix the import here, not the contract).
 */
actual val isDebugBuild: Boolean = recipe.composeapp.BuildConfig.DEBUG
```

Note: if the Android `BuildConfig` package import does not resolve, fall back to a
runtime check using `android.os.Build` / `ApplicationInfo.FLAG_DEBUGGABLE`. The
BuildConfig path is preferred (compile-time constant → R8 prunes the dead branch).
Document the actual chosen approach in the file's KDoc.

Do NOT add any Liquid or Haze imports in `GlassBackend.kt` or `IsDebugBuild.kt` —
those belong only to the per-backend composable files (next task).
./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid -q - `grep -c 'enum class GlassBackend' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt` returns 1 - `grep -c 'val LocalGlassBackend' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt` returns 1 - `grep -c 'fun resolveGlassBackend' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt` returns 1 - `grep -c '"debug.glass_backend"' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt` returns 1 - `grep -c 'expect val isDebugBuild' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt` returns 1 - `grep -c 'actual val isDebugBuild' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt` returns 1 - `grep -c 'actual val isDebugBuild' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt` returns 1 - `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt | wc -l` returns 0 (no library imports leak into the dispatcher / gate files) - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 - `./gradlew :composeApp:compileDebugKotlinAndroid -q` exits 0 GlassBackend enum + LocalGlassBackend + resolveGlassBackend + DEBUG_GLASS_BACKEND_KEY all live in commonMain. The `isDebugBuild` expect declaration has compiling actuals on both iOS and Android. No Liquid/Haze import has leaked into the dispatcher or gate. Task 2: Create GlassBackdrop source + GlassSurface public composable + three backend implementations (Liquid / Haze / Flat) composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pattern 3 (lines 362-388) — public composable signature - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pitfall C (lines 454-458) — Liquid sampleable backdrop contract - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Glass / Liquid contract (lines 230-260) — surface parameters, blur radius, border, shadow - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § Glass primitive (lines 352-371) — backend file layout - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt (post plan 02 — confirms RecipeTheme.colors.surfaceGlass / borderCard exist as Color) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-16 — same token API across all 3 backends Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt`:
```kotlin
package dev.ulfrx.recipe.ui.components.glass

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier

/**
 * Shared source/sampling state for glass chrome.
 *
 * AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends
 * consume [LocalGlassBackdropState] so Liquid/Haze sample the same layer behind
 * the dock/search chrome. Direct Liquid/Haze types stay hidden in this package:
 * this wrapper exposes only Recipe-owned abstractions to the rest of the app.
 */
@Stable
class GlassBackdropState internal constructor()

val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { null }

@Composable
fun rememberGlassBackdropState(): GlassBackdropState = remember { GlassBackdropState() }

@Composable
fun GlassBackdropSource(
    modifier: Modifier = Modifier,
    state: GlassBackdropState = rememberGlassBackdropState(),
    content: @Composable BoxScope.() -> Unit,
) {
    CompositionLocalProvider(LocalGlassBackdropState provides state) {
        Box(modifier = modifier, content = content)
    }
}
```

Liquid/Haze-specific versions of this wrapper may add the actual
`Modifier.liquefiable(...)` / `Modifier.haze(...)` source modifiers internally if
the libraries require concrete state types. The public contract stays the same:
AppShell calls `GlassBackdropSource`, chrome calls `GlassSurface`, and no non-glass
package imports Liquid or Haze.

Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt`:

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

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme

/**
 * Single public entry point for glass-effect chrome (DockBar, FloatingSearchButton,
 * SearchPill in plans 02.1-05 / 02.1-06). Dispatches to one of three backends via
 * [LocalGlassBackend] which is set once at composition root from
 * [resolveGlassBackend].
 * Backends also consume [LocalGlassBackdropState], which is provided by
 * AppShell's [GlassBackdropSource] around the RootNavHost body.
 *
 * Per CONTEXT D-16 all three backends consume the same token API:
 *   - [tint] Color — composited inside the glass effect
 *   - [cornerRadius] Dp — pill / circle radius (28dp dock, 22dp pill / button per UI-SPEC line 253)
 *   - [border] BorderStroke? — outline for edge clarity (UI-SPEC line 254)
 *
 * Per CLAUDE.md non-negotiable #10 + RESEARCH § Anti-Patterns: this primitive is
 * for chrome ONLY. Never wrap scrolling content. Lint discipline: outside
 * `ui/components/glass/`, no source file may import `io.github.fletchmckee.liquid`
 * or `dev.chrisbanes.haze`.
 */
@Composable
fun GlassSurface(
    modifier: Modifier = Modifier,
    tint: Color = RecipeTheme.colors.surfaceGlass,
    cornerRadius: Dp = 28.dp,
    border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
    content: @Composable BoxScope.() -> Unit,
) {
    val backdropState = LocalGlassBackdropState.current
    when (LocalGlassBackend.current) {
        GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
        GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, backdropState, content)
        GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content)
    }
}
```

Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt`:

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

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp

/**
 * Flat translucent fallback (no blur). Per D-16 / D-17 this is the last-resort
 * backend — engaged when neither Liquid nor Haze is available for a target,
 * or when the debug runtime override selects it.
 *
 * The visual is a solid translucent fill in [tint] (which already carries alpha
 * from RecipeColors.surfaceGlass) with the same shape and border as the other
 * backends — geometry is identical so chrome call sites never need to know which
 * backend is active (D-16 contract).
 */
@Composable
internal fun FlatGlassSurface(
    modifier: Modifier,
    tint: Color,
    cornerRadius: Dp,
    border: BorderStroke?,
    content: @Composable BoxScope.() -> Unit,
) {
    val shape = RoundedCornerShape(cornerRadius)
    Box(
        modifier = modifier
            .clip(shape)
            .background(tint, shape)
            .let { if (border != null) it.border(border, shape) else it },
        content = content,
    )
}
```

Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt`.

Reference RESEARCH § Pattern 3 lines 367-388 + Pitfall C lines 454-458 for the contract:
Liquid's pixel-sampling needs a tagged source layer. The screen body backdrop is
tagged with `Modifier.liquefiable(state)` at the AppShell level (plan 02.1-05);
chrome elements consume `Modifier.liquid(state)` from the same `LiquidState`.

For this file, mirror the FlatGlassSurface shape and border treatment, but apply
`Modifier.liquid(state)` (where `state = rememberLiquidState()` if no upstream
state is provided — verify the Liquid 1.1.1 API at implementation time; if Liquid
requires the state to be hoisted, expose it as a CompositionLocal in plan 02.1-05's
AppShell wiring rather than rebuilding here).

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

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import io.github.fletchmckee.liquid.liquid
import io.github.fletchmckee.liquid.rememberLiquidState

/**
 * Liquid backend per CONTEXT D-16 — preferred path for chrome on iOS + Android.
 *
 * Pitfall C (RESEARCH lines 454-458): Liquid's `liquid(state)` modifier needs a
 * peer `liquefiable(state)` source layer in the composition tree to render. The
 * AppShell composable (plan 02.1-05) wraps the screen body in GlassBackdropSource.
 * chrome surfaces consume the same Recipe-owned [GlassBackdropState]. If no
 * upstream state is provided, use a local remembered state as a defensive fallback
 * that degrades to no-op rather than crashing.
 *
 * UI-SPEC § Glass: blur radius 24dp initial; refraction = library default; tune
 * in Phase 10. Border is applied OUTSIDE the liquid effect (above it) so the edge
 * stays crisp regardless of refraction strength.
 */
@Composable
internal fun LiquidGlassSurface(
    modifier: Modifier,
    tint: Color,
    cornerRadius: Dp,
    border: BorderStroke?,
    backdropState: GlassBackdropState?,
    content: @Composable BoxScope.() -> Unit,
) {
    // Implement against the actual Liquid API. The important contract is that
    // Liquid uses backdropState when it is non-null, so AppShell's body and chrome
    // share one source/sampling layer.
    val state = rememberLiquidState()
    val shape = RoundedCornerShape(cornerRadius)
    Box(
        modifier = modifier
            .clip(shape)
            .liquid(state)
            .background(tint, shape)
            .let { if (border != null) it.border(border, shape) else it },
        content = content,
    )
}
```

Implementation note: if the Liquid 1.1.1 public API differs from the names above
(`liquid` / `rememberLiquidState`), conform to the actual API surface — the
reference is the project's `gradle/libs.versions.toml` resolved version and the
Liquid README. Do NOT downgrade behavior to flat — fix the import. RESEARCH §
Sources points at github.com/FletchMcKee/liquid for the API.

Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt`:

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

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild

/**
 * Haze 1.x backend per CONTEXT D-16 — secondary blur path. Engaged when Liquid is
 * unavailable for a target, or when the debug runtime override selects "haze".
 *
 * Symmetric to LiquidGlassSurface's contract: AppShell provides GlassBackdropSource
 * around the body (plan 02.1-05). When no upstream state is provided, the Haze child
 * no-ops gracefully.
 *
 * Geometry (shape, border, tint) is identical to Flat / Liquid — chrome call
 * sites never need to branch on backend (D-16).
 */
@Composable
internal fun HazeGlassSurface(
    modifier: Modifier,
    tint: Color,
    cornerRadius: Dp,
    border: BorderStroke?,
    backdropState: GlassBackdropState?,
    content: @Composable BoxScope.() -> Unit,
) {
    // Implement against the actual Haze API. The important contract is that Haze
    // uses backdropState when it is non-null, so AppShell's body and chrome share
    // one source/sampling layer.
    val state = remember { HazeState() }
    val shape = RoundedCornerShape(cornerRadius)
    Box(
        modifier = modifier
            .clip(shape)
            .hazeChild(state, shape)
            .background(tint, shape)
            .let { if (border != null) it.border(border, shape) else it },
        content = content,
    )
}
```

Implementation note: if Haze 1.6.10 requires a different child API (e.g.
`Modifier.hazeChild(state, shape = shape, style = ...)` or a separate `HazeStyle`
parameter), conform to the actual API. The signature to the parent
`GlassSurface` does NOT change.

Per CONTEXT D-17 + UI-SPEC § Glass: blur radius initial 24dp, library default
elsewhere — tune Phase 10.

Material 3 boundary check: NONE of these four files imports `androidx.compose.material3.*`.
The `Box` / `background` / `border` modifiers are from `androidx.compose.foundation.*`.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid -q - `grep -c 'fun GlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt` returns 1 - `grep -c 'fun GlassBackdropSource' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt` returns 1 - `grep -c 'LocalGlassBackdropState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt` returns at least 1 - `grep -c 'LocalGlassBackend.current' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt` returns 1 - `grep -c 'GlassBackend.Liquid' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt` returns 1 - `grep -c 'GlassBackend.Haze' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt` returns 1 - `grep -c 'GlassBackend.Flat' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt` returns 1 - `grep -c 'internal fun LiquidGlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt` returns 1 - `grep -c 'internal fun HazeGlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt` returns 1 - `grep -c 'internal fun FlatGlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt` returns 1 - `grep -c 'io.github.fletchmckee.liquid' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt` returns at least 1 - `grep -c 'dev.chrisbanes.haze' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt` returns at least 1 - Material 3 boundary: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/` returns 0 (no Material 3 imports anywhere in the glass package) - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 - `./gradlew :composeApp:compileDebugKotlinAndroid -q` exits 0 Single public composable `GlassSurface(...)` dispatches to three backend composables. AppShell can provide the shared source layer via GlassBackdropSource and Liquid/Haze backends consume LocalGlassBackdropState. All three backends have identical public (tint, cornerRadius, border) call-site signatures. Liquid + Haze imports are confined to the glass package only. Build is green on both targets. Task 3: Replace @Ignore stubs in GlassBackendTest + GlassBackendOverrideTest with real assertions hitting resolveGlassBackend composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt (current Wave-0 stub — un-Ignore + add real body) - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt (current Wave-0 stub — un-Ignore + add real body) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt (just-created — `resolveGlassBackend(settings, isDebug, default)`) - 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-02 / V-03 (lines 47-48) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Validation Architecture line 731 — MapSettings reference for test impl Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` with:
```kotlin
package dev.ulfrx.recipe.ui.components.glass

import com.russhwolf.settings.MapSettings
import kotlin.test.Test
import kotlin.test.assertEquals

/**
 * V-02 — UI-04 — `resolveGlassBackend(...)` returns the compile-time default
 * (Liquid for iOS source-set defaults) when no debug override is present.
 *
 * Implemented by plan 02.1-03; production-build short-circuit gated by
 * [isDebugBuild]. This unit test exercises the pure helper directly, so it
 * runs identically on every target.
 */
class GlassBackendTest {
    @Test
    fun resolveGlassBackend_iosDefault_returnsLiquid() {
        val result = resolveGlassBackend(
            settings = MapSettings(),
            isDebug = false,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Liquid, result)
    }

    @Test
    fun resolveGlassBackend_emptySettings_returnsDefault() {
        // Even in a debug build, an empty settings store falls through to default.
        val result = resolveGlassBackend(
            settings = MapSettings(),
            isDebug = true,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Liquid, result)
    }

    @Test
    fun resolveGlassBackend_unknownOverride_returnsDefault() {
        val settings = MapSettings()
        settings.putString(DEBUG_GLASS_BACKEND_KEY, "neon-wave")
        val result = resolveGlassBackend(
            settings = settings,
            isDebug = true,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Liquid, result)
    }
}
```

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

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

import com.russhwolf.settings.MapSettings
import kotlin.test.Test
import kotlin.test.assertEquals

/**
 * V-03 — UI-04 — debug-build runtime override via multiplatform-settings honors
 * `"debug.glass_backend"` key with values "liquid" / "haze" / "flat".
 * Production builds (isDebug=false) ignore the override entirely (D-17).
 */
class GlassBackendOverrideTest {
    @Test
    fun resolveGlassBackend_debugBuildHonorsHazeOverride() {
        val settings = MapSettings()
        settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze")
        val result = resolveGlassBackend(
            settings = settings,
            isDebug = true,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Haze, result)
    }

    @Test
    fun resolveGlassBackend_debugBuildHonorsFlatOverride() {
        val settings = MapSettings()
        settings.putString(DEBUG_GLASS_BACKEND_KEY, "flat")
        val result = resolveGlassBackend(
            settings = settings,
            isDebug = true,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Flat, result)
    }

    @Test
    fun resolveGlassBackend_debugBuildHonorsLiquidOverride() {
        val settings = MapSettings()
        settings.putString(DEBUG_GLASS_BACKEND_KEY, "liquid")
        val result = resolveGlassBackend(
            settings = settings,
            isDebug = true,
            default = GlassBackend.Haze,
        )
        assertEquals(GlassBackend.Liquid, result)
    }

    @Test
    fun resolveGlassBackend_caseInsensitive() {
        val settings = MapSettings()
        settings.putString(DEBUG_GLASS_BACKEND_KEY, "HAZE")
        val result = resolveGlassBackend(
            settings = settings,
            isDebug = true,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Haze, result)
    }

    @Test
    fun resolveGlassBackend_productionBuildIgnoresOverride() {
        val settings = MapSettings()
        settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze")
        val result = resolveGlassBackend(
            settings = settings,
            isDebug = false,
            default = GlassBackend.Liquid,
        )
        assertEquals(GlassBackend.Liquid, result)
    }
}
```

Both test files MUST drop the `@Ignore` import and the `@Ignore` annotation.

If `MapSettings` is not on the commonTest classpath after Phase 2's wiring, add the
multiplatform-settings test artifact (`com.russhwolf:multiplatform-settings-test`)
as a `commonTest.dependencies` entry in `composeApp/build.gradle.kts`. This is a
minor fix; the catalog already pins the version. Verify by `./gradlew :composeApp:compileTestKotlinIosSimulatorArm64`.
./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.components.glass.*" -q - `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` returns 0 - `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` returns 0 - `grep -c 'resolveGlassBackend' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` returns at least 3 - `grep -c 'resolveGlassBackend' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` returns at least 5 - `grep -c 'MapSettings' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` returns at least 1 - `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.components.glass.*" -q` exits 0 (all assertions pass) - VALIDATION.md anchors V-02 and V-03 are now backed by passing tests, not stubs (manual verification: read VALIDATION.md and confirm test paths align) GlassBackendTest contains 3 passing assertions; GlassBackendOverrideTest contains 5 passing assertions covering all three backend keys, case-insensitivity, and production-build short-circuit. V-02 + V-03 anchors fully covered. - Build green on both compile targets: - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 - `./gradlew :composeApp:compileDebugKotlinAndroid -q` exits 0 - Glass package tests green: `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.components.glass.*" -q` exits 0 - Material 3 boundary preserved: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/` returns 0 - Liquid / Haze imports confined to backend files only: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt | wc -l` returns 0

<success_criteria>

  1. Six new commonMain files in ui/components/glass/: GlassBackend.kt (enum + LocalGlassBackend + DEBUG_GLASS_BACKEND_KEY + resolveGlassBackend), GlassBackdrop.kt (shared source/provider wrapper), GlassSurface.kt (public dispatcher), LiquidGlassSurface.kt, HazeGlassSurface.kt, FlatGlassSurface.kt.
  2. expect val isDebugBuild declared in commonMain with two compiling actuals (iOS and Android) — production binaries pick up false so the override path is dead-code-eliminated.
  3. All three backends consume the same (tint, cornerRadius, border) token API per D-16 — chrome call sites never branch on backend.
  4. V-02 anchor: GlassBackendTest passes 3 assertions covering compile-time default + empty settings + unknown override.
  5. V-03 anchor: GlassBackendOverrideTest passes 5 assertions covering haze / flat / liquid override values, case-insensitive parsing, and production-build short-circuit (D-17).
  6. Material 3 boundary preserved: zero androidx.compose.material3 imports in any of the glass package files.
  7. Liquid / Haze imports confined to LiquidGlassSurface.kt and HazeGlassSurface.kt only. </success_criteria>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-03-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record: - Final Liquid 1.1.1 modifier API used (`Modifier.liquid(state)` confirmed) — note any divergence from RESEARCH.md if the actual API differs. - Final Haze 1.6.10 modifier API used (`Modifier.hazeChild(state, shape)` confirmed) — note any divergence. - Whether `multiplatform-settings-test` was added to commonTest dependencies. - Whether the Android `BuildConfig.DEBUG` import resolved cleanly or required the runtime fallback.