The same purple ground, and a test that checks it stays the same

iOS moved to a purple gradient in ios!144. This is the other half, in the same
values, plus the thing that has been missing since both files were written.

🔴 **`Theme.swift` has always opened with "one palette, two apps — change a
value here and change it there in the same commit", and nothing enforced it.**
Its own comment records what happened last time: the file claimed the two were
matched while iOS was indigo and Android was ember. A promise in a comment is a
promise nobody keeps, because nothing goes red when it is broken.

`PaletteMatchesIOSTest` reads `../flamenetmessenger/Sources/Theme/Theme.swift`
and compares the hex values for the accent, both gradient stops, and the deep
accent. ⚠️ It SKIPS when the iOS checkout is not beside this one — a red test
on a machine that only has Android would train people to ignore it.

It also measures contrast, because "still readable" is the actual requirement:

- white on the button gradient's LIGHT end must clear 4.5:1 (it is 5.50:1)
- ink and secondary text must clear 4.5:1 on BOTH ends of the page gradient
  (18.6/14.8 and 8.7/6.9)

Secondary text is the one that fails first — most of the explanatory copy on
these screens is set in it.

Falsified both ways: putting the old blue back in `Accent` fails with
"primary differs: iOS says 7A3FF2, Android says 2298FF", and lightening the
gradient's top stop fails with "white on the gradient's light end is 2.28:1".

Mechanics worth knowing: the ground is painted ONCE on the root `Surface`, with
`Scaffold(containerColor = Transparent)` under it — Scaffold's default container
colour paints straight over a gradient, and a gradient applied per screen
restarts at every navigation, which reads as the background flickering. The
button's gradient is on the modifier with a transparent `Button`, clipped to the
same shape or the corners show square.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 11:09:04 -04:00
co-authored by Claude Opus 5
parent 832cbf9214
commit 563e9efed7
4 changed files with 185 additions and 8 deletions
@@ -1,6 +1,10 @@
package io.flamenet.messenger.app package io.flamenet.messenger.app
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -52,13 +56,31 @@ fun KitPrimaryButton(
enabled: Boolean = true, enabled: Boolean = true,
tag: String? = null, tag: String? = null,
) { ) {
// ⚠️ The gradient goes on the MODIFIER and the Button is transparent.
// Compose's Button takes a single container colour, so a gradient painted
// inside the content would sit above the ripple and below nothing — it has
// to be the surface itself, clipped to the same shape or the corners show
// square.
//
// ⚠️ Disabled fades the whole gradient rather than swapping a flat colour
// in: a disabled button that changes shape as well as weight reads as a
// different control.
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
shape = RoundedCornerShape(KitButton.radius), shape = RoundedCornerShape(KitButton.radius),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
contentColor = Color.White,
disabledContentColor = Color.White,
),
modifier = modifier modifier = modifier
.height(KitButton.height) .height(KitButton.height)
.clip(RoundedCornerShape(KitButton.radius))
.background(accentBrush(), RoundedCornerShape(KitButton.radius))
.alpha(if (enabled) 1f else 0.35f)
.then(if (tag != null) Modifier.testTag(tag) else Modifier), .then(if (tag != null) Modifier.testTag(tag) else Modifier),
) { ) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium) Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
@@ -109,7 +109,14 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
setContent { setContent {
MessengerTheme { MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { // The purple ground, painted once for the whole app. The
// Surface itself is transparent so the brush shows through;
// Scaffold below is transparent for the same reason, or its
// default container colour paints straight over it.
Surface(
color = androidx.compose.ui.graphics.Color.Transparent,
modifier = Modifier.fillMaxSize().background(groundBrush()),
) {
// 🔴 **The gate is around Root, not inside it.** Drawn over // 🔴 **The gate is around Root, not inside it.** Drawn over
// the top, the message list still renders underneath for a // the top, the message list still renders underneath for a
// frame — and a frame is all a screenshot or a shoulder // frame — and a frame is all a screenshot or a shoulder
@@ -179,7 +186,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
@Composable @Composable
private fun Root(model: MessengerViewModel = viewModel()) { private fun Root(model: MessengerViewModel = viewModel()) {
val phase by model.phase.collectAsStateWithLifecycle() val phase by model.phase.collectAsStateWithLifecycle()
Scaffold { padding -> Scaffold(containerColor = androidx.compose.ui.graphics.Color.Transparent) { padding ->
val frame = Modifier.fillMaxSize().padding(padding).padding(20.dp) val frame = Modifier.fillMaxSize().padding(padding).padding(20.dp)
when (val current = phase) { when (val current = phase) {
// Scrollable, because a keyboard over a form needs somewhere to go. // Scrollable, because a keyboard over a form needs somewhere to go.
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -51,22 +52,49 @@ object Palette {
// brand when this app was written and is not any more — the public pages // brand when this app was written and is not any more — the public pages
// carry none of it — so the names go with the colour rather than outliving // carry none of it — so the names go with the colour rather than outliving
// 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(0xFF2298FF) // buttons, bubbles, links val Accent = Color(0xFF7A3FF2) // buttons, bubbles, links
val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch val AccentBright = Color(0xFFB99BFF) // links and small text on the dark ground
val AccentSoft = Color(0x1A2298FF) val AccentDeep = Color(0xFF5B27C7) // links and small text on the light ground
val AccentSoft = Color(0x1A7A3FF2)
// The gradient.
//
// 🔴 **The LIGHT end is the one that has to carry the label.** A gradient
// button is only as readable as its lightest stop, and checking the mean
// or the dark end is how a pretty button ships unreadable. Measured with
// white text: #7A3FF2 is 5.50:1 and #5B27C7 is 8.20:1, so the label clears
// AA along the whole sweep rather than at one end of it.
val GradientTop = Color(0xFF7A3FF2)
val GradientBottom = Color(0xFF5B27C7)
// The page ground, as a gradient rather than a flat fill.
//
// ⚠️ Deliberately a whisper on light. Cards are white and have to lift off
// 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 Teal = Color(0xFF3BD5B2) // success and presence val Teal = Color(0xFF3BD5B2) // success and presence
val Pink = Color(0xFFED497C) // the kit's alert accent val Pink = Color(0xFFED497C) // the kit's alert accent
// Neutrals, all from the kit. // Neutrals, all from the kit.
val Ink = Color(0xFF090710) val Ink = Color(0xFF090710)
val Mist = Color(0xFFF9F9F9) // the page ground // ⚠️ 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 SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
val IncomingBubble = Color(0xFFF3F3F3) val IncomingBubble = Color(0xFFF3F3F3)
// ⚠️ 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.
val AvatarFill = Color(0xFFE8F1FB) // ⚠️ 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) val SecondaryText = Color(0xFF505C69)
val Divider = Color(0x1A505C69) val Divider = Color(0x1A505C69)
@@ -77,9 +105,26 @@ object Palette {
// 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(0xFF121A2E) // --panel
val NightGround = Color(0xFF0A0E1A) // --bg val NightGround = Color(0xFF150C26) // --bg, the dark gradient's mid-tone
} }
/**
* The app's ground.
*
* ⚠️ One brush for the whole app rather than a fill per screen: a gradient
* applied per screen is a gradient that restarts at every navigation, which
* reads as the background flickering.
*/
@Composable
fun groundBrush(): Brush = Brush.verticalGradient(
if (isSystemInDarkTheme()) listOf(Palette.NightGroundTop, Palette.NightGroundBottom)
else listOf(Palette.GroundTop, Palette.GroundBottom)
)
/** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */
fun accentBrush(): Brush =
Brush.verticalGradient(listOf(Palette.GradientTop, Palette.GradientBottom))
/** Corner radii, matching `Theme.Radius` on iOS. */ /** Corner radii, matching `Theme.Radius` on iOS. */
object Radius { object Radius {
val small = 10.dp val small = 10.dp
@@ -0,0 +1,103 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The two apps' palettes, checked rather than claimed.
*
* 🔴 **`Theme.swift` opens with "One palette, two apps… change a value here and
* change it there in the same commit" — and nothing enforced it.** Its own
* comment records what happened last time: the file claimed the two were
* matched while iOS was indigo and Android was ember. A promise in a comment is
* a promise nobody keeps, because nothing goes red when it is broken.
*
* This reads the iOS source and compares the hex values. It is coarse on
* purpose — it does not know what a colour means, only that both files say the
* same number for it.
*
* ⚠️ It needs the iOS checkout beside this one. When it is missing the test
* SKIPS rather than fails: a green suite on a machine that only has Android is
* the honest outcome, and a red one there would train people to ignore it.
*/
class PaletteMatchesIOSTest {
private val iosTheme: File? =
listOf(
File("../flamenetmessenger/Sources/Theme/Theme.swift"),
File("../../flamenetmessenger/Sources/Theme/Theme.swift"),
).firstOrNull { it.isFile }
/** `static let name = Color(hex: 0xRRGGBB)` and the light/dark pair form. */
private fun iosHex(source: String, name: String): String? {
val flat = Regex("""static let $name\s*=\s*Color\(hex:\s*0x([0-9A-Fa-f]{6})\)""")
.find(source)?.groupValues?.get(1)
if (flat != null) return flat.uppercase()
return Regex("""static let $name\s*=\s*Color\(light:\s*0x([0-9A-Fa-f]{6})""")
.find(source)?.groupValues?.get(1)?.uppercase()
}
private fun androidHex(color: androidx.compose.ui.graphics.Color): String =
"%02X%02X%02X".format(
(color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
@Test
fun `the accent and its gradient are the same on both platforms`() {
val ios = iosTheme?.readText() ?: return
for ((swift, android) in listOf(
"primary" to Palette.Accent,
"primaryGradientTop" to Palette.GradientTop,
"primaryGradientBottom" to Palette.GradientBottom,
"primaryDeep" to Palette.AccentDeep,
)) {
val expected = iosHex(ios, swift)
assertTrue(expected != null, "iOS Theme.swift has no `$swift` in a form this can read")
assertEquals(expected, androidHex(android),
"$swift differs: iOS says $expected, Android says ${androidHex(android)}")
}
}
/**
* 🔴 The half that decides whether the app is readable. A gradient button is
* only as readable as its LIGHTEST stop, and both platforms draw white on
* it.
*/
@Test
fun `white text clears AA on the lightest end of the button gradient`() {
val ratio = contrast(Palette.GradientTop, androidx.compose.ui.graphics.Color.White)
assertTrue(ratio >= 4.5,
"white on the gradient's light end is %.2f:1 — below AA for normal text".format(ratio))
}
@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))
}
}
private fun channel(c: Float): Double {
val v = c.toDouble()
return if (v <= 0.04045) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4)
}
private fun luminance(c: androidx.compose.ui.graphics.Color): Double =
0.2126 * channel(c.red) + 0.7152 * channel(c.green) + 0.0722 * channel(c.blue)
private fun contrast(
a: androidx.compose.ui.graphics.Color,
b: androidx.compose.ui.graphics.Color,
): Double {
val la = luminance(a)
val lb = luminance(b)
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
}
}