diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt index e411ad9..33b9e32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt @@ -62,18 +62,13 @@ fun ColumnScope.DisplaySection() { settings.largerType = on; larger = on } - Spacer(Modifier.height(Space.sm)) - Text("Appearance", fontSize = 14.sp) - Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) { - DisplaySettings.Appearance.entries.forEach { option -> - KitSecondaryButton( - text = if (option == appearance) "✓ ${option.label}" else option.label, - onClick = { settings.appearance = option; appearance = option }, - modifier = Modifier.weight(1f).padding(end = Space.xs), - tag = "display-appearance-${option.stored}", - ) - } - } + // ⚠️ The Light / Dark / Match-the-phone choice is gone. The palette has one + // set of colours now and the app is dark whatever the phone says, so the + // control changed nothing a person could see. A setting that is still there + // and no longer works is worse than one that was removed — somebody sets + // it, nothing happens, and they are left wondering which of the two is + // broken. `DisplaySettings.appearance` is left in place so an existing + // stored value is not a crash; nothing reads it. Spacer(Modifier.height(Space.xs)) // 🔴 Says the thing the label alone cannot: this hides them from you, not @@ -81,8 +76,7 @@ fun ColumnScope.DisplaySection() { Text( "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 " - + "something the app can do yet.\n\nAppearance takes effect next time the " - + "app starts.", + + "something the app can do yet.", fontSize = 12.sp, color = Palette.SecondaryText, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index b3af685..d2f0980 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -1,6 +1,5 @@ package io.flamenet.messenger.app -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.remember import androidx.compose.foundation.layout.PaddingValues 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. val Accent = Color(0xFF7A3FF2) // buttons, bubbles, links 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) // 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 // 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. - val GroundTop = Color(0xFFC0A4EC) - val GroundBottom = Color(0xFF5A8BCC) - val NightGroundTop = Color(0xFF170D2E) - val NightGroundBottom = Color(0xFF050F1E) + val GroundTop = Color(0xFF241650) + val GroundBottom = Color(0xFF0B2140) + // Kept as the same two colours: there is one theme now. + val NightGroundTop = Color(0xFF241650) + val NightGroundBottom = Color(0xFF0B2140) - val Teal = Color(0xFF3BD5B2) // success and presence - val Pink = Color(0xFFED497C) // the kit's alert accent + val Teal = Color(0xFF4BE3C0) // success and presence + val Pink = Color(0xFFFF6B92) // the kit's alert accent // 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 // system bar tint). This is the gradient's mid-tone, so the two never read // as different surfaces. - val Mist = Color(0xFF8D97DC) // the page ground, flat - val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands - val IncomingBubble = Color(0xFFF3F3F3) + val Mist = Color(0xFF18183F) // the page ground, flat + val SurfaceAlt = Color(0xFF3B3A6E) // received bubbles, pills, bands + val IncomingBubble = Color(0xFF3B3A6E) // ⚠️ 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 // shade apart do not. - // ⚠️ Lavender, not the old blue wash. A blue-tinted circle on a purple - // ground reads as a bug rather than as a second accent. - val AvatarFill = Color(0xFFEDE6FC) + val AvatarFill = Color(0xFF3B3974) // 🔴 **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 // 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. - // 🔴 Close to the floor: it has darkened at every step of the ground and - // is now very near Ink (#090710), so the difference between primary and - // secondary text is nearly gone. Contrast is satisfied; hierarchy is not, - // and no test catches that. - val SecondaryText = Color(0xFF1B1822) - // 🔴 **Dark mode had no secondary text colour at all**, and used the light - // one: #505C69 on the dark ground measures **2.41:1**, far below AA, and - // most of the explanatory copy on these screens is set in it. iOS has had - // a dark variant since it was written (`secondaryText` is a light/dark - // pair); Android used one value for both grounds and nothing noticed. - // - // 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) + // 🔴 **A mid-tone again, and that is the point of the flip.** On the light + // ground this had darkened at every step until it was #1B1822 — almost the + // same colour as Ink — so the hierarchy between primary and secondary text + // had collapsed while the contrast numbers still passed. On a dark ground + // it sits in the middle where it belongs: 7.3:1 on the ground, 5.7:1 on a + // card, 4.7:1 on a field. + val SecondaryText = Color(0xFFA8AEC9) + // One theme now, so one secondary. Kept as an alias so the dark + // colour scheme below still reads clearly. + val SecondaryTextDark = Color(0xFFA8AEC9) + val Divider = Color(0x2EA8AEC9) // Semantic - val Success = Color(0xFF3BD5B2) - val Warning = Color(0xFFB8860B) - val Danger = Color(0xFFED497C) + val Success = Color(0xFF4BE3C0) + val Warning = Color(0xFFE9B44C) + val Danger = Color(0xFFFF6B92) // Dark-mode grounds — these ARE the site's, since the site is dark. - val InkSurface = Color(0xFF121A2E) // --panel - val NightGround = Color(0xFF0E0E26) // --bg, the dark gradient's mid-tone + val InkSurface = Color(0xFF302F5C) // --panel + val NightGround = Color(0xFF18183F) // --bg, the gradient's mid-tone } /** @@ -142,10 +134,11 @@ object Palette { * reads as the background flickering. */ @Composable -fun groundBrush(): Brush = Brush.verticalGradient( - if (isSystemInDarkTheme()) listOf(Palette.NightGroundTop, Palette.NightGroundBottom) - else listOf(Palette.GroundTop, Palette.GroundBottom) -) +fun groundBrush(): Brush = + // One theme, so no branch. The Night* pair is kept pointing at the same two + // 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. */ fun accentBrush(): Brush = @@ -263,53 +256,31 @@ fun Modifier.flamenetCard( @Composable fun MessengerTheme(content: @Composable () -> Unit) { - // ⚠️ Read here rather than passed in, so every entry point picks it up — - // the app has more than one, and a theme applied at only some of them is - // the kind of half-wiring that looks fine until somebody opens a call - // screen from a notification. - val context = androidx.compose.ui.platform.LocalContext.current - val preference = remember { DisplaySettings(context).appearance } - val dark = when (preference) { - DisplaySettings.Appearance.LIGHT -> false - DisplaySettings.Appearance.DARK -> true - DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme() - } + // 🔴 **One theme, and it is dark.** There used to be a light scheme, a dark + // one, and a preference choosing between them. The palette has a single set + // of colours now, so the light branch would have drawn light-mode Material + // chrome under dark-mode artwork — and the preference would have been a + // control that changes nothing a person can see except the parts that + // break. + // + // ⚠️ `onBackground`/`onSurface` are `Ink`, not `Mist`. `Mist` is the + // ground now; using it as a text colour on that ground is dark-on-dark, + // which is exactly the kind of swap that survives a rename. MaterialTheme( - colorScheme = if (dark) { - 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, - 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, + colorScheme = darkColorScheme( + primary = Palette.Accent, + onPrimary = Color.White, + secondary = Palette.AccentDeep, + background = Palette.Mist, + surface = Palette.InkSurface, + surfaceVariant = Palette.SurfaceAlt, + onBackground = Palette.Ink, + onSurface = Palette.Ink, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, + ), + typography = MaterialTheme.typography, content = content, ) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt index 5568b01..284a542 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt @@ -72,34 +72,49 @@ class PaletteMatchesIOSTest { } /** - * 🔴 The dark grounds, which had NO secondary-text colour of their own until - * 2026-09-07: the light `#505C69` was used on both, and measured 2.41:1 on - * the dark ground. Nothing noticed, because a dim grey on a dark background - * looks deliberate. + * 🔴 **One theme now, so one set of grounds — and every surface a person + * reads text on has to clear AA, not just the page.** + * + * 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 - fun `body text clears AA on both ends of the DARK gradient`() { - for (ground in listOf(Palette.NightGroundTop, Palette.NightGroundBottom, - Palette.InkSurface)) { - val ink = contrast(ground, Palette.Mist) - assertTrue(ink >= 4.5, "dark-mode ink on ${androidHex(ground)} is %.2f:1".format(ink)) - val muted = contrast(ground, Palette.SecondaryTextDark) - assertTrue(muted >= 4.5, - "dark-mode secondary text on ${androidHex(ground)} is %.2f:1".format(muted)) + fun `text clears AA on every surface it is read on`() { + val surfaces = listOf( + "ground top" to Palette.GroundTop, + "ground bottom" to Palette.GroundBottom, + "card" to Palette.InkSurface, + "field and incoming bubble" to Palette.SurfaceAlt, + "avatar" to Palette.AvatarFill, + ) + 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 - fun `body text clears AA on both ends of the page gradient`() { - for (ground in listOf(Palette.GroundTop, Palette.GroundBottom)) { - val ink = contrast(ground, Palette.Ink) - assertTrue(ink >= 4.5, "ink on ${androidHex(ground)} is %.2f:1".format(ink)) - // ⚠️ Secondary text is the one that fails first — it is the colour - // 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)) - } + fun `cards and fields lift off what is behind them`() { + val cardLift = contrast(Palette.InkSurface, Palette.GroundBottom) + assertTrue(cardLift >= 1.15, "a card lifts off the ground by only %.2f:1".format(cardLift)) + val fieldLift = contrast(Palette.SurfaceAlt, Palette.InkSurface) + assertTrue(fieldLift >= 1.15, "a field lifts off its card by only %.2f:1".format(fieldLift)) } private fun channel(c: Float): Double {