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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user