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.
This commit is contained in:
makiolaj committed 2026-10-14 17:19:31 +02:00
1 parent 0b44d353d2
commit f1ded81c03
7 files changed
+164 -4

No files matched your search

@@ -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
@@ -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(
@@ -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()
}
}