Implement main app navigation

This commit is contained in:
2026-05-08 14:03:26 +02:00
parent f7e866a08d
commit 794e27c554
90 changed files with 11725 additions and 187 deletions

View File

@@ -0,0 +1,788 @@
---
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>