From 563e9efed7a519af1c63fe25f99d96c33a664040 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 7 Sep 2026 11:09:04 -0400 Subject: [PATCH] The same purple ground, and a test that checks it stays the same MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/KitButtons.kt | 22 ++++ .../io/flamenet/messenger/app/MainActivity.kt | 11 +- .../kotlin/io/flamenet/messenger/app/Theme.kt | 57 +++++++++- .../messenger/app/PaletteMatchesIOSTest.kt | 103 ++++++++++++++++++ 4 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt index edcecd2..3b70fe6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt @@ -1,6 +1,10 @@ package io.flamenet.messenger.app 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.Spacer import androidx.compose.foundation.layout.height @@ -52,13 +56,31 @@ fun KitPrimaryButton( enabled: Boolean = true, 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( onClick = onClick, enabled = enabled, shape = RoundedCornerShape(KitButton.radius), contentPadding = PaddingValues(horizontal = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + contentColor = Color.White, + disabledContentColor = Color.White, + ), modifier = modifier .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), ) { Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index cd18d5e..e91461a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -109,7 +109,14 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { setContent { 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 top, the message list still renders underneath for a // frame — and a frame is all a screenshot or a shoulder @@ -179,7 +186,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { @Composable private fun Root(model: MessengerViewModel = viewModel()) { 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) when (val current = phase) { // Scrollable, because a keyboard over a form needs somewhere to go. 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 b12cc74..42a6b31 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight 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 // 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. - val Accent = Color(0xFF2298FF) // buttons, bubbles, links - val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch - val AccentSoft = Color(0x1A2298FF) + 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 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 Pink = Color(0xFFED497C) // the kit's alert accent // Neutrals, all from the kit. 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 IncomingBubble = Color(0xFFF3F3F3) // ⚠️ 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. - 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 Divider = Color(0x1A505C69) @@ -77,9 +105,26 @@ object Palette { // Dark-mode grounds — these ARE the site's, since the site is dark. 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. */ object Radius { val small = 10.dp diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt new file mode 100644 index 0000000..efe039b --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt @@ -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) + } +}