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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user