From 8fa66cd6035f39637e3d4bf71f3e7cd4498ff522 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 7 Sep 2026 14:11:20 -0400 Subject: [PATCH] =?UTF-8?q?White=20text=20boxes=20=E2=80=94=20matches=20io?= =?UTF-8?q?s!144?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An input is the one surface a person types into and reads back character by character, and a dark field on a dark ground gave it the least contrast on the screen at the moment it mattered most. Typed text reads 18.3:1 on white; the placeholder 4.6:1, because a placeholder is still text. ⚠️ **Its own colour, 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: setting only `containerColor` leaves a light-on-dark placeholder on a white box, and it simply disappears. iOS had the same failure in a different dialect — SwiftUI drew the placeholder and caret for a dark background until the field was given `.environment(\.colorScheme, .light)`. Two frameworks, one mistake: a white island in a dark app has to be told it is an island. One `fieldColors()` in `Theme.kt`, applied to all 34 `OutlinedTextField` call sites. Mechanical and wide rather than a new wrapper composable, because a wrapper would have to be adopted one screen at a time and the ones that were missed would be exactly the ones nobody looks at. ⚠️ The message composer is a `BasicTextField` and stays dark. It is a pill in the chat, not a form field, and turning it white would put a white bar across the bottom of every conversation. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/ChatScreens.kt | 10 +++++ .../io/flamenet/messenger/app/FilesScreen.kt | 5 +++ .../flamenet/messenger/app/HistoryBackup.kt | 2 + .../io/flamenet/messenger/app/MainActivity.kt | 7 +++ .../flamenet/messenger/app/MessagesScreen.kt | 1 + .../flamenet/messenger/app/NetworkSection.kt | 1 + .../messenger/app/ParentalControlsSection.kt | 3 ++ .../io/flamenet/messenger/app/StoriesFeed.kt | 4 ++ .../kotlin/io/flamenet/messenger/app/Theme.kt | 45 +++++++++++++++++++ .../io/flamenet/messenger/app/WalletScreen.kt | 1 + 10 files changed, 79 insertions(+) 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 d04b7df..c61be4d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -447,6 +447,7 @@ fun ColumnScope.GroupsSection( if (creating) { OutlinedTextField( + colors = fieldColors(), value = name, onValueChange = { name = it }, label = { Text("Group name") }, @@ -795,6 +796,7 @@ 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) }, @@ -816,6 +818,7 @@ 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) }, @@ -2923,6 +2926,7 @@ private fun ColumnScope.GroupMembers( if (renaming) { OutlinedTextField( + colors = fieldColors(), value = draftName, onValueChange = { draftName = it }, label = { Text("Group name") }, @@ -3165,6 +3169,7 @@ 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, @@ -3200,6 +3205,7 @@ fun ColumnScope.AddContactForm( val card = remember(username) { ContactCard.decode(username) } OutlinedTextField( + colors = fieldColors(), value = username, onValueChange = { username = it @@ -3828,6 +3834,7 @@ fun ColumnScope.MailboxesSection( } OutlinedTextField( + colors = fieldColors(), value = host, onValueChange = { host = it }, placeholder = { Text("relay.example.com", fontSize = 14.sp) }, @@ -3836,6 +3843,7 @@ fun ColumnScope.MailboxesSection( ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, placeholder = { Text("Your passphrase", fontSize = 14.sp) }, @@ -4031,6 +4039,7 @@ private fun EditMessageSheet( text = { Column { androidx.compose.material3.OutlinedTextField( + colors = fieldColors(), value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxWidth().testTag("edit-field"), @@ -4169,6 +4178,7 @@ 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/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 0e6008c..bed9077 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -145,6 +145,7 @@ private fun ColumnScope.FilesSetUp( Spacer(Modifier.height(Space.md)) OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Your Flamenet passphrase") }, visualTransformation = PasswordVisualTransformation(), @@ -153,6 +154,7 @@ private fun ColumnScope.FilesSetUp( ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = again, onValueChange = { again = it }, label = { Text("Type it again") }, visualTransformation = PasswordVisualTransformation(), @@ -187,6 +189,7 @@ 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(), @@ -452,6 +455,7 @@ 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"), @@ -510,6 +514,7 @@ 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 86ff598..6f3d960 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt @@ -187,6 +187,7 @@ 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) }, @@ -197,6 +198,7 @@ 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/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 8a9e939..721fa42 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -395,6 +395,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Spacer(Modifier.height(Space.xl)) OutlinedTextField( + colors = fieldColors(), value = relay, onValueChange = model::setRelay, label = { Text("Server") }, @@ -415,6 +416,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } OutlinedTextField( + colors = fieldColors(), value = handle, onValueChange = { handle = it }, label = { Text("Handle") }, @@ -429,6 +431,7 @@ 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") }, @@ -444,6 +447,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo } OutlinedTextField( + colors = fieldColors(), value = passphrase, onValueChange = { passphrase = it }, label = { Text("Passphrase") }, @@ -459,6 +463,7 @@ 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") }, @@ -569,6 +574,7 @@ 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") }, @@ -576,6 +582,7 @@ 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") }, 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 1d36f37..077a66f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -124,6 +124,7 @@ fun ColumnScope.MessagesScreen( Spacer(Modifier.height(Space.sm)) OutlinedTextField( + colors = fieldColors(), value = query.value, onValueChange = { query.value = it }, placeholder = { Text("Search conversations") }, 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 6db595f..631855e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt @@ -148,6 +148,7 @@ 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 28afbeb..fbb0482 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt @@ -56,6 +56,7 @@ 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, @@ -64,6 +65,7 @@ fun ParentalControlsSection() { ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( + colors = fieldColors(), value = repeatPin, onValueChange = { repeatPin = it }, placeholder = { Text("Repeat PIN", fontSize = 14.sp) }, singleLine = true, @@ -86,6 +88,7 @@ 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 97baa14..4d9ac5e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -357,6 +357,7 @@ 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"), @@ -453,6 +454,7 @@ fun StoryComposerDialog( } } androidx.compose.material3.OutlinedTextField( + colors = fieldColors(), value = caption.value, onValueChange = { caption.value = it }, placeholder = { Text("Add a caption…") }, @@ -464,6 +466,7 @@ 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) }, @@ -706,6 +709,7 @@ 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 d1b4122..0874187 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.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 @@ -154,6 +155,50 @@ fun groundBrush(): Brush = 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 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 40ae9c6..0cb23cc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt @@ -129,6 +129,7 @@ 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) },