Darker ground, the mark on sign-in, and a dark mode that was never readable

Matches ios!144's second pass, value for value.

**Darker.** #E9DDFB → #C6AFF2, a clear step down from the whisper it was. 🔴
That forced a second change: secondary text at #505C69 read 5.0:1 on the old
bottom stop and only 3.5:1 on the new one, below AA, and most of the
explanatory copy is set in it. #413A4D now — 8.4:1 top, 5.6:1 bottom.

🔴 **And dark mode never had a secondary text colour at all.** It used the light
one, which measures **2.41:1** on the dark ground. iOS has had a light/dark pair
since it was written; Android used one value for both and nothing noticed —
because a dim grey on a dark background looks deliberate. Found by measuring
while changing the palette, not by looking. #AFA4C9 now: 7.0:1 on the lightest
dark stop, 8.5:1 on the darkest. The palette test covers the dark grounds too,
so it cannot come back.

**The mark replaces the 'F' box** on the sign-in screen, which is also the
sign-up screen here — `registering` is a flag on one composable, so both get it.

**The launcher icon was cropped.** The adaptive foreground had the mark at 72%
of the canvas, and the guaranteed-visible area is a 66dp CIRCLE: a mark that is
widest at its vertical middle gets clipped at exactly the circle's widest point.
59% now, and it is measured rather than eyeballed — compositing the foreground
under both a circle and a squircle mask clips **0 artwork pixels**.

⚠️ `R` needed an explicit import: the namespace is `io.flamenet.messenger` and
this code is in `io.flamenet.messenger.app`, so the generated class is not in
scope by package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 11:21:33 -04:00
co-authored by Claude Opus 5
parent 563e9efed7
commit 49854ac222
13 changed files with 59 additions and 16 deletions
@@ -3,6 +3,8 @@ package io.flamenet.messenger.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -25,6 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import io.flamenet.messenger.R
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -349,15 +352,22 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
// The product's mark. This was an accent-coloured tile with a bold "F"
// in it — a placeholder from before there was a logo, and the first
// thing anybody sees. iOS carries the same image; see ios!144.
//
// ⚠️ Clipped to a rounded rectangle, and that is not decoration. The
// artwork's background is painted rather than transparent — the space
// behind the globe is part of the picture — so an unclipped mark is a
// hard-edged dark rectangle on a pale ground. Rounded, it matches the
// launcher icon the person just tapped.
Image(
painter = painterResource(R.drawable.brand_mark),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(Radius.medium))
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
.size(72.dp)
.clip(RoundedCornerShape(Radius.medium)),
)
Spacer(Modifier.height(Space.lg))
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(Space.sm))
@@ -73,10 +73,10 @@ object Palette {
// it, and every piece of body text sits on this: ink reads 18.6:1 at the
// top and 14.8:1 at the bottom, secondary text 8.7:1 and 6.9:1. A
// saturated purple would look bolder in a screenshot and fight both.
val GroundTop = Color(0xFFF8F5FF)
val GroundBottom = Color(0xFFE4D8FB)
val NightGroundTop = Color(0xFF241645)
val NightGroundBottom = Color(0xFF0D0718)
val GroundTop = Color(0xFFE9DDFB)
val GroundBottom = Color(0xFFC6AFF2)
val NightGroundTop = Color(0xFF1C1036)
val NightGroundBottom = Color(0xFF070410)
val Teal = Color(0xFF3BD5B2) // success and presence
val Pink = Color(0xFFED497C) // the kit's alert accent
@@ -86,7 +86,7 @@ object Palette {
// ⚠️ 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(0xFFF1EAFE) // the page ground, flat
val Mist = Color(0xFFD8C6F6) // the page ground, flat
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
val IncomingBubble = Color(0xFFF3F3F3)
// ⚠️ A blue-tinted grey rather than the flat #F3F3F3: an initials avatar
@@ -95,7 +95,22 @@ object Palette {
// ⚠️ 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 SecondaryText = Color(0xFF505C69)
// 🔴 **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.
val SecondaryText = Color(0xFF413A4D)
// 🔴 **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)
// Semantic
@@ -105,7 +120,7 @@ object Palette {
// Dark-mode grounds — these ARE the site's, since the site is dark.
val InkSurface = Color(0xFF121A2E) // --panel
val NightGround = Color(0xFF150C26) // --bg, the dark gradient's mid-tone
val NightGround = Color(0xFF110A20) // --bg, the dark gradient's mid-tone
}
/**
@@ -259,7 +274,7 @@ fun MessengerTheme(content: @Composable () -> Unit) {
surfaceVariant = Palette.InkSurface,
onBackground = Palette.Mist,
onSurface = Palette.Mist,
onSurfaceVariant = Palette.SecondaryText,
onSurfaceVariant = Palette.SecondaryTextDark,
error = Palette.Danger,
outline = Palette.Divider,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 93 KiB

@@ -71,6 +71,24 @@ class PaletteMatchesIOSTest {
"white on the gradient's light end is %.2f:1 — below AA for normal text".format(ratio))
}
/**
* 🔴 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.
*/
@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))
}
}
@Test
fun `body text clears AA on both ends of the page gradient`() {
for (ground in listOf(Palette.GroundTop, Palette.GroundBottom)) {