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:
co-authored by
Claude Opus 5
parent
37143c5b6e
commit
c688ed21b4
@@ -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.AccentBright,
|
|
||||||
onPrimary = Color.White,
|
|
||||||
secondary = Palette.Accent,
|
|
||||||
background = Palette.NightGround,
|
|
||||||
surface = Palette.InkSurface,
|
|
||||||
surfaceVariant = Palette.InkSurface,
|
|
||||||
onBackground = Palette.Mist,
|
|
||||||
onSurface = Palette.Mist,
|
|
||||||
onSurfaceVariant = Palette.SecondaryTextDark,
|
|
||||||
error = Palette.Danger,
|
|
||||||
outline = Palette.Divider,
|
|
||||||
)
|
|
||||||
} 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,
|
primary = Palette.Accent,
|
||||||
onPrimary = Color.White,
|
onPrimary = Color.White,
|
||||||
secondary = Palette.AccentBright,
|
secondary = Palette.AccentDeep,
|
||||||
background = Palette.Mist,
|
background = Palette.Mist,
|
||||||
surface = Color.White,
|
surface = Palette.InkSurface,
|
||||||
surfaceVariant = Palette.SurfaceAlt,
|
surfaceVariant = Palette.SurfaceAlt,
|
||||||
onBackground = Palette.Ink,
|
onBackground = Palette.Ink,
|
||||||
onSurface = Palette.Ink,
|
onSurface = Palette.Ink,
|
||||||
onSurfaceVariant = Palette.SecondaryText,
|
onSurfaceVariant = Palette.SecondaryText,
|
||||||
error = Palette.Danger,
|
error = Palette.Danger,
|
||||||
outline = Palette.Divider,
|
outline = Palette.Divider,
|
||||||
)
|
),
|
||||||
},
|
typography = MaterialTheme.typography,
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user