diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt index a24199e..3a4f281 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -62,13 +62,10 @@ fun CallScreenBody( Column( modifier = Modifier .fillMaxSize() - // ⚠️ The app's ground, not `colorScheme.background`. That token is - // a flat blue, and painting it full-screen here stamped a solid - // rectangle over the gradient every other screen shows — which - // read as "a blue box around the call area". The brush is used - // rather than transparency so this composable still stands on its - // own in the screenshot harness. - .background(groundBrush()) + // ⚠️ Back to the theme's own background: the gradient this used to + // avoid painting over went with the purple theme (reverted + // 2026-09-08), so there is nothing left to clash with. + .background(MaterialTheme.colorScheme.background) .padding(horizontal = Kit.margin) .testTag("call-screen"), horizontalAlignment = Alignment.CenterHorizontally, @@ -88,7 +85,7 @@ fun CallScreenBody( initials, fontSize = 40.sp, fontWeight = FontWeight.Bold, - color = Palette.OnDark, + color = MaterialTheme.colorScheme.primary, ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt index 55eafe8..b5cf665 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt @@ -69,7 +69,7 @@ fun ColumnScope.CallsScreen( Icon( KitIcon.Call, contentDescription = "New call", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(28.dp) .clickable { showNewCall.value = true } @@ -105,12 +105,12 @@ fun ColumnScope.CallsScreen( overflow = TextOverflow.Ellipsis, ) Icon(KitIcon.Call, contentDescription = null, - tint = Palette.OnDark, modifier = Modifier.size(20.dp)) + tint = Palette.Accent, modifier = Modifier.size(20.dp)) Spacer(Modifier.width(Space.md)) Icon( KitIcon.Video, contentDescription = "Video call", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(22.dp) .clickable { @@ -226,7 +226,7 @@ fun ColumnScope.CallsScreen( Icon( KitIcon.Video, contentDescription = "Video call $name", - tint = Palette.OnDark, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(Kit.icon), ) } @@ -243,7 +243,7 @@ fun ColumnScope.CallsScreen( Icon( KitIcon.Call, contentDescription = "Call $name", - tint = Palette.OnDark, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(Kit.icon), ) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index d2bd6ae..a613cc3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -438,7 +438,7 @@ fun ColumnScope.GroupsSection( androidx.compose.material3.Icon( if (creating) KitIcon.Close else KitIcon.NewGroup, contentDescription = "New group", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(22.dp) .clickable { onCreatingChange(!creating) } @@ -448,7 +448,6 @@ fun ColumnScope.GroupsSection( if (creating) { OutlinedTextField( - colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Group name") }, @@ -797,7 +796,6 @@ fun ColumnScope.AccountScreen( // same duplication the contact rows had. SettingsRow(label = "Display name") {} OutlinedTextField( - colors = fieldColors(), value = displayName, onValueChange = { displayName = it }, placeholder = { Text("How others see you", fontSize = 14.sp) }, @@ -819,7 +817,6 @@ fun ColumnScope.AccountScreen( description = "Changing it signs this device out — that is what changing it means.", ) {} OutlinedTextField( - colors = fieldColors(), value = password, onValueChange = { password = it }, placeholder = { Text("At least 8 characters", fontSize = 14.sp) }, @@ -1361,16 +1358,13 @@ internal fun SectionBandPublic() = SectionBand() @Composable private fun SectionBand() { - // 🔴 **A gap, not a filled block.** On the light theme this was a 16pt band - // of #F3F3F3 — a barely-there grey that read as a seam between sections. - // Filled with `surfaceVariant` on the dark theme it became a light purple - // slab across the screen, which is the opposite of a separator: it drew - // more attention than the sections it was dividing. - // - // Letting the ground show through does the same job the grey band did, - // because the sections themselves are raised cards now — the gap between - // two panels IS the separator, and it needs no colour of its own. - Spacer(Modifier.fillMaxWidth().height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 0.dp) + .height(16.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) } /** @@ -1449,9 +1443,7 @@ internal fun Avatar( initials, fontSize = (size.value * 0.33f).sp, fontWeight = FontWeight.SemiBold, - // White on the avatar circle. The accent as ink on a dark - // circle is a light purple mark on a dark blue field. - color = Palette.OnDark, + color = MaterialTheme.colorScheme.primary, ) } } @@ -1543,7 +1535,7 @@ private fun AlertGlyph(kind: String) { Icon( symbol, contentDescription = null, - tint = Palette.OnDark, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(Kit.icon), ) } @@ -2853,7 +2845,7 @@ fun ColumnScope.StoryStrip( Icon( KitIcon.Add, contentDescription = null, - tint = Palette.OnDark, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(Kit.icon), ) } @@ -2890,7 +2882,7 @@ fun ColumnScope.StoryStrip( label.take(1).uppercase(), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, - color = Palette.OnDark, + color = MaterialTheme.colorScheme.primary, ) } Text( @@ -2990,7 +2982,6 @@ private fun ColumnScope.GroupMembers( if (renaming) { OutlinedTextField( - colors = fieldColors(), value = draftName, onValueChange = { draftName = it }, label = { Text("Group name") }, @@ -3233,7 +3224,6 @@ fun ColumnScope.AddContactForm( fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.xs)) OutlinedTextField( - colors = fieldColors(), value = gatePin, onValueChange = { gatePin = it; gateWrong = false }, placeholder = { Text("Parent PIN", fontSize = 14.sp) }, singleLine = true, @@ -3269,7 +3259,6 @@ fun ColumnScope.AddContactForm( val card = remember(username) { ContactCard.decode(username) } OutlinedTextField( - colors = fieldColors(), value = username, onValueChange = { username = it @@ -3898,7 +3887,6 @@ fun ColumnScope.MailboxesSection( } OutlinedTextField( - colors = fieldColors(), value = host, onValueChange = { host = it }, placeholder = { Text("relay.example.com", fontSize = 14.sp) }, @@ -3907,7 +3895,6 @@ fun ColumnScope.MailboxesSection( ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( - colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, placeholder = { Text("Your passphrase", fontSize = 14.sp) }, @@ -4103,7 +4090,6 @@ private fun EditMessageSheet( text = { Column { androidx.compose.material3.OutlinedTextField( - colors = fieldColors(), value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxWidth().testTag("edit-field"), @@ -4242,7 +4228,6 @@ internal fun ReportSheet( Spacer(Modifier.height(Space.sm)) androidx.compose.material3.OutlinedTextField( - colors = fieldColors(), value = note, onValueChange = { note = it }, label = { Text("Anything you want to add (optional)") }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt index 0baf6d8..274626a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt @@ -80,7 +80,7 @@ fun ColumnScope.ContactDetail( Icon( KitIcon.Back, contentDescription = null, - tint = Palette.OnDark, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(Kit.icon), ) } @@ -111,8 +111,7 @@ fun ColumnScope.ContactDetail( initials, fontSize = 64.sp, fontWeight = FontWeight.Bold, - // White on the avatar circle, like every other one. - color = Palette.OnDark, + color = MaterialTheme.colorScheme.primary, ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt index 33b9e32..e411ad9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt @@ -62,13 +62,18 @@ fun ColumnScope.DisplaySection() { settings.largerType = on; larger = on } - // ⚠️ The Light / Dark / Match-the-phone choice is gone. The palette has one - // set of colours now and the app is dark whatever the phone says, so the - // control changed nothing a person could see. A setting that is still there - // and no longer works is worse than one that was removed — somebody sets - // it, nothing happens, and they are left wondering which of the two is - // broken. `DisplaySettings.appearance` is left in place so an existing - // stored value is not a crash; nothing reads it. + Spacer(Modifier.height(Space.sm)) + Text("Appearance", fontSize = 14.sp) + Row(modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs)) { + DisplaySettings.Appearance.entries.forEach { option -> + KitSecondaryButton( + text = if (option == appearance) "✓ ${option.label}" else option.label, + onClick = { settings.appearance = option; appearance = option }, + modifier = Modifier.weight(1f).padding(end = Space.xs), + tag = "display-appearance-${option.stored}", + ) + } + } Spacer(Modifier.height(Space.xs)) // 🔴 Says the thing the label alone cannot: this hides them from you, not @@ -76,7 +81,8 @@ fun ColumnScope.DisplaySection() { Text( "These change what this phone shows you. Your contacts are still told when " + "you are online whichever way this is set — hiding yourself is not " - + "something the app can do yet.", + + "something the app can do yet.\n\nAppearance takes effect next time the " + + "app starts.", fontSize = 12.sp, color = Palette.SecondaryText, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index bed9077..0e6008c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -145,7 +145,6 @@ private fun ColumnScope.FilesSetUp( Spacer(Modifier.height(Space.md)) OutlinedTextField( - colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Your Flamenet passphrase") }, visualTransformation = PasswordVisualTransformation(), @@ -154,7 +153,6 @@ private fun ColumnScope.FilesSetUp( ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = again, onValueChange = { again = it }, label = { Text("Type it again") }, visualTransformation = PasswordVisualTransformation(), @@ -189,7 +187,6 @@ private fun ColumnScope.FilesUnlock( fontSize = 14.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.md)) OutlinedTextField( - colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Passphrase") }, visualTransformation = PasswordVisualTransformation(), @@ -455,7 +452,6 @@ private fun ColumnScope.AddFileSheet( Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("files-add-name"), @@ -514,7 +510,6 @@ private fun ColumnScope.FolderNameSheet( Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("files-folder-name"), diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt index 6f3d960..86ff598 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt @@ -187,7 +187,6 @@ private fun PassphraseDialog( Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = phrase, onValueChange = { phrase = it }, placeholder = { Text("Passphrase", fontSize = 14.sp) }, @@ -198,7 +197,6 @@ private fun PassphraseDialog( if (requireRepeat) { Spacer(Modifier.height(Space.xs)) OutlinedTextField( - colors = fieldColors(), value = repeat, onValueChange = { repeat = it }, placeholder = { Text("Repeat passphrase", fontSize = 14.sp) }, 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 3b70fe6..edcecd2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt @@ -1,10 +1,6 @@ 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 @@ -56,31 +52,13 @@ 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 93b22d1..fd3563b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -3,8 +3,6 @@ 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 @@ -27,7 +25,6 @@ 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 @@ -112,27 +109,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { setContent { MessengerTheme { - // 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, - // 🔴 **`contentColor` MUST be set when `color` is - // Transparent.** Material derives it with - // `contentColorFor(color)`, which has no answer for - // Transparent and returns Unspecified — so `LocalContentColor` - // falls back to its own default, which is BLACK. Every - // `Text` that does not name a colour of its own then draws - // black on the dark ground. - // - // It looked like "some of the text is hard to read" rather - // than like a broken theme, because the many places that DO - // set a colour were fine and only the inheriting ones went - // dark. - contentColor = Palette.Ink, - modifier = Modifier.fillMaxSize().background(groundBrush()), - ) { + Surface(modifier = Modifier.fillMaxSize()) { // 🔴 **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 @@ -202,7 +179,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { @Composable private fun Root(model: MessengerViewModel = viewModel()) { val phase by model.phase.collectAsStateWithLifecycle() - Scaffold(containerColor = androidx.compose.ui.graphics.Color.Transparent) { padding -> + Scaffold { 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. @@ -365,22 +342,15 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - // 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, + Box( modifier = Modifier - .size(72.dp) - .clip(RoundedCornerShape(Radius.medium)), - ) + .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) + } Spacer(Modifier.height(Space.lg)) Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(Space.sm)) @@ -395,7 +365,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Spacer(Modifier.height(Space.xl)) OutlinedTextField( - colors = fieldColors(), value = relay, onValueChange = model::setRelay, label = { Text("Server") }, @@ -416,7 +385,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } OutlinedTextField( - colors = fieldColors(), value = handle, onValueChange = { handle = it }, label = { Text("Handle") }, @@ -431,7 +399,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // would be collecting something nobody needs. if (needsEmail) { OutlinedTextField( - colors = fieldColors(), value = email, onValueChange = { email = it }, label = { Text("Email") }, @@ -447,7 +414,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } OutlinedTextField( - colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Passphrase") }, @@ -463,7 +429,6 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // this device to refuse an under-13 sign-up, then forgotten. Nothing // about age is sent to any server. OutlinedTextField( - colors = fieldColors(), value = birthYear, onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, label = { Text("Year of birth") }, @@ -574,7 +539,6 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) ) { Text("Send a code") } OutlinedTextField( - colors = fieldColors(), value = code, onValueChange = { code = it }, label = { Text("Code from the email") }, @@ -582,7 +546,6 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) modifier = Modifier.fillMaxWidth().testTag("reset-code"), ) OutlinedTextField( - colors = fieldColors(), value = fresh, onValueChange = { fresh = it }, label = { Text("New password") }, @@ -912,7 +875,7 @@ private fun BarAction( Icon( icon, contentDescription = null, - tint = Palette.OnDark, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(Kit.icon), ) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index 8689900..1d36f37 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -76,7 +76,7 @@ fun ColumnScope.MessagesScreen( androidx.compose.material3.Icon( KitIcon.Scan, contentDescription = "Scan an address", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(24.dp) .clickable { @@ -92,7 +92,7 @@ fun ColumnScope.MessagesScreen( androidx.compose.material3.Icon( if (addingContact) KitIcon.Close else KitIcon.Add, contentDescription = "Add contact", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(26.dp) .clickable { addingContact = !addingContact } @@ -124,7 +124,6 @@ fun ColumnScope.MessagesScreen( Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = query.value, onValueChange = { query.value = it }, placeholder = { Text("Search conversations") }, @@ -168,7 +167,7 @@ fun ColumnScope.MessagesScreen( androidx.compose.material3.Icon( KitIcon.NewChat, contentDescription = "New chat", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(22.dp) .clickable { showNewChat.value = true } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt index 631855e..6db595f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt @@ -148,7 +148,6 @@ fun ColumnScope.NetworkSection( Spacer(Modifier.height(Space.md)) OutlinedTextField( - colors = fieldColors(), value = draft, onValueChange = { draft = it }, placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt index fbb0482..28afbeb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt @@ -56,7 +56,6 @@ fun ParentalControlsSection() { ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = newPin, onValueChange = { newPin = it }, placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) }, singleLine = true, @@ -65,7 +64,6 @@ fun ParentalControlsSection() { ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( - colors = fieldColors(), value = repeatPin, onValueChange = { repeatPin = it }, placeholder = { Text("Repeat PIN", fontSize = 14.sp) }, singleLine = true, @@ -88,7 +86,6 @@ fun ParentalControlsSection() { fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = pinEntry, onValueChange = { pinEntry = it; wrong = false }, placeholder = { Text("Parent PIN", fontSize = 14.sp) }, singleLine = true, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index bb6dd0c..97baa14 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -95,7 +95,7 @@ fun ColumnScope.StoriesFeed( Icon( KitIcon.Add, contentDescription = "New story", - tint = Palette.OnDark, + tint = Palette.Accent, modifier = Modifier .size(28.dp) .clickable { choosing = true } @@ -357,7 +357,6 @@ private fun StoryActions( + "saw this story can read it.", fontSize = 13.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - colors = fieldColors(), value = draft, onValueChange = { draft = it }, placeholder = { Text("Message") }, modifier = Modifier.fillMaxWidth().testTag("story-reply-field"), @@ -454,7 +453,6 @@ fun StoryComposerDialog( } } androidx.compose.material3.OutlinedTextField( - colors = fieldColors(), value = caption.value, onValueChange = { caption.value = it }, placeholder = { Text("Add a caption…") }, @@ -466,7 +464,6 @@ fun StoryComposerDialog( // §6.13 — attach a live stream. The picked picture becomes its // poster: what people see before deciding whether to connect. androidx.compose.material3.OutlinedTextField( - colors = fieldColors(), value = streamUrl.value, onValueChange = { streamUrl.value = it }, label = { Text("Live stream link (optional)", fontSize = 12.sp) }, @@ -709,7 +706,6 @@ private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) { title = { Text("New story") }, text = { OutlinedTextField( - colors = fieldColors(), value = text, onValueChange = { text = it }, placeholder = { Text("Say something") }, 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 c1ff51e..b12cc74 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -1,5 +1,6 @@ package io.flamenet.messenger.app +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.remember import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -13,8 +14,6 @@ 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.material3.OutlinedTextFieldDefaults import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -52,164 +51,35 @@ 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(0xFF7A3FF2) // buttons, bubbles, links - val AccentBright = Color(0xFFB99BFF) // links and small text on the dark ground - val AccentDeep = Color(0xFFB99BFF) // links and small text - val AccentSoft = Color(0x1A7A3FF2) + val Accent = Color(0xFF2298FF) // buttons, bubbles, links + val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch + val AccentSoft = Color(0x1A2298FF) - // 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. - // - // Purple at the top, medium blue at the bottom — top down. - // - // ⚠️ The blue end is not decoration. The mark is a blue globe with an - // orange flame, and a purple-only app left the icon as the odd one out; - // shading into blue puts the icon's own colour on the page instead of - // beside it. - // - // 🔴 Every darkening step drags SecondaryText with it. Ink reads 12.3:1 at - // the top and 8.4:1 at the bottom, secondary text 8.1:1 and 5.5:1 — the - // bottom stop binds, and it is where most of the explanatory copy sits. - // White cards lift off the bottom at 2.4:1, which keeps a card a card. - val GroundTop = Color(0xFF3A1E9E) // purple - val GroundMid = Color(0xFF0B3080) // blue - val GroundBottom = Color(0xFF070A1A) // black - // Kept as the same two colours: there is one theme now. - val NightGroundTop = Color(0xFF3A1E9E) - val NightGroundBottom = Color(0xFF070A1A) - - val Teal = Color(0xFF4BE3C0) // success and presence - val Pink = Color(0xFFFF819F) // the kit's alert accent + val Teal = Color(0xFF3BD5B2) // success and presence + val Pink = Color(0xFFED497C) // the kit's alert accent // Neutrals, all from the kit. - val Ink = Color(0xFFEDEFF9) - // ⚠️ 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(0xFF191D68) // the page ground, flat - val SurfaceAlt = Color(0xFF1E2A70) // received bubbles, pills, bands - val IncomingBubble = Color(0xFF1E2A70) + val Ink = Color(0xFF090710) + val Mist = Color(0xFFF9F9F9) // the page ground + 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. - // Icons and initials drawn ON a dark surface. - // - // 🔴 White, not the accent. The accent is a mid purple chosen to be - // readable as a FILL with white on top of it. Used as the ink for a glyph - // on a dark ground it is the wrong way round — a light purple mark on a - // dark blue field, the least legible thing on the screen, and it reads as - // a disabled control rather than a button. - // - // ⚠️ The accent still fills: primary buttons, the selected tab, badges. - val OnDark = Color(0xFFFFFFFF) - - val AvatarFill = Color(0xFF243480) - // 🔴 **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. - // 🔴 **A mid-tone again, and that is the point of the flip.** On the light - // ground this had darkened at every step until it was #1B1822 — almost the - // same colour as Ink — so the hierarchy between primary and secondary text - // had collapsed while the contrast numbers still passed. On a dark ground - // it sits in the middle where it belongs: 7.3:1 on the ground, 5.7:1 on a - // card, 4.7:1 on a field. - val SecondaryText = Color(0xFFB4BAD4) - // One theme now, so one secondary. Kept as an alias so the dark - // colour scheme below still reads clearly. - val SecondaryTextDark = Color(0xFFB4BAD4) - val Divider = Color(0x2EB4BAD4) + val AvatarFill = Color(0xFFE8F1FB) + val SecondaryText = Color(0xFF505C69) + val Divider = Color(0x1A505C69) // Semantic - val Success = Color(0xFF4BE3C0) - val Warning = Color(0xFFE9B44C) - val Danger = Color(0xFFFF819F) + val Success = Color(0xFF3BD5B2) + val Warning = Color(0xFFB8860B) + val Danger = Color(0xFFED497C) // Dark-mode grounds — these ARE the site's, since the site is dark. - // 🔴 Darker than the ground, not lighter. The ground is vivid here, and a - // card lighter than a saturated violet has to be lighter still than that, - // which puts secondary text below AA on it. Recessed panels invert the - // problem: the gradient is the bright thing, content sits in dark wells. - // 🔴 One card colour against a THREE-stop ground, so it must lift off all - // three. #0E1745 looked right against the purple and the blue and measured - // 1.15:1 against the black end — a card that disappears at the bottom of a - // long screen. This lifts 1.41 / 1.33 / 1.23. - val InkSurface = Color(0xFF141C52) // --panel - val NightGround = Color(0xFF191D68) // --bg, the gradient's mid-tone + val InkSurface = Color(0xFF121A2E) // --panel + val NightGround = Color(0xFF0A0E1A) // --bg } -/** - * 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 = - // One theme, so no branch. The Night* pair is kept pointing at the same two - // colours rather than deleted, because several screens name it directly and - // a rename across both apps is a separate change. - Brush.verticalGradient( - listOf(Palette.GroundTop, Palette.GroundMid, Palette.GroundBottom)) - -/** The primary action's fill. Matches `Theme.primaryGradient*` on iOS. */ -fun accentBrush(): Brush = - Brush.verticalGradient(listOf(Palette.GradientTop, Palette.GradientBottom)) - -/** - * Text fields, and only text fields. - * - * 🔴 **White, on a dark app, deliberately.** An input is the one surface a - * person types into and reads back character by character, and a dark field on - * a dark ground gives it the least contrast on the screen at the moment it - * matters most. Typed text reads 18.3:1 on white; the placeholder 4.6:1, - * because a placeholder is still text. Matches `Theme.fieldFill` on iOS. - * - * ⚠️ **Not `SurfaceAlt`.** That fill is shared with incoming bubbles and the - * tab pill, which stay dark — a white message bubble on this ground would read - * as somebody else's app. - * - * ⚠️ Every colour a field draws has to be named, not just the container. - * Material derives label, placeholder, cursor and outline from the colour - * scheme, which is the DARK scheme here — leaving any of them unset puts a - * light-on-dark colour on a white box, and the placeholder simply disappears. - */ -object FieldPalette { - val fill = Color(0xFFFFFFFF) - val ink = Color(0xFF0E1230) - val hint = Color(0xFF6B7590) -} - -@Composable -fun fieldColors() = OutlinedTextFieldDefaults.colors( - focusedContainerColor = FieldPalette.fill, - unfocusedContainerColor = FieldPalette.fill, - disabledContainerColor = FieldPalette.fill, - errorContainerColor = FieldPalette.fill, - focusedTextColor = FieldPalette.ink, - unfocusedTextColor = FieldPalette.ink, - disabledTextColor = FieldPalette.hint, - errorTextColor = FieldPalette.ink, - focusedLabelColor = FieldPalette.hint, - unfocusedLabelColor = FieldPalette.hint, - disabledLabelColor = FieldPalette.hint, - focusedPlaceholderColor = FieldPalette.hint, - unfocusedPlaceholderColor = FieldPalette.hint, - cursorColor = Palette.Accent, - focusedBorderColor = Palette.Accent, - unfocusedBorderColor = FieldPalette.hint, -) - /** Corner radii, matching `Theme.Radius` on iOS. */ object Radius { val small = 10.dp @@ -322,31 +192,53 @@ fun Modifier.flamenetCard( @Composable fun MessengerTheme(content: @Composable () -> Unit) { - // 🔴 **One theme, and it is dark.** There used to be a light scheme, a dark - // one, and a preference choosing between them. The palette has a single set - // of colours now, so the light branch would have drawn light-mode Material - // chrome under dark-mode artwork — and the preference would have been a - // control that changes nothing a person can see except the parts that - // break. - // - // ⚠️ `onBackground`/`onSurface` are `Ink`, not `Mist`. `Mist` is the - // ground now; using it as a text colour on that ground is dark-on-dark, - // which is exactly the kind of swap that survives a rename. + // ⚠️ Read here rather than passed in, so every entry point picks it up — + // the app has more than one, and a theme applied at only some of them is + // the kind of half-wiring that looks fine until somebody opens a call + // screen from a notification. + val context = androidx.compose.ui.platform.LocalContext.current + val preference = remember { DisplaySettings(context).appearance } + val dark = when (preference) { + DisplaySettings.Appearance.LIGHT -> false + DisplaySettings.Appearance.DARK -> true + DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme() + } MaterialTheme( - colorScheme = darkColorScheme( - primary = Palette.Accent, - onPrimary = Color.White, - secondary = Palette.AccentDeep, - background = Palette.Mist, - surface = Palette.InkSurface, - surfaceVariant = Palette.SurfaceAlt, - onBackground = Palette.Ink, - onSurface = Palette.Ink, - onSurfaceVariant = Palette.SecondaryText, - error = Palette.Danger, - outline = Palette.Divider, - ), - typography = MaterialTheme.typography, + colorScheme = if (dark) { + darkColorScheme( + primary = Palette.AccentBright, + onPrimary = Color.White, + secondary = Palette.Accent, + background = Palette.NightGround, + surface = Palette.InkSurface, + surfaceVariant = Palette.InkSurface, + onBackground = Palette.Mist, + onSurface = Palette.Mist, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, + ) + } else { + lightColorScheme( + // ⚠️ The kit's #2298FF on white is about 3.3:1 — fine for a + // bold button label, not for small text. Anything small and + // blue must use a darker step; the two places that do are the + // section labels and the timestamps, and both are grey. + primary = Palette.Accent, + onPrimary = Color.White, + secondary = Palette.AccentBright, + background = Palette.Mist, + surface = Color.White, + surfaceVariant = Palette.SurfaceAlt, + onBackground = Palette.Ink, + onSurface = Palette.Ink, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, + ) + }, + shapes = FlamenetShapes, + typography = FlamenetTypography, content = content, ) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt index 0cb23cc..40ae9c6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt @@ -129,7 +129,6 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) { Text("Already have one?", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.xs)) OutlinedTextField( - colors = fieldColors(), value = phrase, onValueChange = { phrase = it }, placeholder = { Text("Your 12 or 24 word recovery phrase", fontSize = 13.sp) }, diff --git a/app/src/main/res/drawable-hdpi/brand_mark.png b/app/src/main/res/drawable-hdpi/brand_mark.png deleted file mode 100644 index 319e3bb..0000000 Binary files a/app/src/main/res/drawable-hdpi/brand_mark.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/brand_mark.png b/app/src/main/res/drawable-mdpi/brand_mark.png deleted file mode 100644 index a8ef9be..0000000 Binary files a/app/src/main/res/drawable-mdpi/brand_mark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/brand_mark.png b/app/src/main/res/drawable-xhdpi/brand_mark.png deleted file mode 100644 index d11913e..0000000 Binary files a/app/src/main/res/drawable-xhdpi/brand_mark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/brand_mark.png b/app/src/main/res/drawable-xxhdpi/brand_mark.png deleted file mode 100644 index b477c04..0000000 Binary files a/app/src/main/res/drawable-xxhdpi/brand_mark.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/brand_mark.png b/app/src/main/res/drawable-xxxhdpi/brand_mark.png deleted file mode 100644 index 795f778..0000000 Binary files a/app/src/main/res/drawable-xxxhdpi/brand_mark.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 3beb7ab..6c028dc 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 3ac986f..4ff696d 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index bca12aa..844711a 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index cc27ff1..64f42ff 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 653ae8c..f1cf14d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt deleted file mode 100644 index 3fbc6b3..0000000 --- a/app/src/test/kotlin/io/flamenet/messenger/app/PaletteMatchesIOSTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -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)) - } - - /** - * 🔴 **One theme now, so one set of grounds — and every surface a person - * reads text on has to clear AA, not just the page.** - * - * The light theme is gone (2026-09-07). It went because the ground kept - * being asked to go darker and `secondaryText` had to darken with it at - * every step, until it was #1B1822 against ink at #090710: contrast passing - * while the hierarchy between primary and secondary text quietly vanished. - * A dark ground puts secondary back in the middle where it belongs. - * - * ⚠️ Cards and field fills are checked as well as the page. A gradient that - * passes and a bubble that does not is still an unreadable app, and the - * bubble is where the messages are. - */ - @Test - fun `text clears AA on every surface it is read on`() { - val surfaces = listOf( - "ground top (purple)" to Palette.GroundTop, - // ⚠️ The middle stop is a surface too. A three-stop gradient - // checked only at its ends is two thirds measured. - "ground middle (blue)" to Palette.GroundMid, - "ground bottom (black)" to Palette.GroundBottom, - "card" to Palette.InkSurface, - "field and incoming bubble" to Palette.SurfaceAlt, - "avatar" to Palette.AvatarFill, - ) - for ((name, ground) in surfaces) { - val ink = contrast(ground, Palette.Ink) - assertTrue(ink >= 4.5, "ink on the $name is %.2f:1".format(ink)) - // Secondary is the one that fails first — most of the explanatory - // copy on these screens is set in it. - val muted = contrast(ground, Palette.SecondaryText) - assertTrue(muted >= 4.5, "secondary text on the $name is %.2f:1".format(muted)) - } - } - - /** - * ⚠️ A card has to be visibly a card. Contrast rules say nothing about - * this, and a surface that matches its ground is a layout people cannot - * parse even though every text ratio passes. - */ - @Test - fun `cards and fields lift off what is behind them`() { - // 🔴 Against ALL THREE stops. One card colour has to survive the whole - // sweep, and the black end is the one it fails on. - for ((name, stop) in listOf("purple" to Palette.GroundTop, - "blue" to Palette.GroundMid, - "black" to Palette.GroundBottom)) { - val lift = contrast(Palette.InkSurface, stop) - assertTrue(lift >= 1.15, - "a card lifts off the $name end of the ground by only %.2f:1".format(lift)) - } - val fieldLift = contrast(Palette.SurfaceAlt, Palette.InkSurface) - assertTrue(fieldLift >= 1.15, "a field lifts off its card by only %.2f:1".format(fieldLift)) - } - - 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) - } -}