One theme, and it is dark — matches ios!144

Same values, same commit, as the file at the top of both palettes has always
asked for.

    ground       #241650 purple -> #0B2140 blue, top down
    card         #302F5C   lifts off the ground at 1.29:1
    field/bubble #3B3A6E   lifts off a card at 1.20:1
    ink          #EDEFF9 · secondary #A8AEC9 · links #B99BFF

🔴 `onBackground`/`onSurface` are `Ink`, not `Mist`. The dark scheme used to set
them to `Mist`, which was the near-white page ground; `Mist` is the DARK ground
now, so leaving that alone would have been dark text on a dark background. That
is the exact shape of a swap that survives a rename, and it would have looked
like the app failing to draw rather than like a colour being wrong.

🔴 `MessengerTheme` no longer branches. There was a light scheme, a dark scheme,
and a stored preference choosing between them; with one palette the light branch
would draw light-mode Material chrome under dark artwork. `DisplaySettings.appearance`
is left in place so an existing stored value is not a crash — nothing reads it —
and the picker is gone from the Display section, on both platforms.

⚠️ The palette test caught one on the way in: secondary text on the avatar fill
was 4.48:1, two hundredths under. #3E3C7A became #3B3974.

The test now checks every surface text is read on — ground, card, field, avatar
— and that cards and fields visibly lift off what is behind them. A gradient
that passes and a bubble that does not is still an unreadable app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 12:26:42 -04:00
co-authored by Claude Opus 5
parent 37143c5b6e
commit c688ed21b4
3 changed files with 103 additions and 123 deletions
@@ -62,18 +62,13 @@ fun ColumnScope.DisplaySection() {
settings.largerType = on; larger = on settings.largerType = on; larger = on
} }
Spacer(Modifier.height(Space.sm)) // ⚠️ The Light / Dark / Match-the-phone choice is gone. The palette has one
Text("Appearance", fontSize = 14.sp) // set of colours now and the app is dark whatever the phone says, so the
Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) { // control changed nothing a person could see. A setting that is still there
DisplaySettings.Appearance.entries.forEach { option -> // and no longer works is worse than one that was removed — somebody sets
KitSecondaryButton( // it, nothing happens, and they are left wondering which of the two is
text = if (option == appearance) "✓ ${option.label}" else option.label, // broken. `DisplaySettings.appearance` is left in place so an existing
onClick = { settings.appearance = option; appearance = option }, // stored value is not a crash; nothing reads it.
modifier = Modifier.weight(1f).padding(end = Space.xs),
tag = "display-appearance-${option.stored}",
)
}
}
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
// 🔴 Says the thing the label alone cannot: this hides them from you, not // 🔴 Says the thing the label alone cannot: this hides them from you, not
@@ -81,8 +76,7 @@ fun ColumnScope.DisplaySection() {
Text( Text(
"These change what this phone shows you. Your contacts are still told when " "These change what this phone shows you. Your contacts are still told when "
+ "you are online whichever way this is set — hiding yourself is not " + "you are online whichever way this is set — hiding yourself is not "
+ "something the app can do yet.\n\nAppearance takes effect next time the " + "something the app can do yet.",
+ "app starts.",
fontSize = 12.sp, fontSize = 12.sp,
color = Palette.SecondaryText, color = Palette.SecondaryText,
) )
@@ -1,6 +1,5 @@
package io.flamenet.messenger.app package io.flamenet.messenger.app
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -54,7 +53,7 @@ object Palette {
// it. A constant called `Ember` holding blue is how a palette drifts back. // it. A constant called `Ember` holding blue is how a palette drifts back.
val Accent = Color(0xFF7A3FF2) // buttons, bubbles, links val Accent = Color(0xFF7A3FF2) // buttons, bubbles, links
val AccentBright = Color(0xFFB99BFF) // links and small text on the dark ground val AccentBright = Color(0xFFB99BFF) // links and small text on the dark ground
val AccentDeep = Color(0xFF5B27C7) // links and small text on the light ground val AccentDeep = Color(0xFFB99BFF) // links and small text
val AccentSoft = Color(0x1A7A3FF2) val AccentSoft = Color(0x1A7A3FF2)
// The gradient. // The gradient.
@@ -80,58 +79,51 @@ object Palette {
// the top and 8.4:1 at the bottom, secondary text 8.1:1 and 5.5:1 — the // the top and 8.4:1 at the bottom, secondary text 8.1:1 and 5.5:1 — the
// bottom stop binds, and it is where most of the explanatory copy sits. // bottom stop binds, and it is where most of the explanatory copy sits.
// White cards lift off the bottom at 2.4:1, which keeps a card a card. // White cards lift off the bottom at 2.4:1, which keeps a card a card.
val GroundTop = Color(0xFFC0A4EC) val GroundTop = Color(0xFF241650)
val GroundBottom = Color(0xFF5A8BCC) val GroundBottom = Color(0xFF0B2140)
val NightGroundTop = Color(0xFF170D2E) // Kept as the same two colours: there is one theme now.
val NightGroundBottom = Color(0xFF050F1E) val NightGroundTop = Color(0xFF241650)
val NightGroundBottom = Color(0xFF0B2140)
val Teal = Color(0xFF3BD5B2) // success and presence val Teal = Color(0xFF4BE3C0) // success and presence
val Pink = Color(0xFFED497C) // the kit's alert accent val Pink = Color(0xFFFF6B92) // the kit's alert accent
// Neutrals, all from the kit. // Neutrals, all from the kit.
val Ink = Color(0xFF090710) val Ink = Color(0xFFEDEFF9)
// ⚠️ Still needed where a gradient cannot go (a single Color parameter, a // ⚠️ Still needed where a gradient cannot go (a single Color parameter, a
// system bar tint). This is the gradient's mid-tone, so the two never read // system bar tint). This is the gradient's mid-tone, so the two never read
// as different surfaces. // as different surfaces.
val Mist = Color(0xFF8D97DC) // the page ground, flat val Mist = Color(0xFF18183F) // the page ground, flat
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands val SurfaceAlt = Color(0xFF3B3A6E) // received bubbles, pills, bands
val IncomingBubble = Color(0xFFF3F3F3) val IncomingBubble = Color(0xFF3B3A6E)
// ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar // ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar
// needs to read as a circle against the surface behind it, and two greys a // needs to read as a circle against the surface behind it, and two greys a
// shade apart do not. // shade apart do not.
// ⚠️ Lavender, not the old blue wash. A blue-tinted circle on a purple val AvatarFill = Color(0xFF3B3974)
// ground reads as a bug rather than as a second accent.
val AvatarFill = Color(0xFFEDE6FC)
// 🔴 **Darkened with the ground, not left behind it.** #505C69 read 5.0:1 // 🔴 **Darkened with the ground, not left behind it.** #505C69 read 5.0:1
// on the old bottom stop and only 3.5:1 on the darker one — below AA, and // on the old bottom stop and only 3.5:1 on the darker one — below AA, and
// most of the explanatory copy on these screens is set in it. Measured now: // most of the explanatory copy on these screens is set in it. Measured now:
// 8.4:1 at the top of the gradient, 5.6:1 at the bottom. // 8.4:1 at the top of the gradient, 5.6:1 at the bottom.
// 🔴 Close to the floor: it has darkened at every step of the ground and // 🔴 **A mid-tone again, and that is the point of the flip.** On the light
// is now very near Ink (#090710), so the difference between primary and // ground this had darkened at every step until it was #1B1822 — almost the
// secondary text is nearly gone. Contrast is satisfied; hierarchy is not, // same colour as Ink — so the hierarchy between primary and secondary text
// and no test catches that. // had collapsed while the contrast numbers still passed. On a dark ground
val SecondaryText = Color(0xFF1B1822) // it sits in the middle where it belongs: 7.3:1 on the ground, 5.7:1 on a
// 🔴 **Dark mode had no secondary text colour at all**, and used the light // card, 4.7:1 on a field.
// one: #505C69 on the dark ground measures **2.41:1**, far below AA, and val SecondaryText = Color(0xFFA8AEC9)
// most of the explanatory copy on these screens is set in it. iOS has had // One theme now, so one secondary. Kept as an alias so the dark
// a dark variant since it was written (`secondaryText` is a light/dark // colour scheme below still reads clearly.
// pair); Android used one value for both grounds and nothing noticed. val SecondaryTextDark = Color(0xFFA8AEC9)
// val Divider = Color(0x2EA8AEC9)
// Found by measuring while changing the palette, not by looking — a dim
// grey on a dark ground looks deliberate, which is exactly why it survived.
// #AFA4C9 is the same idea in the new hue: 7.04:1 on the lightest dark
// stop, 8.46:1 on the darkest.
val SecondaryTextDark = Color(0xFFAFA4C9)
val Divider = Color(0x1A505C69)
// Semantic // Semantic
val Success = Color(0xFF3BD5B2) val Success = Color(0xFF4BE3C0)
val Warning = Color(0xFFB8860B) val Warning = Color(0xFFE9B44C)
val Danger = Color(0xFFED497C) val Danger = Color(0xFFFF6B92)
// Dark-mode grounds — these ARE the site's, since the site is dark. // Dark-mode grounds — these ARE the site's, since the site is dark.
val InkSurface = Color(0xFF121A2E) // --panel val InkSurface = Color(0xFF302F5C) // --panel
val NightGround = Color(0xFF0E0E26) // --bg, the dark gradient's mid-tone val NightGround = Color(0xFF18183F) // --bg, the gradient's mid-tone
} }
/** /**
@@ -142,10 +134,11 @@ object Palette {
* reads as the background flickering. * reads as the background flickering.
*/ */
@Composable @Composable
fun groundBrush(): Brush = Brush.verticalGradient( fun groundBrush(): Brush =
if (isSystemInDarkTheme()) listOf(Palette.NightGroundTop, Palette.NightGroundBottom) // One theme, so no branch. The Night* pair is kept pointing at the same two
else listOf(Palette.GroundTop, Palette.GroundBottom) // colours rather than deleted, because several screens name it directly and
) // a rename across both apps is a separate change.
Brush.verticalGradient(listOf(Palette.GroundTop, Palette.GroundBottom))
/** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */ /** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */
fun accentBrush(): Brush = fun accentBrush(): Brush =
@@ -263,53 +256,31 @@ fun Modifier.flamenetCard(
@Composable @Composable
fun MessengerTheme(content: @Composable () -> Unit) { fun MessengerTheme(content: @Composable () -> Unit) {
// ⚠️ Read here rather than passed in, so every entry point picks it up — // 🔴 **One theme, and it is dark.** There used to be a light scheme, a dark
// the app has more than one, and a theme applied at only some of them is // one, and a preference choosing between them. The palette has a single set
// the kind of half-wiring that looks fine until somebody opens a call // of colours now, so the light branch would have drawn light-mode Material
// screen from a notification. // chrome under dark-mode artwork — and the preference would have been a
val context = androidx.compose.ui.platform.LocalContext.current // control that changes nothing a person can see except the parts that
val preference = remember { DisplaySettings(context).appearance } // break.
val dark = when (preference) { //
DisplaySettings.Appearance.LIGHT -> false // ⚠️ `onBackground`/`onSurface` are `Ink`, not `Mist`. `Mist` is the
DisplaySettings.Appearance.DARK -> true // ground now; using it as a text colour on that ground is dark-on-dark,
DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme() // which is exactly the kind of swap that survives a rename.
}
MaterialTheme( MaterialTheme(
colorScheme = if (dark) { colorScheme = darkColorScheme(
darkColorScheme( primary = Palette.Accent,
primary = Palette.AccentBright, onPrimary = Color.White,
onPrimary = Color.White, secondary = Palette.AccentDeep,
secondary = Palette.Accent, background = Palette.Mist,
background = Palette.NightGround, surface = Palette.InkSurface,
surface = Palette.InkSurface, surfaceVariant = Palette.SurfaceAlt,
surfaceVariant = Palette.InkSurface, onBackground = Palette.Ink,
onBackground = Palette.Mist, onSurface = Palette.Ink,
onSurface = Palette.Mist, onSurfaceVariant = Palette.SecondaryText,
onSurfaceVariant = Palette.SecondaryTextDark, error = Palette.Danger,
error = Palette.Danger, outline = Palette.Divider,
outline = Palette.Divider, ),
) typography = MaterialTheme.typography,
} else {
lightColorScheme(
// ⚠️ The kit's #2298FF on white is about 3.3:1 — fine for a
// bold button label, not for small text. Anything small and
// blue must use a darker step; the two places that do are the
// section labels and the timestamps, and both are grey.
primary = Palette.Accent,
onPrimary = Color.White,
secondary = Palette.AccentBright,
background = Palette.Mist,
surface = Color.White,
surfaceVariant = Palette.SurfaceAlt,
onBackground = Palette.Ink,
onSurface = Palette.Ink,
onSurfaceVariant = Palette.SecondaryText,
error = Palette.Danger,
outline = Palette.Divider,
)
},
shapes = FlamenetShapes,
typography = FlamenetTypography,
content = content, content = content,
) )
} }
@@ -72,34 +72,49 @@ class PaletteMatchesIOSTest {
} }
/** /**
* 🔴 The dark grounds, which had NO secondary-text colour of their own until * 🔴 **One theme now, so one set of grounds — and every surface a person
* 2026-09-07: the light `#505C69` was used on both, and measured 2.41:1 on * reads text on has to clear AA, not just the page.**
* the dark ground. Nothing noticed, because a dim grey on a dark background *
* looks deliberate. * The light theme is gone (2026-09-07). It went because the ground kept
* being asked to go darker and `secondaryText` had to darken with it at
* every step, until it was #1B1822 against ink at #090710: contrast passing
* while the hierarchy between primary and secondary text quietly vanished.
* A dark ground puts secondary back in the middle where it belongs.
*
* ⚠️ Cards and field fills are checked as well as the page. A gradient that
* passes and a bubble that does not is still an unreadable app, and the
* bubble is where the messages are.
*/ */
@Test @Test
fun `body text clears AA on both ends of the DARK gradient`() { fun `text clears AA on every surface it is read on`() {
for (ground in listOf(Palette.NightGroundTop, Palette.NightGroundBottom, val surfaces = listOf(
Palette.InkSurface)) { "ground top" to Palette.GroundTop,
val ink = contrast(ground, Palette.Mist) "ground bottom" to Palette.GroundBottom,
assertTrue(ink >= 4.5, "dark-mode ink on ${androidHex(ground)} is %.2f:1".format(ink)) "card" to Palette.InkSurface,
val muted = contrast(ground, Palette.SecondaryTextDark) "field and incoming bubble" to Palette.SurfaceAlt,
assertTrue(muted >= 4.5, "avatar" to Palette.AvatarFill,
"dark-mode secondary text on ${androidHex(ground)} is %.2f:1".format(muted)) )
for ((name, ground) in surfaces) {
val ink = contrast(ground, Palette.Ink)
assertTrue(ink >= 4.5, "ink on the $name is %.2f:1".format(ink))
// Secondary is the one that fails first — most of the explanatory
// copy on these screens is set in it.
val muted = contrast(ground, Palette.SecondaryText)
assertTrue(muted >= 4.5, "secondary text on the $name is %.2f:1".format(muted))
} }
} }
/**
* ⚠️ A card has to be visibly a card. Contrast rules say nothing about
* this, and a surface that matches its ground is a layout people cannot
* parse even though every text ratio passes.
*/
@Test @Test
fun `body text clears AA on both ends of the page gradient`() { fun `cards and fields lift off what is behind them`() {
for (ground in listOf(Palette.GroundTop, Palette.GroundBottom)) { val cardLift = contrast(Palette.InkSurface, Palette.GroundBottom)
val ink = contrast(ground, Palette.Ink) assertTrue(cardLift >= 1.15, "a card lifts off the ground by only %.2f:1".format(cardLift))
assertTrue(ink >= 4.5, "ink on ${androidHex(ground)} is %.2f:1".format(ink)) val fieldLift = contrast(Palette.SurfaceAlt, Palette.InkSurface)
// ⚠️ Secondary text is the one that fails first — it is the colour assertTrue(fieldLift >= 1.15, "a field lifts off its card by only %.2f:1".format(fieldLift))
// most of the explanatory copy on these screens is set in.
val muted = contrast(ground, Palette.SecondaryText)
assertTrue(muted >= 4.5,
"secondary text on ${androidHex(ground)} is %.2f:1".format(muted))
}
} }
private fun channel(c: Float): Double { private fun channel(c: Float): Double {