From f1ded81c032bba88bfe351b87dfd99916e8a6b63 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 17:19:31 +0200 Subject: [PATCH] feat(identity): pure-black (AMOLED) dark theme ColorScheme.toPureBlack() sets background, surface, surfaceDim and surfaceContainerLowest to #000 and dims the other containers by scaling their sRGB channels, so their hue tint carries over and grid tiles drawn as containers over surface keep visible gaps. FloretExpressiveTheme takes pureBlack to apply it to the dark scheme, dynamic or app-supplied. core-prefs gains ThemeMode.BLACK (dark, pure black) and ThemeMode.isPureBlack, stored under the same theme_mode key. --- CHANGELOG.md | 11 ++++ .../jeanlucmakiola/floret/prefs/Appearance.kt | 12 +++- .../floret/prefs/ThemeModeTest.kt | 18 +++++ identity/build.gradle.kts | 12 ++++ .../floret/identity/PureBlack.kt | 43 ++++++++++++ .../jeanlucmakiola/floret/identity/Theme.kt | 6 +- .../floret/identity/PureBlackTest.kt | 66 +++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt create mode 100644 identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8894c4a..d220c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,17 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` + sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to + #000 and pulls the other containers down towards black, keeping their hue + tint, so grid tiles drawn as containers over `surface` still show their + gaps. `FloretExpressiveTheme` gains `pureBlack` (default `false`), which + applies it to the dark scheme, dynamic or app-supplied. +- **`core-prefs`** — `ThemeMode.BLACK`, dark with pure-black surfaces. + `isDark` treats it as dark, and the new `ThemeMode.isPureBlack` is what an + app passes to the theme's `pureBlack`. It's stored as `"BLACK"` under the + same `theme_mode` key; a build without it reads that as `SYSTEM`. + - **`glance`** (new module) — the family's shared home-screen-widget plumbing and M3 Expressive widget vocabulary, built on Jetpack Glance: `FloretGlanceTheme` (dynamic colour on API 31+, app-supplied fallback diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt index dcb0c22..19a3fc5 100644 --- a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt @@ -4,16 +4,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -/** Light/dark override. [SYSTEM] follows the device setting. */ -enum class ThemeMode { SYSTEM, LIGHT, DARK } +/** + * Light/dark override. [SYSTEM] follows the device setting; [BLACK] is dark + * with pure-black surfaces (the AMOLED option). + */ +enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK } /** Resolve to a concrete dark flag; [SYSTEM] defers to [systemDark]. */ fun ThemeMode.isDark(systemDark: Boolean): Boolean = when (this) { ThemeMode.SYSTEM -> systemDark ThemeMode.LIGHT -> false - ThemeMode.DARK -> true + ThemeMode.DARK, ThemeMode.BLACK -> true } +/** Whether the theme should draw pure-black surfaces; only [ThemeMode.BLACK] does. */ +val ThemeMode.isPureBlack: Boolean get() = this == ThemeMode.BLACK + /** The two appearance choices every app in the family has. */ data class Appearance( val themeMode: ThemeMode = ThemeMode.SYSTEM, diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt index d42d47e..32155d9 100644 --- a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt @@ -42,6 +42,24 @@ class ThemeModeTest { assertThat(dark).isTrue() } + @Test + fun `isDark lets BLACK override a light system`() { + val mode = ThemeMode.BLACK + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isTrue() + } + + @Test + fun `isPureBlack is set only for BLACK`() { + val modes = ThemeMode.entries + + val pureBlack = modes.filter { it.isPureBlack } + + assertThat(pureBlack).containsExactly(ThemeMode.BLACK) + } + @Test fun `Appearance defaults to following the system with dynamic colour on`() { val appearance = Appearance() diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index a9fa649..26740f5 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -23,6 +23,13 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } } kotlin { @@ -39,4 +46,9 @@ dependencies { implementation(libs.androidx.animation) // activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek). implementation(libs.androidx.activity.compose) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) } diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt new file mode 100644 index 0000000..a99aa1b --- /dev/null +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color + +/** + * The pure-black (AMOLED) take on a dark [ColorScheme]: the ground — `background`, + * `surface`, `surfaceDim`, `surfaceContainerLowest` — goes to #000, and the + * container ladder is pulled down towards black rather than flattened onto it. + * Apps draw grids as container tiles over a `surface` ground, so the tiles keep + * a small lift and the gaps between them still read as lines; sheets and menus + * on the upper containers still separate from the page. + * + * Each container keeps a fixed share of its own sRGB channels, so the scheme's + * hue tint — a dynamic wallpaper colour or an app's seed — carries + * over. Accent, outline and `on*` roles are untouched. Meant for a dark scheme + * only; on a light one it would just darken the surfaces. + */ +fun ColorScheme.toPureBlack(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = surfaceContainerLow.dimmed(PURE_BLACK_LOW), + surfaceContainer = surfaceContainer.dimmed(PURE_BLACK_CONTAINER), + surfaceContainerHigh = surfaceContainerHigh.dimmed(PURE_BLACK_HIGH), + surfaceContainerHighest = surfaceContainerHighest.dimmed(PURE_BLACK_HIGHEST), +) + +/** + * This colour with each sRGB channel scaled by [share], keeping its hue. Plain + * channel scaling rather than `lerp`, which blends in Oklab and lands darker. + */ +private fun Color.dimmed(share: Float): Color = + Color(red = red * share, green = green * share, blue = blue * share, alpha = alpha) + +// Shares of each container's own sRGB channels kept in pure black. Against the +// stock dark tones (#1D1B20 / #211F26 / #2B2930 / #36343B) they land near +// #131215 / #1A191E / #252329 / #302F35. +private const val PURE_BLACK_LOW = 0.65f +private const val PURE_BLACK_CONTAINER = 0.80f +private const val PURE_BLACK_HIGH = 0.85f +private const val PURE_BLACK_HIGHEST = 0.90f diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt index bce727d..6574513 100644 --- a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt @@ -34,6 +34,9 @@ val LocalFloretDarkTheme = staticCompositionLocalOf { false } * passing its own hand-tuned, seed-derived [lightScheme] / [darkScheme] and an * optional [typography]. A thin per-app wrapper (e.g. `AgendulaTheme`) supplies * those and forwards [darkTheme] / [dynamicColor] from settings. + * + * [pureBlack] swaps the dark scheme — dynamic or app-supplied — for its + * [toPureBlack] take, the AMOLED option. It has no effect on a light theme. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -42,6 +45,7 @@ fun FloretExpressiveTheme( darkScheme: ColorScheme, darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + pureBlack: Boolean = false, typography: Typography = Typography(), content: @Composable () -> Unit, ) { @@ -52,7 +56,7 @@ fun FloretExpressiveTheme( } darkTheme -> darkScheme else -> lightScheme - } + }.let { if (darkTheme && pureBlack) it.toPureBlack() else it } CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) { MaterialExpressiveTheme( diff --git a/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt b/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt new file mode 100644 index 0000000..de62607 --- /dev/null +++ b/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** The pure-black take on a dark scheme: a black ground, a dimmed container ladder. */ +class PureBlackTest { + + private val dark = darkColorScheme() + + @Test + fun `toPureBlack sets the ground roles to black`() { + val black = dark.toPureBlack() + + val ground = listOf(black.background, black.surface, black.surfaceDim, black.surfaceContainerLowest) + + assertThat(ground).containsExactly(Color.Black, Color.Black, Color.Black, Color.Black) + } + + @Test + fun `toPureBlack keeps the containers above black and in order`() { + val black = dark.toPureBlack() + + val ladder = listOf( + black.surfaceContainerLow, + black.surfaceContainer, + black.surfaceContainerHigh, + black.surfaceContainerHighest, + ).map { it.red + it.green + it.blue } + + assertThat(ladder.first()).isGreaterThan(0f) + assertThat(ladder).isInStrictOrder() + } + + @Test + fun `toPureBlack dims each container below its stock tone`() { + val black = dark.toPureBlack() + + val dimmed = listOf( + black.surfaceContainer.red < dark.surfaceContainer.red, + black.surfaceContainerHighest.red < dark.surfaceContainerHighest.red, + ) + + assertThat(dimmed).containsExactly(true, true) + } + + @Test + fun `toPureBlack scales a container's channels linearly`() { + val black = dark.toPureBlack() + + val ratio = black.surfaceContainer.red / dark.surfaceContainer.red + + assertThat(ratio).isWithin(0.02f).of(0.80f) + } + + @Test + fun `toPureBlack leaves accent and content roles alone`() { + val black = dark.toPureBlack() + + val kept = listOf(black.primary, black.onSurface, black.outline) + + assertThat(kept).containsExactly(dark.primary, dark.onSurface, dark.outline).inOrder() + } +}