From 21d57eba542c4fb02828df583d458440e339caa3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 00:10:06 +0200 Subject: [PATCH] feat: honour the platform's contrast setting, reaching four schemes that were dead code Phase 1, step 2 of docs/material-design-conformance.md. `Color.kt` has carried medium- and high-contrast variants of both themes since it was generated -- 156 colour values, wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and never selected. `TorchTheme` chose between `darkScheme` and `lightScheme` and nothing else, so a user who turned contrast up in Accessibility settings got no change at all. M3's accessibility foundation leads with *honour individuals*: "supporting varying preferences and choices that allow individuals to address how their changing conditions, individual knowledge, and varying needs are met." The work to do that was already done and disconnected. **The expect/actual boundary moved, because it was in the wrong place.** `themeColorScheme` took four arguments and did two unrelated jobs -- decide the contrast-free light/dark scheme, and decide whether to prefer a wallpaper palette. Adding contrast to it would have meant passing six schemes across the boundary and repeating the selection table in three actuals. It splits instead into `platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow platform question, with the six-way table as a plain function `appColorScheme(darkTheme, contrast)` in common code. `dynamicColorScheme` returns null rather than falling back internally so the fallback stays in one place. **Android reads the setting and listens for changes.** `UiModeManager.getContrast()` is API 34; the app's minSdk is 26, so below that the answer is Standard. The float is snapped to the nearest of the platform's three documented positions rather than matched exactly, so a future finer-grained slider degrades to the closest scheme this app has instead of falling back to Standard. The `ContrastChangeListener` is the part that is easy to leave out and matters most. A contrast change does not restart the activity and does not arrive as a `Configuration` update, so without it the new setting would take effect on the next cold start -- which is precisely the case the setting exists for. `context.mainExecutor` rather than `ContextCompat.getMainExecutor`: it needs API 28, this branch is already gated on 34, and composeApp does not declare androidx.core -- it only arrives transitively through activity-compose, which is not a dependency to lean on. **iOS observes the notification for the same reason** -- `UIAccessibilityDarkerSystemColorsEnabled` plus `UIAccessibilityDarkerSystemColorsStatusDidChangeNotification`. It is a boolean, not a slider, so iOS reports High or Standard and never Medium. **Desktop is honest rather than complete.** Windows publishes high contrast as the `win.highContrast.on` AWT desktop property and fires a property change when it is toggled, so that path is real and live. macos "Increase contrast" and the linux desktop equivalents do not reach AWT, and reading them means a native call per platform, so on those two the answer is Standard and the file says so. This is the right place for a user-overridable preference later; a desktop app cannot always see what the desktop was told. **Verified on an emulator, at the pixel.** API 36, dynamic colour temporarily switched off (see below for why that is necessary), sampling the `onPrimaryContainer` pixel of the "Skip for now" label as `settings put secure contrast_level` moved: standard (0.0) #848484 onPrimaryContainerLight medium (0.5) #A7A7A7 onPrimaryContainerLightMediumContrast high (1.0) #D0D0D0 onPrimaryContainerLightHighContrast The three declared values exactly, and **the app was not restarted between them** -- only the setting changed, four seconds apart. That is the listener working end to end. The probe that switched dynamic colour off is reverted in this commit; the emulator's contrast_level is back at 0.0. **A finding that came out of the verification, and is not fixed here.** `TorchTheme` defaults `dynamicColor = true`, and on Android 12+ dynamic colour wins unconditionally -- so on essentially every current Android device **none of the six schemes is used at all** and the app renders in whatever the user's wallpaper produced. The first screenshot of this session shows the onboarding screen in Material lavender; switching dynamic colour off reveals the black-and-gold brand for the first time. Nobody on a modern Android has been seeing this app's palette. That is a product decision, not a conformance one, so it is recorded in the plan's "What this plan does not cover" rather than changed. It does bound what this commit buys: on Android 14+ with dynamic colour on, contrast is honoured by the platform anyway (the `system_*` resources shift with it, confirmed on the same emulator -- buttons went slate-blue to near-black navy). What this commit reaches is Android below 12, Android 12-13, iOS, and desktop. **Three new assertions.** `AppColorSchemeSelectionTest` covers the table itself, because its failure mode is silent and specific: a scheme wired to the wrong cell still renders a complete, plausible UI, and somebody who turns contrast up and gets the medium scheme back cannot tell it apart from a high-contrast scheme that is not very high. It asserts each of the six cells by identity, that all six are distinct objects (a copy-paste leaving two cells on the same scheme would pass the first test only if it also mislabelled one), and that `onSurface` on `surface` never *falls* as contrast rises -- the one direction that must hold, and deliberately not the full monotonicity assertion that ColorSchemeContrastTest explains is false. **Not compiled: the iOS actual.** The ios targets are declared only on macos (see docs/jvm-target.md), so `Theme.ios.kt` is written against the UIKit and Foundation bindings rather than checked by a compiler. Its file comment says so. The android and jvm actuals of the same two functions are compiled, and the android one is verified on a device. **Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, up from 920/583/337. `:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build, `m3-audit.sh --check` exits 0. The 54 existing `TorchTheme { }` call sites are untouched -- the new parameter is defaulted. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/ui/theme/Theme.android.kt | 81 ++++++++++++++---- .../press/mantra/compose/ui/theme/Theme.kt | 77 ++++++++++++++--- .../ui/theme/AppColorSchemeSelectionTest.kt | 83 +++++++++++++++++++ .../mantra/compose/ui/theme/Theme.ios.kt | 59 ++++++++++--- .../mantra/compose/ui/theme/Theme.jvm.kt | 65 +++++++++++++-- docs/material-design-conformance.md | 37 +++++++-- 6 files changed, 350 insertions(+), 52 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt index 0d8559cc..2f20eef1 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt @@ -1,26 +1,79 @@ package press.mantra.compose.ui.theme +import android.app.UiModeManager +import android.content.Context import android.os.Build import androidx.compose.material3.ColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +/** + * Android 14 (API 34) added a three-step contrast setting under Accessibility > Display. + * `UiModeManager.getContrast()` reports it as a float, and the platform documents the + * three positions as 0.0, 0.5 and 1.0. Values between are treated as the nearer step + * rather than rejected -- the API returns a float, so a future finer-grained slider + * should degrade to the closest scheme this app has rather than to Standard. + * + * Below API 34 there is nothing to read and the answer is [ThemeContrast.Standard]. The + * app's minSdk is 26. + */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme { - return when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } +actual fun platformThemeContrast(): ThemeContrast { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return ThemeContrast.Standard - darkTheme -> darkScheme - else -> lightScheme + val context = LocalContext.current + val uiModeManager = remember(context) { + context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + } ?: return ThemeContrast.Standard + + var contrast by remember(uiModeManager) { + mutableStateOf(contrastStepFor(uiModeManager.contrast)) } -} \ No newline at end of file + + // The setting can be changed while the app is in the foreground, and unlike a theme + // or locale change it does not restart the activity or arrive as a Configuration + // update -- so without this listener the new value would only take effect on the + // next cold start, which is the case the setting exists for. + DisposableEffect(uiModeManager, context) { + val listener = UiModeManager.ContrastChangeListener { value -> + contrast = contrastStepFor(value) + } + // context.mainExecutor rather than ContextCompat.getMainExecutor: it needs API 28 + // and this whole branch is already gated on 34, and it keeps androidx.core off + // this file's imports -- composeApp does not declare it, it only arrives + // transitively through activity-compose. + uiModeManager.addContrastChangeListener(context.mainExecutor, listener) + onDispose { uiModeManager.removeContrastChangeListener(listener) } + } + + return contrast +} + +/** Nearest of the platform's three documented positions. */ +private fun contrastStepFor(value: Float): ThemeContrast = when { + value < 0.25f -> ThemeContrast.Standard + value < 0.75f -> ThemeContrast.Medium + else -> ThemeContrast.High +} + +/** + * Material You, from Android 12 (API 31). + * + * No contrast argument is needed. From API 34 the `android.R.color.system_*` resources + * these are built from shift with the contrast setting themselves, so a dynamic scheme + * already carries it; passing this app's own contrast on top would apply it twice. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? { + if (!dynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + + val context = LocalContext.current + return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index 6c4816ae..b3e5c885 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -384,19 +384,59 @@ val unspecified_scheme = ColorFamily( Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified ) +/** + * How much contrast the person using the device has asked for. + * + * Not a preference this app invents -- every platform that has the setting owns it, and + * `platformThemeContrast` reports it. M3's accessibility foundation puts *honour + * individuals* first: "supporting varying preferences and choices that allow individuals + * to address how their changing conditions, individual knowledge, and varying needs are + * met." The four schemes to answer with were already written out in Color.kt and were, + * until this type existed, unreachable. + */ +enum class ThemeContrast { + /** The app's default schemes. */ + Standard, + + /** Android's 0.5 contrast step. No iOS or desktop equivalent, so never reported there. */ + Medium, + + /** Android's 1.0 step; iOS "Increase Contrast"; Windows high contrast mode. */ + High, +} + +/** + * The scheme to use, before dynamic colour gets a say. + * + * Kept in common code rather than behind the platform boundary so that all six schemes + * are selected from one table. The platform actuals answer two narrow questions instead + * -- what contrast was asked for, and whether there is a wallpaper palette to prefer -- + * which is the part that genuinely differs. + */ +internal fun appColorScheme(darkTheme: Boolean, contrast: ThemeContrast): ColorScheme = + when (contrast) { + ThemeContrast.Standard -> if (darkTheme) darkScheme else lightScheme + ThemeContrast.Medium -> + if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ThemeContrast.High -> + if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + } + @Composable fun TorchTheme( darkTheme: Boolean = isSystemInDarkTheme(), + contrast: ThemeContrast = platformThemeContrast(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, - content: @Composable() () -> Unit + content: @Composable () -> Unit ) { - val colorScheme = themeColorScheme( - darkTheme, - dynamicColor, - darkScheme, - lightScheme - ) + // Dynamic colour wins when the platform offers it, because it is the user's own + // choice and already carries their contrast setting -- on Android 14+ the + // `system_*` palette resources shift with it, so `dynamicLightColorScheme` needs + // no help from `contrast`. Everywhere else the app's schemes answer, and that is + // where `contrast` decides which of the six. + val colorScheme = dynamicColorScheme(darkTheme, dynamicColor) + ?: appColorScheme(darkTheme, contrast) MaterialTheme( colorScheme = colorScheme, @@ -405,10 +445,21 @@ fun TorchTheme( ) } +/** + * The platform's contrast setting, recomposing when it changes. + * + * Reading it once at startup would be a worse version of honouring it: someone who turns + * contrast up because they cannot read the screen in front of them should not have to + * find out that the app needs restarting. + */ @Composable -expect fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme +expect fun platformThemeContrast(): ThemeContrast + +/** + * A wallpaper-derived palette, or `null` where the platform has none. + * + * Returns `null` rather than falling back internally so that the choice of app scheme -- + * which now depends on contrast as well as darkness -- stays in one place. + */ +@Composable +expect fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt new file mode 100644 index 00000000..900cac4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt @@ -0,0 +1,83 @@ +package press.mantra.compose.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +/** + * The six-way table `appColorScheme` picks from. + * + * Worth a test because the failure mode is silent and specific: a scheme wired to the + * wrong cell still renders a complete, plausible UI. Somebody who turns contrast up and + * gets the medium scheme back has no way to tell it apart from a high-contrast scheme + * that is simply not very high, and `Color.kt`'s six schemes are 468 near-identical + * lines in which a transposed row reads as a typo you would have to be looking for. + * + * Until this table existed, four of the six schemes were unreachable -- declared in full + * and never selected. + */ +class AppColorSchemeSelectionTest { + + @Test + fun `each darkness and contrast pair selects its own scheme`() { + assertSame(lightScheme, appColorScheme(darkTheme = false, contrast = ThemeContrast.Standard)) + assertSame(darkScheme, appColorScheme(darkTheme = true, contrast = ThemeContrast.Standard)) + + assertSame( + mediumContrastLightColorScheme, + appColorScheme(darkTheme = false, contrast = ThemeContrast.Medium), + ) + assertSame( + mediumContrastDarkColorScheme, + appColorScheme(darkTheme = true, contrast = ThemeContrast.Medium), + ) + + assertSame( + highContrastLightColorScheme, + appColorScheme(darkTheme = false, contrast = ThemeContrast.High), + ) + assertSame( + highContrastDarkColorScheme, + appColorScheme(darkTheme = true, contrast = ThemeContrast.High), + ) + } + + @Test + fun `all six schemes are distinct`() { + // A copy-paste that left two cells pointing at the same object would satisfy the + // table test above only if it also mislabelled one, so this catches the other + // half: six declarations that are not six schemes. + val schemes = ThemeContrast.entries.flatMap { contrast -> + listOf(false, true).map { dark -> "$contrast/${if (dark) "dark" else "light"}" to appColorScheme(dark, contrast) } + } + + schemes.forEachIndexed { i, (nameA, a) -> + schemes.drop(i + 1).forEach { (nameB, b) -> + assertNotEquals(a, b, "$nameA and $nameB are the same scheme") + } + } + } + + @Test + fun `raising contrast never lowers the contrast of body text`() { + // The one direction that must hold. Individual tonal surfaces legitimately move + // the other way -- see ColorSchemeContrastTest for why a full monotonicity + // assertion is wrong -- but onSurface against surface is the pair the setting + // exists for, and it going backwards would be indefensible. + listOf(false, true).forEach { dark -> + val standard = appColorScheme(dark, ThemeContrast.Standard) + val medium = appColorScheme(dark, ThemeContrast.Medium) + val high = appColorScheme(dark, ThemeContrast.High) + + val ratios = listOf(standard, medium, high).map { contrastRatio(it.surface, it.onSurface) } + val theme = if (dark) "dark" else "light" + + assertEquals( + ratios.sorted(), + ratios, + "$theme: onSurface on surface does not rise with contrast — $ratios", + ) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt index 1760ce45..edd9e0d8 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt @@ -2,16 +2,55 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import platform.UIKit.UIAccessibilityDarkerSystemColorsEnabled +import platform.UIKit.UIAccessibilityDarkerSystemColorsStatusDidChangeNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +/** + * iOS Settings > Accessibility > Display & Text Size > Increase Contrast. + * + * A boolean, not a three-step slider, so this answers [ThemeContrast.High] or + * [ThemeContrast.Standard] and never [ThemeContrast.Medium]. Apple's own contrast work is + * mostly done inside its system colours rather than exposed as a level; the one bit it + * does expose is `UIAccessibilityDarkerSystemColorsEnabled`. + * + * **Not compiled on this machine.** The ios targets are declared only on macos (see + * docs/jvm-target.md for why the composite build forces that), so this file has been + * written against the UIKit and Foundation bindings rather than checked by a compiler. + * The Android and jvm actuals of the same two functions are compiled and tested. + */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme { - return when { - darkTheme -> darkScheme - else -> lightScheme +actual fun platformThemeContrast(): ThemeContrast { + var contrast by remember { mutableStateOf(currentContrast()) } + + // The switch can be flipped while the app is foregrounded, and iOS announces it + // rather than restarting anything -- so without this observer the new setting would + // wait for the next cold start. + DisposableEffect(Unit) { + val observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, + `object` = null, + queue = NSOperationQueue.mainQueue, + ) { _ -> contrast = currentContrast() } + + onDispose { NSNotificationCenter.defaultCenter.removeObserver(observer) } } -} \ No newline at end of file + + return contrast +} + +private fun currentContrast(): ThemeContrast = + if (UIAccessibilityDarkerSystemColorsEnabled()) ThemeContrast.High else ThemeContrast.Standard + +/** + * Always `null`: dynamic colour means Material You, an android wallpaper-derived palette + * with no iOS counterpart. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt index c5d5e191..db5010e2 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt @@ -2,16 +2,63 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import java.awt.Toolkit /** - * `dynamicColor` is ignored: it means Material You, which reads a wallpaper-derived palette - * from the android system and has no desktop counterpart. The app's own schemes are used - * whatever the caller asks for. + * Desktop has no portable contrast setting, and this reads the one platform that exposes + * a usable signal through AWT. + * + * Windows publishes its high contrast mode as the `win.highContrast.on` desktop property, + * which AWT surfaces on that platform and reports as `null` everywhere else. There is no + * medium step -- Windows high contrast is a boolean -- so this answers + * [ThemeContrast.High] or [ThemeContrast.Standard] and never [ThemeContrast.Medium]. + * + * On linux and macos the answer is always [ThemeContrast.Standard]. macos does have + * "Increase contrast" and linux desktops have their own equivalents, but neither reaches + * AWT, and reading them means a native call per platform. **Until the app has a settings + * screen this is simply unhonoured there**, which is worth knowing rather than papering + * over: the setting should ultimately be a preference the user can override anyway, + * since a desktop app cannot always see what the desktop was told. */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme = if (darkTheme) darkScheme else lightScheme +actual fun platformThemeContrast(): ThemeContrast { + val toolkit = remember { runCatching { Toolkit.getDefaultToolkit() }.getOrNull() } + ?: return ThemeContrast.Standard + + var contrast by remember(toolkit) { mutableStateOf(windowsHighContrast(toolkit)) } + + // Windows fires a property change when the user toggles high contrast, so the app + // does not need restarting. The listener is a no-op on platforms that never publish + // the property. + DisposableEffect(toolkit) { + val listener = java.beans.PropertyChangeListener { + contrast = windowsHighContrast(toolkit) + } + toolkit.addPropertyChangeListener(HIGH_CONTRAST_PROPERTY, listener) + onDispose { toolkit.removePropertyChangeListener(HIGH_CONTRAST_PROPERTY, listener) } + } + + return contrast +} + +private const val HIGH_CONTRAST_PROPERTY = "win.highContrast.on" + +private fun windowsHighContrast(toolkit: Toolkit): ThemeContrast = + if (toolkit.getDesktopProperty(HIGH_CONTRAST_PROPERTY) == true) { + ThemeContrast.High + } else { + ThemeContrast.Standard + } + +/** + * Always `null`: dynamic colour means Material You, which reads a wallpaper-derived + * palette from the android system and has no desktop counterpart. The app's own schemes + * answer whatever the caller asks for. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index 9d53c1e1..48626aee 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -418,12 +418,26 @@ find-and-replace. Keep the existing hex values for the roles already defined so nothing shifts visually; this phase adds, it does not restyle. -2. **Reach the contrast schemes.** `TorchTheme` grows a contrast parameter and - the platform actuals report it — Android from `UiModeManager.getContrast()` - (API 34+, a float where `0f`/`0.33f`/`0.66f` map onto the three schemes), - falling back to the default scheme on the app's minSdk of 26; iOS from - `UIAccessibilityDarkerSystemColorsEnabled`; desktop from a preference. The - four schemes already written stop being dead code. +2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast` + defaulted from a new `platformThemeContrast()` expect/actual, and the six-way + selection table lives in common code as `appColorScheme`. The old + `themeColorScheme` expect took four arguments and did both jobs; it splits into + `platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow + question, so the scheme table is in one place rather than three. + + Android reads `UiModeManager.getContrast()` (API 34+) and registers a + `ContrastChangeListener`, because a contrast change does not restart the + activity or arrive as a `Configuration` update — without the listener the new + setting would wait for the next cold start, which is the case the setting + exists for. iOS reads `UIAccessibilityDarkerSystemColorsEnabled` and observes + `…StatusDidChangeNotification`; it is a boolean, so iOS never reports Medium. + Desktop reads Windows' `win.highContrast.on` AWT desktop property and answers + Standard on linux and macos, which is honest rather than complete — see + "What this plan does not cover". + + Verified on an API 36 emulator with dynamic colour off: the `onPrimaryContainer` + pixel of the "Skip for now" label reads `#848484` → `#A7A7A7` → `#D0D0D0` as the + setting moves, the three declared values exactly, **without the app restarting**. 3. **Give `MaterialTheme` its other three slots.** `Shapes`, `Typography` and a `MotionScheme` are all parameters of the overload the app already calls: @@ -787,6 +801,17 @@ Phases 1–5 can be worked in parallel by different people if 1 lands first; `ExperimentalMaterial3ExpressiveApi` opt-ins and the pinned alpha both point expressive, but it is a product decision with a visible outcome, and it should be made deliberately rather than inherited from an import. +- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults + `dynamicColor = true`, and on Android 12+ that wins unconditionally — so on + essentially every current Android device, none of the six schemes below is used + and the app renders in whatever the user's wallpaper produced. Verified on an API + 36 emulator: the app paints Material lavender until dynamic colour is switched + off, at which point the black-and-gold brand appears. M3's customization + foundation treats this as a developer choice, and applying it selectively — a + profile screen, say — is the usual answer for an app with a brand. Deciding it is + a product call, not a conformance one, and it is why phase 1's contrast work is + reachable today only on Android below 12, on iOS and on desktop. On Android 14+ + with dynamic colour on, the platform honours contrast itself. - **Whether the monochrome palette is right.** The scheme's `primary` is pure black in light and near-white in dark, with the entire tertiary family a copy of primary. That is a defensible choice for this product and it passes