diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/RoutineCardElevationContractTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/RoutineCardElevationContractTest.kt new file mode 100644 index 000000000..c4cc6e9ff --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/RoutineCardElevationContractTest.kt @@ -0,0 +1,33 @@ +package com.devil.phoenixproject.presentation.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import androidx.compose.ui.unit.dp + +/** + * Behavior contract for the `RoutineCard` 2.dp / 8.dp elevation flicker under + * Dark + Material You (issue #640). + * + * The RCA traces the per-card "flicker" the reporter sees on expand/collapse to + * RoutineCard's collapsed/expanded elevation delta. The production call site now + * reads this value through [routineCardDefaultElevation], which gives this test a + * stable semantic contract instead of parsing formatting-sensitive Kotlin source. + */ +class RoutineCardElevationContractTest { + + @Test + fun routineCard_elevationIsTwoOrEightDp() { + assertEquals( + 2.dp, + routineCardDefaultElevation(expanded = false), + "Collapsed RoutineCard elevation must stay 2.dp so the elevation-tint " + + "delta remains the RCA/acceptance-tested 6-dp step.", + ) + assertEquals( + 8.dp, + routineCardDefaultElevation(expanded = true), + "Expanded RoutineCard elevation must stay 8.dp so the elevation-tint " + + "delta remains the RCA/acceptance-tested 6-dp step.", + ) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/RoutinesChromeLuminanceContractTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/RoutinesChromeLuminanceContractTest.kt new file mode 100644 index 000000000..1786f8cb8 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/RoutinesChromeLuminanceContractTest.kt @@ -0,0 +1,97 @@ +package com.devil.phoenixproject.presentation.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * Behavior contract guarding the rendered chrome luminance invariant under + * Dark + Material You (issue #640). + * + * Production chrome call sites read their high-visibility container colors through + * the helpers in `PhoenixChromeColorContracts.kt`. These tests assert those helpers + * forward the runtime [ColorScheme] roles that `clampDynamicDarkScheme` rewrites, + * without reading physical source files or matching fragile formatting-sensitive + * strings in `.kt` files. + */ +class RoutinesChromeLuminanceContractTest { + + private fun chromeScheme(): ColorScheme = darkColorScheme( + surface = Color(0xFF101820), + surfaceContainerHigh = Color(0xFF1E293B), + surfaceContainerHighest = Color(0xFF334155), + primaryContainer = Color(0xFF7C2D12), + ) + + @Test + fun topAppBar_containerColor_readsColorSchemeSurface() { + val scheme = chromeScheme().copy(surface = Color(0xFF123456)) + + assertEquals( + scheme.surface, + phoenixTopAppBarContainerColor(scheme), + "Top app bar container color must read ColorScheme.surface so the " + + "dark+Material You clamp propagates through.", + ) + } + + @Test + fun bottomNavigation_containerColor_readsColorSchemeSurfaceContainerHigh() { + val scheme = chromeScheme().copy(surfaceContainerHigh = Color(0xFF234567)) + + assertEquals( + scheme.surfaceContainerHigh, + phoenixBottomNavigationContainerColor(scheme), + "Bottom navigation container color must read ColorScheme.surfaceContainerHigh " + + "so the dark+Material You clamp propagates through.", + ) + } + + @Test + fun routineCard_unselectedContainerColor_readsColorSchemeSurfaceContainerHighest() { + val scheme = chromeScheme().copy(surfaceContainerHighest = Color(0xFF345678)) + + assertEquals( + scheme.surfaceContainerHighest, + routineCardContainerColor(scheme, isSelected = false), + "Unselected RoutineCard container color must read " + + "ColorScheme.surfaceContainerHighest so the dark+Material You clamp propagates through.", + ) + } + + @Test + fun routineCard_selectedContainerColor_preservesPrimaryContainerSelectionTint() { + val scheme = chromeScheme().copy(primaryContainer = Color(0xFF456789)) + + assertEquals( + scheme.primaryContainer.copy(alpha = 0.4f), + routineCardContainerColor(scheme, isSelected = true), + "Selected RoutineCard container color must remain the primaryContainer " + + "selection tint, not a hardcoded surface color.", + ) + } + + @Test + fun chromeHelpers_areDrivenByInputSchemeNotHardcodedLightColors() { + val darkScheme = chromeScheme() + val lightSentinel = Color.White + + val returnedColors = listOf( + phoenixTopAppBarContainerColor(darkScheme), + phoenixBottomNavigationContainerColor(darkScheme), + routineCardContainerColor(darkScheme, isSelected = false), + ) + + returnedColors.forEach { color -> + assertNotEquals( + lightSentinel, + color, + "Chrome helper returned Color.White for a dark scheme; this would " + + "bypass the issue #640 dark-surface clamp.", + ) + } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/ui/theme/MaterialYouDarkClampContractTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/ui/theme/MaterialYouDarkClampContractTest.kt new file mode 100644 index 000000000..cc871b0e9 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/ui/theme/MaterialYouDarkClampContractTest.kt @@ -0,0 +1,119 @@ +package com.devil.phoenixproject.ui.theme + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Source-level contract test for the dark + Material You clamp wired into [Theme.kt]. + * + * The fix for issue #640 only takes effect if `VitruvianTheme` calls + * `clampDynamicDarkScheme(dynamic, DarkColorScheme)` on the dark branch and *only* + * the dark branch. A contract test is the right tool here: a Robolectric / Compose + * runtime round-trip would not exercise any additional code paths, and the + * `clampDynamicDarkScheme` helper itself is covered by [ClampedDynamicDarkSchemeTest] + * in `commonTest` (the JVM-only test suite). + * + * If a future refactor removes the clamp call or moves it outside the dark branch, + * this test fails loudly. The contract is intentionally narrow: just one assertion + * per behavior, all anchored on the exact phrasing we expect. + */ +class MaterialYouDarkClampContractTest { + + private val projectRoot: File by lazy { + var dir = File(System.getProperty("user.dir") ?: ".") + while (!File(dir, "shared/src/commonMain").exists()) { + dir = dir.parentFile ?: break + } + dir + } + + private fun read(relativePath: String): String = + File(projectRoot, relativePath).readText() + + private val themeKt: String by lazy { + read("shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt") + } + + @Test + fun darkBranch_invokesClampDynamicDarkScheme() { + // The dark branch of `VitruvianTheme` must call `clampDynamicDarkScheme` with + // the dynamic dark scheme and the static `DarkColorScheme` as the fallback. + // This is the single line of glue that makes the fix for issue #640 take + // effect at the theme boundary. + assertTrue( + themeKt.contains("clampDynamicDarkScheme(dynamic, DarkColorScheme)"), + "Theme.kt must call clampDynamicDarkScheme(dynamic, DarkColorScheme) " + + "on the dark + Material You branch. The dynamic surface family " + + "otherwise leaks high-luminance wallpaper values into the chrome.", + ) + } + + @Test + fun lightBranch_doesNotInvokeClamp() { + // The clamp is dark-mode only. Light + Material You must continue to use the + // wallpaper-derived palette as today. + // The literal substring `clampDynamicDarkScheme` must appear, and it must be + // gated by `useDarkColors` (we asserted the call shape in darkBranch_invokes... + // above). This test asserts the structural shape of the call site. + assertTrue( + themeKt.contains("dynamic != null && useDarkColors -> clampDynamicDarkScheme"), + "Theme.kt must gate clampDynamicDarkScheme on useDarkColors=true so light " + + "+ Material You keeps wallpaper hues across the entire scheme.", + ) + } + + @Test + fun clampHelperExists_alongsideTheme() { + // The helper must live next to Theme.kt so the surface family token list is + // discoverable from the theme module. If a future refactor moves the helper + // into a non-theme package, surface the failure here and re-document. + val helper = File( + projectRoot, + "shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt", + ) + assertTrue( + helper.exists(), + "ClampedDynamicDarkScheme.kt must live in " + + "shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ " + + "alongside Theme.kt so the surface family token list is discoverable " + + "from the theme module. Found missing at ${helper.absolutePath}.", + ) + } + + @Test + fun staticDarkColorScheme_unmodifiedByFix() { + // Non-goal: do not change the static DarkColorScheme. The clamp must compose + // against the existing static palette; if the static palette itself is + // modified, the iOS / non-dynamic Android brand contract breaks. + assertTrue( + themeKt.contains("surfaceContainerHighest = SurfaceContainerHighestDark"), + "DarkColorScheme must still anchor surfaceContainerHighest to " + + "SurfaceContainerHighestDark (Slate700). The clamp composes against " + + "the existing static palette; it does not redefine it.", + ) + } + + @Test + fun clampHelper_clampsInverseSurfaceAndSurfaceTintFromFallback() { + // Material 3 components such as Snackbar read `inverseSurface` for their + // container color and `inverseOnSurface` for content. If the clamp leaves + // these from `dynamic`, a wallpaper that produces a light surface produces + // a dark `inverseSurface`, and the Snackbar renders dark-on-dark under the + // very wallpaper this fix is meant to tame. Material 3 also applies + // `surfaceTint` to elevated surface containers, so pin that to fallback too + // and prevent bright/saturated wallpaper tints from reintroducing chrome drift. + val helper = read( + "shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt", + ) + assertTrue( + helper.contains("inverseSurface = fallback.inverseSurface") && + helper.contains("inverseOnSurface = fallback.inverseOnSurface") && + helper.contains("surfaceTint = fallback.surfaceTint"), + "ClampedDynamicDarkScheme must clamp inverseSurface, inverseOnSurface, " + + "and surfaceTint from fallback so Material 3 Snackbar / Tooltip / " + + "similar components and elevated surfaces stay on the brand-controlled " + + "dark ramp described in the automated reviews on PR #642.", + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt index e20af3734..5e54ddf5f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt @@ -82,6 +82,8 @@ import com.devil.phoenixproject.presentation.components.HapticFeedbackEffect import com.devil.phoenixproject.presentation.components.ProfileSidePanel import com.devil.phoenixproject.presentation.navigation.NavGraph import com.devil.phoenixproject.presentation.navigation.NavigationRoutes +import com.devil.phoenixproject.presentation.theme.phoenixBottomNavigationContainerColor +import com.devil.phoenixproject.presentation.theme.phoenixTopAppBarContainerColor import com.devil.phoenixproject.presentation.util.LocalPlatformAccessibilitySettings import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass import com.devil.phoenixproject.presentation.util.WindowHeightSizeClass @@ -332,7 +334,7 @@ fun EnhancedMainScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = phoenixTopAppBarContainerColor(MaterialTheme.colorScheme), titleContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurface, ), @@ -497,7 +499,7 @@ private fun PhoenixBottomNavigationBar( onInsightsClick: () -> Unit, onSettingsClick: () -> Unit, ) { - val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + val containerColor = phoenixBottomNavigationContainerColor(MaterialTheme.colorScheme) val barHeight = remember(isCompactHeight) { if (isCompactHeight) 56.dp else 60.dp } Box( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutinesTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutinesTab.kt index 470f1e1ad..fffb907ed 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutinesTab.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutinesTab.kt @@ -71,7 +71,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -92,6 +91,8 @@ import com.devil.phoenixproject.presentation.components.DestructiveConfirmDialog import com.devil.phoenixproject.presentation.components.EmptyState import com.devil.phoenixproject.presentation.components.ProfileColors import com.devil.phoenixproject.presentation.components.RoutineModifierDialog +import com.devil.phoenixproject.presentation.theme.routineCardContainerColor +import com.devil.phoenixproject.presentation.theme.routineCardDefaultElevation import com.devil.phoenixproject.presentation.util.isCompactAccessibilityLayout import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.ThemeMode @@ -1077,14 +1078,13 @@ fun RoutineCard( ), shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - } else { - MaterialTheme.colorScheme.surfaceContainerHighest - }, + containerColor = routineCardContainerColor( + colorScheme = MaterialTheme.colorScheme, + isSelected = isSelected, + ), ), elevation = CardDefaults.cardElevation( - defaultElevation = if (expanded) 8.dp else 2.dp, + defaultElevation = routineCardDefaultElevation(expanded), ), border = BorderStroke( 2.dp, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/theme/PhoenixChromeColorContracts.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/theme/PhoenixChromeColorContracts.kt new file mode 100644 index 000000000..395495e7f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/theme/PhoenixChromeColorContracts.kt @@ -0,0 +1,34 @@ +package com.devil.phoenixproject.presentation.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Semantic color/elevation accessors for Phoenix's high-visibility chrome surfaces. + * + * Issue #640 was caused by Android dynamic dark color schemes returning light + * wallpaper-derived values for surface roles that the Routines screen uses as + * structural chrome: the top app bar, bottom navigation bar, and routine cards. + * Keeping these reads behind tiny helpers gives tests a stable behavior contract + * without parsing Kotlin source files or matching formatting-sensitive call-site + * strings. + */ +internal fun phoenixTopAppBarContainerColor(colorScheme: ColorScheme): Color = + colorScheme.surface + +internal fun phoenixBottomNavigationContainerColor(colorScheme: ColorScheme): Color = + colorScheme.surfaceContainerHigh + +internal fun routineCardContainerColor( + colorScheme: ColorScheme, + isSelected: Boolean, +): Color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.4f) +} else { + colorScheme.surfaceContainerHighest +} + +internal fun routineCardDefaultElevation(expanded: Boolean): Dp = + if (expanded) 8.dp else 2.dp diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt new file mode 100644 index 000000000..357ea2521 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt @@ -0,0 +1,85 @@ +package com.devil.phoenixproject.ui.theme + +import androidx.compose.material3.ColorScheme + +/** + * Clamp a wallpaper-derived dynamic dark [ColorScheme] so the chrome / card surface + * family can never resolve to high-luminance values while keeping the wallpaper hue + * alive on accents, toggles, and selection chips. + * + * Background — see RCA for issue #640: + * On Android 12+, `dynamicDarkColorScheme(context)` returns a tonal palette derived from + * the user's wallpaper. For some wallpapers the `surface` / `surfaceContainerHigh` / + * `surfaceContainerHighest` roles resolve to high-luminance values while + * `surfaceContainerLowest` stays dark. The Routines screen reads these directly: + * - top app bar = `MaterialTheme.colorScheme.surface` + * - bottom nav = `MaterialTheme.colorScheme.surfaceContainerHigh` + * - expanded RoutineCard = `MaterialTheme.colorScheme.surfaceContainerHighest` + * at elevation 8.dp (and the same color at elevation 2.dp when collapsed) + * which produces a half-light / half-dark screen under Dark + Material You. + * + * This helper is the minimal, **conservative** product fix. It clamps **the entire + * surface family** (incl. `surfaceVariant` / `onSurfaceVariant` / `surfaceDim` / + * `surfaceBright` / `surfaceTint` / `background` / `onBackground` / + * `inverseSurface` / `inverseOnSurface`) to the brand-controlled static + * `DarkColorScheme`, because those + * tokens are co-sampled by the same chrome surfaces that the reporter saw flip light. + * `inverseSurface` / `inverseOnSurface` in particular are used by Material 3 + * components like `Snackbar` for their container / content colors; if we clamp + * `surface` to dark but leave the dynamic `inverseSurface` in place, a Snackbar on a + * wallpaper with a light `surface` would render dark-on-dark (Snackbar container + * resolves to dynamic `inverseSurface` which is dark when the dynamic `surface` is + * light, while the underlying screen is dark from the clamp → no contrast). + * `surfaceTint` is also clamped because Material 3 applies it to elevated surface + * containers; a light or saturated wallpaper tint can otherwise reintroduce the + * same color drift on elevated bars/cards after the surface roles are clamped. + * Only the wallpaper-derived accents — primary, secondary, tertiary, error, + * outline, outlineVariant — are preserved, so the wallpaper hue still flows + * through toggles, sliders, badges, and error states. + * + * Why not pass any of those tokens through from `dynamic`? They are all part of the + * same wallpaper-miscalibrated surface family. The conservative clamp keeps the + * chrome visually consistent across wallpapers; the wallpaper hue stays alive on + * accent colors that are explicitly user-interactive (toggles, selection chips, + * error states) instead of on ambient background tints that should stay + * brand-controlled. + * + * Only the dark branch of `VitruvianTheme` invokes this helper; light + Material You + * is unchanged. iOS never invokes it because `platformDynamicColorScheme` returns + * `null` on iOS and the `DarkColorScheme` fallback is used directly. + * + * @param dynamic the `dynamicDarkColorScheme(context)` result; expected non-null + * @param fallback the static `DarkColorScheme` to clamp surfaces against + * @return a fresh `ColorScheme` with the surface family from `fallback` and accents + * from `dynamic`. Returns a fresh instance via `ColorScheme.copy(...)` so the + * caller's `dynamic` is never mutated. + */ +fun clampDynamicDarkScheme( + dynamic: ColorScheme, + fallback: ColorScheme, +): ColorScheme = dynamic.copy( + // Surface family — brand-controlled dark Slate ramp from the static DarkColorScheme. + surface = fallback.surface, + onSurface = fallback.onSurface, + surfaceVariant = fallback.surfaceVariant, + onSurfaceVariant = fallback.onSurfaceVariant, + surfaceDim = fallback.surfaceDim, + surfaceBright = fallback.surfaceBright, + surfaceContainerLowest = fallback.surfaceContainerLowest, + surfaceContainerLow = fallback.surfaceContainerLow, + surfaceContainer = fallback.surfaceContainer, + surfaceContainerHigh = fallback.surfaceContainerHigh, + surfaceContainerHighest = fallback.surfaceContainerHighest, + background = fallback.background, + onBackground = fallback.onBackground, + // surfaceTint: Material 3 applies this tint to elevated surface containers. + // Keep it on the static dark ramp so elevated bars/cards cannot drift away + // from the clamped surface family under bright/saturated wallpaper palettes. + surfaceTint = fallback.surfaceTint, + // inverseSurface / inverseOnSurface: Material 3 components such as Snackbar use + // these for their container / content colors. Clamping them keeps Snackbar (and + // any other inverse-surface component) readable under the wallpaper bug instead + // of falling into the dark-on-dark trap. See Gemini Code Review on PR #642. + inverseSurface = fallback.inverseSurface, + inverseOnSurface = fallback.inverseOnSurface, +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt index 6fe5e6090..b83e2dbc6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt @@ -112,7 +112,14 @@ fun VitruvianTheme( ThemeMode.DARK -> true } val colorScheme = if (dynamicColorEnabled) { - platformDynamicColorScheme(useDarkColors) + // Material You path. In dark mode, clamp the dynamic dark scheme so the + // surface family never resolves to high-luminance values; see + // `ClampedDynamicDarkScheme.kt` and the RCA for issue #640 for the rationale. + val dynamic = platformDynamicColorScheme(useDarkColors) + when { + dynamic != null && useDarkColors -> clampDynamicDarkScheme(dynamic, DarkColorScheme) + else -> dynamic + } } else { null } ?: if (useDarkColors) { diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkSchemeTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkSchemeTest.kt new file mode 100644 index 000000000..eb00ed3d4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkSchemeTest.kt @@ -0,0 +1,331 @@ +package com.devil.phoenixproject.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Unit tests for [clampDynamicDarkScheme] (fix for issue #640). + * + * The helper merges a wallpaper-derived dynamic dark `ColorScheme` with the + * brand-controlled static `DarkColorScheme`: the surface family — surface / + * surfaceVariant / onSurfaceVariant / surfaceDim / surfaceBright / surfaceTint / + * surfaceContainer* / background / onBackground / inverseSurface / inverseOnSurface — must come from + * the static palette (so chrome / card surfaces and Material 3 components that + * read from inverseSurface such as Snackbar never resolve to high-luminance values + * or fall into the dark-on-dark trap), while primary / secondary / tertiary / error + * / outline / outlineVariant come from the dynamic palette (so the wallpaper hue + * still flows through toggles, sliders, badges, error states, etc.). + * + * These tests are JVM-only — they construct fake `ColorScheme` instances via the + * `darkColorScheme(...)` builder + `.copy(...)` and assert on the resulting merged + * `ColorScheme`. No Compose runtime, no Robolectric, no UI thread. + */ +class ClampedDynamicDarkSchemeTest { + + /** + * Build the brand-controlled static dark scheme to use as the fallback. + * Mirrors `DarkColorScheme` in `Theme.kt` so the test does not depend on + * the private field directly. + */ + private val fallback: ColorScheme = darkColorScheme( + primary = Primary80, + onPrimary = Primary20, + primaryContainer = PrimaryContainerDark, + onPrimaryContainer = OnPrimaryContainerDark, + secondary = Secondary80, + onSecondary = Secondary20, + secondaryContainer = SecondaryContainerDark, + onSecondaryContainer = OnSecondaryContainerDark, + tertiary = Tertiary80, + onTertiary = Tertiary20, + tertiaryContainer = AshBlueLight, + onTertiaryContainer = Color.White, + background = SurfaceContainerDark, + onBackground = OnSurfaceDark, + surface = SurfaceContainerDark, + onSurface = OnSurfaceDark, + surfaceVariant = SurfaceContainerHighDark, + onSurfaceVariant = OnSurfaceVariantDark, + surfaceDim = SurfaceDimDark, + surfaceBright = SurfaceContainerHighestDark, + surfaceContainerLowest = Slate950, + surfaceContainerLow = Slate900, + surfaceContainer = SurfaceContainerDark, + surfaceContainerHigh = SurfaceContainerHighDark, + surfaceContainerHighest = SurfaceContainerHighestDark, + error = SignalError, + onError = Color.White, + outline = Slate400, + outlineVariant = Slate700, + ) + + /** + * Build a faked high-luminance dynamic dark scheme that simulates the wallpaper + * bug: light surface roles, dark gradient base, wallpaper-derived primary/secondary. + */ + private fun highLuminanceDynamicDark(): ColorScheme { + val light = Color(0xFFE0E0E0) // ~0.78 luminance — representative of the bug + val darker = Color(0xFFC0C0C0) // ~0.60 luminance — also "high" + return darkColorScheme( + primary = Color(0xFFFF6B6B), // wallpaper red-ish + onPrimary = Color.Black, + primaryContainer = Color(0xFF7A1F1F), + onPrimaryContainer = Color(0xFFFFDAD4), + secondary = Color(0xFF4DD0E1), // wallpaper teal + onSecondary = Color.Black, + secondaryContainer = Color(0xFF003F4A), + onSecondaryContainer = Color(0xFFB2EBF2), + tertiary = Color(0xFFFFD54F), // wallpaper amber + onTertiary = Color.Black, + tertiaryContainer = Color(0xFF5C4400), + onTertiaryContainer = Color(0xFFFFE082), + background = Slate950, // stays dark in the bug + onBackground = Color(0xFFE0E0E0), + surface = light, // BUG: light + onSurface = Color(0xFF101010), + surfaceVariant = darker, + onSurfaceVariant = Color(0xFF202020), + surfaceDim = light, + surfaceBright = Color.White, // BUG: white + surfaceContainerLowest = Slate950, + surfaceContainerLow = Color(0xFF888888), + surfaceContainer = Color(0xFFA0A0A0), + surfaceContainerHigh = Color(0xFFB0B0B0), // BUG: light + surfaceContainerHighest = Color(0xFFD0D0D0), // BUG: very light + surfaceTint = Color(0xFFFFF59D), // BUG: bright elevated-surface tint + error = Color(0xFFFF1744), + onError = Color.White, + outline = Color(0xFF707070), + outlineVariant = Color(0xFF505050), + // Simulate a wallpaper whose inverseSurface would resolve dark when the + // surface is light — the exact dark-on-dark Snackbar trap. Material 3 + // `darkColorScheme(...)` defaults inverseSurface to a light value, so we + // have to override it here to make the trap reproducible in tests. + inverseSurface = Color(0xFF101010), + inverseOnSurface = Color(0xFF101010), + ) + } + + // ========== Surface family is clamped to the static Slate ramp ========== + + @Test + fun `clamped surface family equals the static dark fallback surface`() { + val dynamic = highLuminanceDynamicDark() + val clamped = clampDynamicDarkScheme(dynamic, fallback) + + assertEquals(fallback.surface, clamped.surface, + "clamped surface must equal fallback.surface (no light wallpaper leak)") + assertEquals(fallback.onSurface, clamped.onSurface) + assertEquals(fallback.surfaceVariant, clamped.surfaceVariant) + assertEquals(fallback.onSurfaceVariant, clamped.onSurfaceVariant) + assertEquals(fallback.surfaceDim, clamped.surfaceDim) + assertEquals(fallback.surfaceBright, clamped.surfaceBright) + assertEquals(fallback.surfaceContainerLowest, clamped.surfaceContainerLowest) + assertEquals(fallback.surfaceContainerLow, clamped.surfaceContainerLow) + assertEquals(fallback.surfaceContainer, clamped.surfaceContainer) + assertEquals(fallback.surfaceContainerHigh, clamped.surfaceContainerHigh) + assertEquals(fallback.surfaceContainerHighest, clamped.surfaceContainerHighest) + assertEquals(fallback.background, clamped.background) + assertEquals(fallback.onBackground, clamped.onBackground) + assertNotEquals(dynamic.surfaceTint, fallback.surfaceTint, + "precondition: faked dynamic surfaceTint must differ from fallback so the clamp assertion is meaningful") + assertEquals(fallback.surfaceTint, clamped.surfaceTint) + assertEquals(fallback.inverseSurface, clamped.inverseSurface) + assertEquals(fallback.inverseOnSurface, clamped.inverseOnSurface) + } + + @Test + fun `conservative clamp routes surfaceVariant background surfaceDim surfaceBright from fallback`() { + // Regression test for the Kilo Code Review warning on PR #642: + // the *entire* surface family (incl. surfaceVariant / onSurfaceVariant / + // surfaceDim / surfaceBright / background / onBackground) is clamped to + // fallback, not just the surfaceContainer* roles. These tokens are part of + // the same wallpaper-miscalibrated surface family that the reporter saw + // flip light. If a future refactor loosens the clamp on these tokens, + // the bug returns on wallpapers with high-luminance surfaceVariant / + // background values. + val dynamic = highLuminanceDynamicDark() + val clamped = clampDynamicDarkScheme(dynamic, fallback) + + // Confirm the test fixture has a non-fallback value for these tokens so the + // assertion is meaningful (otherwise the test would silently pass). + assertTrue( + dynamic.surfaceVariant != fallback.surfaceVariant || + dynamic.background != fallback.background || + dynamic.surfaceDim != fallback.surfaceDim, + "precondition: faked dynamic must differ from fallback on surfaceVariant/" + + "background/surfaceDim so the clamp assertion is meaningful", + ) + + assertEquals(fallback.surfaceVariant, clamped.surfaceVariant, + "surfaceVariant must come from fallback — defensive against wallpaper " + + "surfaceVariant leak. See PR #642 Kilo Code Review.") + assertEquals(fallback.onSurfaceVariant, clamped.onSurfaceVariant, + "onSurfaceVariant must come from fallback — defensive against wallpaper " + + "onSurfaceVariant leak.") + assertEquals(fallback.background, clamped.background) + assertEquals(fallback.onBackground, clamped.onBackground) + assertEquals(fallback.surfaceDim, clamped.surfaceDim) + assertEquals(fallback.surfaceBright, clamped.surfaceBright) + // inverseSurface / inverseOnSurface are NOT asserted here — see the broader + // `clamped surface family equals the static dark fallback surface` test above, + // which asserts every surface-family token in one place. Asserting them here + // too would be a duplicate copy of the same clamp check. + } + + @Test + fun `clamped chrome surface luminance is below the dark threshold`() { + // Regression test for issue #640: top app bar / bottom nav / expanded card. + val dynamic = highLuminanceDynamicDark() + val clamped = clampDynamicDarkScheme(dynamic, fallback) + + assertTrue(clamped.surface.luminance() < 0.5f, + "Top app bar surface must be dark (luminance < 0.5), got ${clamped.surface.luminance()}") + assertTrue(clamped.surfaceContainerHigh.luminance() < 0.5f, + "Bottom nav surfaceContainerHigh must be dark (luminance < 0.5), got ${clamped.surfaceContainerHigh.luminance()}") + assertTrue(clamped.surfaceContainerHighest.luminance() < 0.5f, + "RoutineCard surfaceContainerHighest must be dark (luminance < 0.5), got ${clamped.surfaceContainerHighest.luminance()}") + // And specifically within Slate ramp (very dark). + assertTrue(clamped.surfaceContainerHighest.luminance() <= 0.2f, + "surfaceContainerHighest must be Slate700-equivalent (luminance <= 0.2), got ${clamped.surfaceContainerHighest.luminance()}") + } + + // ========== Wallpaper hue is preserved on accents ========== + + @Test + fun `primary and secondary and tertiary come from the dynamic scheme`() { + val dynamic = highLuminanceDynamicDark() + val clamped = clampDynamicDarkScheme(dynamic, fallback) + + assertEquals(dynamic.primary, clamped.primary, + "primary must come from dynamic — wallpaper hue on toggles/badges") + assertEquals(dynamic.onPrimary, clamped.onPrimary) + assertEquals(dynamic.primaryContainer, clamped.primaryContainer) + assertEquals(dynamic.onPrimaryContainer, clamped.onPrimaryContainer) + assertEquals(dynamic.secondary, clamped.secondary) + assertEquals(dynamic.onSecondary, clamped.onSecondary) + assertEquals(dynamic.secondaryContainer, clamped.secondaryContainer) + assertEquals(dynamic.onSecondaryContainer, clamped.onSecondaryContainer) + assertEquals(dynamic.tertiary, clamped.tertiary) + assertEquals(dynamic.onTertiary, clamped.onTertiary) + assertEquals(dynamic.tertiaryContainer, clamped.tertiaryContainer) + assertEquals(dynamic.onTertiaryContainer, clamped.onTertiaryContainer) + assertEquals(dynamic.error, clamped.error) + assertEquals(dynamic.onError, clamped.onError) + assertEquals(dynamic.outline, clamped.outline) + assertEquals(dynamic.outlineVariant, clamped.outlineVariant) + } + + // ========== Identity: when dynamic == fallback, result equals both ========== + + @Test + fun `clamping an already-dark dynamic scheme is a no-op`() { + val clamped = clampDynamicDarkScheme(fallback, fallback) + // Every role should match the fallback because both arguments are the same. + assertEquals(fallback.surface, clamped.surface) + assertEquals(fallback.surfaceContainerHigh, clamped.surfaceContainerHigh) + assertEquals(fallback.surfaceContainerHighest, clamped.surfaceContainerHighest) + assertEquals(fallback.primary, clamped.primary) + assertEquals(fallback.secondary, clamped.secondary) + assertEquals(fallback.tertiary, clamped.tertiary) + } + + // ========== The helper never mutates the dynamic argument ========== + + @Test + fun `clamping does not mutate the dynamic input`() { + val dynamic = highLuminanceDynamicDark() + val surfaceBefore = dynamic.surface + val surfaceContainerHighestBefore = dynamic.surfaceContainerHighest + val primaryBefore = dynamic.primary + + clampDynamicDarkScheme(dynamic, fallback) + + assertEquals(surfaceBefore, dynamic.surface, + "dynamic.surface must not be mutated by clamping") + assertEquals(surfaceContainerHighestBefore, dynamic.surfaceContainerHighest, + "dynamic.surfaceContainerHighest must not be mutated by clamping") + assertEquals(primaryBefore, dynamic.primary, + "dynamic.primary must not be mutated by clamping") + } + + // ========== Surface shift between 2dp and 8dp ≤ 6% ========== + + @Test + fun `card surfaceContainerHighest differs from surfaceContainerHigh by less than 6 percent`() { + // Compose's M3 Card elevation tint mixes a small amount of primary into the + // base surfaceContainerHighest. When the base color is the static Slate + // ramp, that tint shift is bounded. This is the structural reason the + // expand/collapse flicker ≤ 6% once the surface family is clamped. + val dynamic = highLuminanceDynamicDark() + val clamped = clampDynamicDarkScheme(dynamic, fallback) + + val delta = kotlin.math.abs( + clamped.surfaceContainerHigh.luminance() - + clamped.surfaceContainerHighest.luminance() + ) + assertTrue(delta <= 0.06f, + "Adjacent surface roles must be within 6% luminance so 2.dp vs 8.dp Card " + + "elevation tint doesn't visibly flip the card. Got delta = $delta") + } + + // ========== Sanity: helper actually changed something for a buggy dynamic scheme ========== + + @Test + fun `clamping actually moves light dynamic surfaces to dark fallback surfaces`() { + val dynamic = highLuminanceDynamicDark() + // Sanity: the buggy dynamic surfaces are light (>0.5 luminance). + assertTrue(dynamic.surface.luminance() > 0.5f, + "precondition: faked dynamic surface must be high-luminance to exercise the bug") + + val clamped = clampDynamicDarkScheme(dynamic, fallback) + + assertNotEquals(dynamic.surface, clamped.surface, + "clamping must replace the light dynamic surface with the dark fallback surface") + assertNotEquals(dynamic.surfaceContainerHighest, clamped.surfaceContainerHighest) + assertNotEquals(dynamic.surfaceContainerHigh, clamped.surfaceContainerHigh) + } + + // ========== Boundary luminance: even white surfaces are clamped ========== + + @Test + fun `boundary luminance 1_0 — pure white dynamic surface is clamped to fallback`() { + val extremeDynamic = highLuminanceDynamicDark().copy( + surface = Color.White, + surfaceContainerHighest = Color.White, + surfaceContainerHigh = Color.White, + surfaceBright = Color.White, + surfaceTint = Color.White, + ) + val clamped = clampDynamicDarkScheme(extremeDynamic, fallback) + assertEquals(fallback.surface, clamped.surface) + assertEquals(fallback.surfaceContainerHighest, clamped.surfaceContainerHighest) + assertEquals(fallback.surfaceContainerHigh, clamped.surfaceContainerHigh) + assertEquals(fallback.surfaceBright, clamped.surfaceBright) + assertEquals(fallback.surfaceTint, clamped.surfaceTint) + assertTrue(clamped.surface.luminance() < 0.5f) + } + + @Test + fun `boundary luminance 0_0 — black dynamic surface is also clamped to fallback`() { + val blackDynamic = highLuminanceDynamicDark().copy( + surface = Color.Black, + surfaceContainerHighest = Color.Black, + surfaceContainerHigh = Color.Black, + surfaceBright = Color.Black, + ) + val clamped = clampDynamicDarkScheme(blackDynamic, fallback) + // The clamp always takes the fallback surface family — even when the + // dynamic scheme is fully black, we still emit the brand-controlled ramp + // so the chrome matches the static palette exactly. + assertEquals(fallback.surface, clamped.surface) + assertEquals(fallback.surfaceContainerHighest, clamped.surfaceContainerHighest) + assertEquals(fallback.surfaceContainerHigh, clamped.surfaceContainerHigh) + } +} \ No newline at end of file