--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md @.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.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. 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 { 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 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. 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.