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:
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user