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:
co-authored by
Claude Opus 5
parent
832cbf9214
commit
563e9efed7
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user