---
phase: 02.1
plan: 03
type: execute
wave: 2
depends_on: ["02.1-01", "02.1-02"]
files_modified:
  - 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
autonomous: true
requirements: [UI-04]
tags: [kotlin, compose-multiplatform, glass, liquid, haze, composition-local, expect-actual, multiplatform-settings]

must_haves:
  truths:
    - "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"
  artifacts:
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt"
      provides: "enum GlassBackend, val LocalGlassBackend, fun resolveGlassBackend(...)"
      contains: "enum class GlassBackend"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt"
      provides: "Public GlassSurface composable that dispatches by LocalGlassBackend.current"
      contains: "fun GlassSurface"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt"
      provides: "Shared backdrop/source wrapper consumed by AppShell and glass backends"
      contains: "fun GlassBackdropSource"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/LiquidGlassSurface.kt"
      provides: "Liquid backend implementation using io.github.fletchmckee.liquid"
      contains: "internal fun LiquidGlassSurface"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt"
      provides: "Haze backend implementation using dev.chrisbanes.haze"
      contains: "internal fun HazeGlassSurface"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt"
      provides: "Flat translucent fallback (no blur) using surfaceGlass token"
      contains: "internal fun FlatGlassSurface"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt"
      provides: "expect val isDebugBuild: Boolean — gates the multiplatform-settings override"
      contains: "expect val isDebugBuild"
  key_links:
    - from: "ui/components/glass/GlassSurface.kt"
      to: "ui/components/glass/GlassBackend.kt"
      via: "LocalGlassBackend.current dispatch + when(backend)"
      pattern: "LocalGlassBackend\\.current"
    - from: "ui/components/glass/GlassSurface.kt"
      to: "ui/components/glass/GlassBackdrop.kt"
      via: "GlassSurface consumes LocalGlassBackdropState; AppShell applies GlassBackdropSource to the body"
      pattern: "LocalGlassBackdropState"
    - from: "ui/components/glass/GlassBackend.kt"
      to: "com.russhwolf.settings.Settings"
      via: "resolveGlassBackend reads 'debug.glass_backend' key when isDebugBuild"
      pattern: "debug\\.glass_backend"
    - from: "commonTest/.../GlassBackendTest.kt + GlassBackendOverrideTest.kt"
      to: "ui/components/glass/GlassBackend.kt"
      via: "calls resolveGlassBackend(MapSettings(), isDebug, default) and asserts result"
      pattern: "resolveGlassBackend"
---

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

<interfaces>
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.10` — `HazeState`, `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.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create GlassBackend enum, LocalGlassBackend CompositionLocal, resolveGlassBackend pure helper, and isDebugBuild expect/actual</name>
  <files>
    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
  </files>
  <read_first>
    - .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
  </read_first>
  <action>
    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).
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid -q</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>
    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.
  </done>
</task>

<task type="auto">
  <name>Task 2: Create GlassBackdrop source + GlassSurface public composable + three backend implementations (Liquid / Haze / Flat)</name>
  <files>
    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
  </files>
  <read_first>
    - .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
  </read_first>
  <action>
    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.*`.
  </action>
  <verify>
    <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid -q</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>
    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.
  </done>
</task>

<task type="auto">
  <name>Task 3: Replace @Ignore stubs in GlassBackendTest + GlassBackendOverrideTest with real assertions hitting resolveGlassBackend</name>
  <files>
    composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt,
    composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt
  </files>
  <read_first>
    - 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
  </read_first>
  <action>
    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`.
  </action>
  <verify>
    <automated>./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.components.glass.*" -q</automated>
  </verify>
  <acceptance_criteria>
    - `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)
  </acceptance_criteria>
  <done>
    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.
  </done>
</task>

</tasks>

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

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

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