Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,3 +641,31 @@ Two rules fall out of this:
2. Previews must resolve through the same code path as the scheme builder. If
a swatch has its own S/L constants, it will drift from what the theme
actually renders.

## 28. Derive Material 3 surface ramps from luminance, never from a background's raw HSL

When building a custom `ColorScheme` around a user-chosen background colour,
the "is this a light or dark surface?" decision has to be made from perceived
**luminance** (`Color.luminance()`), but the neutral tones themselves must not
reuse the background's own HSL saturation or lightness. Those two measures
diverge badly for vivid mid-lightness hues: pure yellow `#F3FF00` has luminance
~0.93 (clearly "light") yet HSL lightness only 0.5, so a `surfaceVariant`
computed as `hsl(bgHue, bgSat.coerceIn(...), bgL - 0.10)` collapses to
`hsl(63, 0.20, 0.40)` — a dark olive that painted every card muddy while the
page background stayed bright yellow.

The fix, and the general rule:

1. Derive all neutral/surface roles from the background **hue only**, at a
near-zero fixed saturation (~0.04), so a saturated background can never tint
surfaces muddy.
2. Anchor surface lightness to **absolute per-mode values** (a monotonic ramp
like 0.99 down to 0.87 in light mode, 0.06 up to 0.24 in dark mode), not to
the background's own lightness.
3. Pick `onBackground` / `onSurface` by contrast (reuse `contrastingOn`) so text
stays legible on any pick.
4. Set the entire `surfaceContainer*` family plus `surfaceDim` / `surfaceBright`
explicitly. If you leave them out of `lightColorScheme(...)` /
`darkColorScheme(...)`, Material 3 fills them with a fixed default neutral
(lavender in light, charcoal in dark) that clashes with the custom
background in menus, bottom sheets, and the navigation bar.
62 changes: 47 additions & 15 deletions app/src/main/java/com/mapgie/dash/ui/theme/Color.kt
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,11 @@ private fun contrastingOn(color: Color): Color =
*
* The picked colours are applied **verbatim** to the primary, secondary, and
* tertiary roles in both light and dark mode; on-colours are chosen by relative
* luminance so text stays legible whatever the user picks. Containers and
* neutrals are derived from the picked hues with mode-appropriate lightness.
* luminance so text stays legible whatever the user picks. Containers are
* derived from the picked hues with mode-appropriate lightness. Neutrals and
* the full `surfaceContainer*` ramp are derived from the background's *hue
* only*, at a near-zero fixed saturation and absolute per-mode lightness
* values, so a vivid background can never turn the neutral surfaces muddy.
*
* [backgroundArgb] overrides background/surface for the current mode (0 keeps
* the background derived from the primary hue).
Expand All @@ -506,17 +509,32 @@ fun buildCustomColorScheme(
}
val bg = FloatArray(3).also { ColorUtils.colorToHSL(background.toArgb(), it) }
val bgIsLight = background.luminance() > 0.35f
val onBackground = if (bgIsLight) Color.hsl(bg[0], 0.10f, 0.10f) else Color.hsl(bg[0], 0.10f, 0.88f)
val surface = if (darkTheme) Color.hsl(bg[0], bg[1], (bg[2] + 0.02f).coerceIn(0f, 1f)) else background
val surfaceVariant = if (bgIsLight)
Color.hsl(bg[0], bg[1].coerceIn(0.05f, 0.20f), (bg[2] - 0.10f).coerceIn(0f, 1f))
else
Color.hsl(bg[0], bg[1].coerceAtMost(0.15f), (bg[2] + 0.15f).coerceIn(0f, 1f))
val onSurfaceVariant = if (bgIsLight) Color.hsl(bg[0], 0.15f, 0.25f) else Color.hsl(bg[0], 0.15f, 0.75f)
val outline = if (bgIsLight) Color.hsl(bg[0], 0.10f, 0.45f) else Color.hsl(bg[0], 0.10f, 0.55f)
val outlineVariant = if (bgIsLight) Color.hsl(bg[0], 0.15f, 0.75f) else Color.hsl(bg[0], 0.15f, 0.25f)
val inverseSurface = if (bgIsLight) Color.hsl(bg[0], 0.10f, 0.18f) else Color.hsl(bg[0], 0.10f, 0.88f)
val inverseOnSurface = if (bgIsLight) Color.hsl(bg[0], 0.10f, 0.93f) else Color.hsl(bg[0], 0.10f, 0.10f)

// Neutrals are derived from the background's hue only, at a near-zero fixed
// saturation and an absolute per-mode lightness. This is deliberately
// independent of the background's own HSL saturation/lightness (bg[1]/bg[2]):
// for a vivid mid-lightness hue like yellow those diverge badly from
// luminance-based "is this light or dark" and used to produce olive mud.
val bgHue = bg[0]
fun neutral(l: Float) = Color.hsl(bgHue, 0.04f, l.coerceIn(0f, 1f))

val surface = background
val onBackground = contrastingOn(background)
val onSurface = contrastingOn(surface)

val surfaceContainerLowest = if (bgIsLight) neutral(0.99f) else neutral(0.06f)
val surfaceContainerLow = if (bgIsLight) neutral(0.97f) else neutral(0.10f)
val surfaceContainer = if (bgIsLight) neutral(0.95f) else neutral(0.12f)
val surfaceContainerHigh = if (bgIsLight) neutral(0.93f) else neutral(0.15f)
val surfaceContainerHighest = if (bgIsLight) neutral(0.91f) else neutral(0.17f)
val surfaceVariant = if (bgIsLight) neutral(0.90f) else neutral(0.17f)
val surfaceBright = if (bgIsLight) neutral(0.99f) else neutral(0.24f)
val surfaceDim = if (bgIsLight) neutral(0.87f) else neutral(0.06f)
val onSurfaceVariant = if (bgIsLight) neutral(0.30f) else neutral(0.80f)
val outline = if (bgIsLight) neutral(0.50f) else neutral(0.55f)
val outlineVariant = if (bgIsLight) neutral(0.80f) else neutral(0.30f)
val inverseSurface = if (bgIsLight) neutral(0.20f) else neutral(0.90f)
val inverseOnSurface = if (bgIsLight) neutral(0.95f) else neutral(0.10f)

return if (!darkTheme) {
lightColorScheme(
Expand All @@ -539,9 +557,16 @@ fun buildCustomColorScheme(
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onBackground,
onSurface = onSurface,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurfaceVariant,
surfaceContainerLowest = surfaceContainerLowest,
surfaceContainerLow = surfaceContainerLow,
surfaceContainer = surfaceContainer,
surfaceContainerHigh = surfaceContainerHigh,
surfaceContainerHighest = surfaceContainerHighest,
surfaceBright = surfaceBright,
surfaceDim = surfaceDim,
outline = outline,
inverseOnSurface = inverseOnSurface,
inverseSurface = inverseSurface,
Expand Down Expand Up @@ -571,9 +596,16 @@ fun buildCustomColorScheme(
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onBackground,
onSurface = onSurface,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurfaceVariant,
surfaceContainerLowest = surfaceContainerLowest,
surfaceContainerLow = surfaceContainerLow,
surfaceContainer = surfaceContainer,
surfaceContainerHigh = surfaceContainerHigh,
surfaceContainerHighest = surfaceContainerHighest,
surfaceBright = surfaceBright,
surfaceDim = surfaceDim,
outline = outline,
inverseOnSurface = inverseOnSurface,
inverseSurface = inverseSurface,
Expand Down
4 changes: 4 additions & 0 deletions changelog/unreleased/custom-theme-surfaces.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"bump": "patch",
"fixed": ["Custom themes with vivid backgrounds no longer render muddy, low-contrast card surfaces; neutral surface roles are now derived cleanly and the full Material 3 surface-container palette is set."]
}
Loading