White text boxes — matches ios!144

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 14:11:20 -04:00
co-authored by Claude Opus 5
parent b824e626e7
commit 8fa66cd603
10 changed files with 79 additions and 0 deletions
@@ -447,6 +447,7 @@ fun ColumnScope.GroupsSection(
if (creating) { if (creating) {
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Group name") }, label = { Text("Group name") },
@@ -795,6 +796,7 @@ fun ColumnScope.AccountScreen(
// same duplication the contact rows had. // same duplication the contact rows had.
SettingsRow(label = "Display name") {} SettingsRow(label = "Display name") {}
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = displayName, value = displayName,
onValueChange = { displayName = it }, onValueChange = { displayName = it },
placeholder = { Text("How others see you", fontSize = 14.sp) }, 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.", description = "Changing it signs this device out — that is what changing it means.",
) {} ) {}
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
placeholder = { Text("At least 8 characters", fontSize = 14.sp) }, placeholder = { Text("At least 8 characters", fontSize = 14.sp) },
@@ -2923,6 +2926,7 @@ private fun ColumnScope.GroupMembers(
if (renaming) { if (renaming) {
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = draftName, value = draftName,
onValueChange = { draftName = it }, onValueChange = { draftName = it },
label = { Text("Group name") }, label = { Text("Group name") },
@@ -3165,6 +3169,7 @@ fun ColumnScope.AddContactForm(
fontSize = 12.sp, color = Palette.SecondaryText) fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = gatePin, onValueChange = { gatePin = it; gateWrong = false }, value = gatePin, onValueChange = { gatePin = it; gateWrong = false },
placeholder = { Text("Parent PIN", fontSize = 14.sp) }, placeholder = { Text("Parent PIN", fontSize = 14.sp) },
singleLine = true, singleLine = true,
@@ -3200,6 +3205,7 @@ fun ColumnScope.AddContactForm(
val card = remember(username) { ContactCard.decode(username) } val card = remember(username) { ContactCard.decode(username) }
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = username, value = username,
onValueChange = { onValueChange = {
username = it username = it
@@ -3828,6 +3834,7 @@ fun ColumnScope.MailboxesSection(
} }
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
placeholder = { Text("relay.example.com", fontSize = 14.sp) }, placeholder = { Text("relay.example.com", fontSize = 14.sp) },
@@ -3836,6 +3843,7 @@ fun ColumnScope.MailboxesSection(
) )
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = passphrase, value = passphrase,
onValueChange = { passphrase = it }, onValueChange = { passphrase = it },
placeholder = { Text("Your passphrase", fontSize = 14.sp) }, placeholder = { Text("Your passphrase", fontSize = 14.sp) },
@@ -4031,6 +4039,7 @@ private fun EditMessageSheet(
text = { text = {
Column { Column {
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
modifier = Modifier.fillMaxWidth().testTag("edit-field"), modifier = Modifier.fillMaxWidth().testTag("edit-field"),
@@ -4169,6 +4178,7 @@ internal fun ReportSheet(
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = note, value = note,
onValueChange = { note = it }, onValueChange = { note = it },
label = { Text("Anything you want to add (optional)") }, label = { Text("Anything you want to add (optional)") },
@@ -145,6 +145,7 @@ private fun ColumnScope.FilesSetUp(
Spacer(Modifier.height(Space.md)) Spacer(Modifier.height(Space.md))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = passphrase, onValueChange = { passphrase = it }, value = passphrase, onValueChange = { passphrase = it },
label = { Text("Your Flamenet passphrase") }, label = { Text("Your Flamenet passphrase") },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
@@ -153,6 +154,7 @@ private fun ColumnScope.FilesSetUp(
) )
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = again, onValueChange = { again = it }, value = again, onValueChange = { again = it },
label = { Text("Type it again") }, label = { Text("Type it again") },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
@@ -187,6 +189,7 @@ private fun ColumnScope.FilesUnlock(
fontSize = 14.sp, color = Palette.SecondaryText) fontSize = 14.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.md)) Spacer(Modifier.height(Space.md))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = passphrase, onValueChange = { passphrase = it }, value = passphrase, onValueChange = { passphrase = it },
label = { Text("Passphrase") }, label = { Text("Passphrase") },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
@@ -452,6 +455,7 @@ private fun ColumnScope.AddFileSheet(
Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp) Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = name, onValueChange = { name = it }, value = name, onValueChange = { name = it },
label = { Text("Name") }, singleLine = true, label = { Text("Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-add-name"), modifier = Modifier.fillMaxWidth().testTag("files-add-name"),
@@ -510,6 +514,7 @@ private fun ColumnScope.FolderNameSheet(
Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp) Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = name, onValueChange = { name = it }, value = name, onValueChange = { name = it },
label = { Text("Name") }, singleLine = true, label = { Text("Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-folder-name"), modifier = Modifier.fillMaxWidth().testTag("files-folder-name"),
@@ -187,6 +187,7 @@ private fun PassphraseDialog(
Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText) Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = phrase, value = phrase,
onValueChange = { phrase = it }, onValueChange = { phrase = it },
placeholder = { Text("Passphrase", fontSize = 14.sp) }, placeholder = { Text("Passphrase", fontSize = 14.sp) },
@@ -197,6 +198,7 @@ private fun PassphraseDialog(
if (requireRepeat) { if (requireRepeat) {
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = repeat, value = repeat,
onValueChange = { repeat = it }, onValueChange = { repeat = it },
placeholder = { Text("Repeat passphrase", fontSize = 14.sp) }, placeholder = { Text("Repeat passphrase", fontSize = 14.sp) },
@@ -395,6 +395,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
Spacer(Modifier.height(Space.xl)) Spacer(Modifier.height(Space.xl))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = relay, value = relay,
onValueChange = model::setRelay, onValueChange = model::setRelay,
label = { Text("Server") }, label = { Text("Server") },
@@ -415,6 +416,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
} }
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = handle, value = handle,
onValueChange = { handle = it }, onValueChange = { handle = it },
label = { Text("Handle") }, label = { Text("Handle") },
@@ -429,6 +431,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// would be collecting something nobody needs. // would be collecting something nobody needs.
if (needsEmail) { if (needsEmail) {
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("Email") }, label = { Text("Email") },
@@ -444,6 +447,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
} }
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = passphrase, value = passphrase,
onValueChange = { passphrase = it }, onValueChange = { passphrase = it },
label = { Text("Passphrase") }, 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 // this device to refuse an under-13 sign-up, then forgotten. Nothing
// about age is sent to any server. // about age is sent to any server.
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = birthYear, value = birthYear,
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
label = { Text("Year of birth") }, label = { Text("Year of birth") },
@@ -569,6 +574,7 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
) { Text("Send a code") } ) { Text("Send a code") }
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = code, value = code,
onValueChange = { code = it }, onValueChange = { code = it },
label = { Text("Code from the email") }, label = { Text("Code from the email") },
@@ -576,6 +582,7 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
modifier = Modifier.fillMaxWidth().testTag("reset-code"), modifier = Modifier.fillMaxWidth().testTag("reset-code"),
) )
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = fresh, value = fresh,
onValueChange = { fresh = it }, onValueChange = { fresh = it },
label = { Text("New password") }, label = { Text("New password") },
@@ -124,6 +124,7 @@ fun ColumnScope.MessagesScreen(
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = query.value, value = query.value,
onValueChange = { query.value = it }, onValueChange = { query.value = it },
placeholder = { Text("Search conversations") }, placeholder = { Text("Search conversations") },
@@ -148,6 +148,7 @@ fun ColumnScope.NetworkSection(
Spacer(Modifier.height(Space.md)) Spacer(Modifier.height(Space.md))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = draft, value = draft,
onValueChange = { draft = it }, onValueChange = { draft = it },
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) }, placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
@@ -56,6 +56,7 @@ fun ParentalControlsSection() {
) )
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = newPin, onValueChange = { newPin = it }, value = newPin, onValueChange = { newPin = it },
placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) }, placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) },
singleLine = true, singleLine = true,
@@ -64,6 +65,7 @@ fun ParentalControlsSection() {
) )
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = repeatPin, onValueChange = { repeatPin = it }, value = repeatPin, onValueChange = { repeatPin = it },
placeholder = { Text("Repeat PIN", fontSize = 14.sp) }, placeholder = { Text("Repeat PIN", fontSize = 14.sp) },
singleLine = true, singleLine = true,
@@ -86,6 +88,7 @@ fun ParentalControlsSection() {
fontSize = 12.sp, color = Palette.SecondaryText) fontSize = 12.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = pinEntry, onValueChange = { pinEntry = it; wrong = false }, value = pinEntry, onValueChange = { pinEntry = it; wrong = false },
placeholder = { Text("Parent PIN", fontSize = 14.sp) }, placeholder = { Text("Parent PIN", fontSize = 14.sp) },
singleLine = true, singleLine = true,
@@ -357,6 +357,7 @@ private fun StoryActions(
+ "saw this story can read it.", fontSize = 13.sp) + "saw this story can read it.", fontSize = 13.sp)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = draft, onValueChange = { draft = it }, value = draft, onValueChange = { draft = it },
placeholder = { Text("Message") }, placeholder = { Text("Message") },
modifier = Modifier.fillMaxWidth().testTag("story-reply-field"), modifier = Modifier.fillMaxWidth().testTag("story-reply-field"),
@@ -453,6 +454,7 @@ fun StoryComposerDialog(
} }
} }
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = caption.value, value = caption.value,
onValueChange = { caption.value = it }, onValueChange = { caption.value = it },
placeholder = { Text("Add a caption…") }, placeholder = { Text("Add a caption…") },
@@ -464,6 +466,7 @@ fun StoryComposerDialog(
// §6.13 — attach a live stream. The picked picture becomes its // §6.13 — attach a live stream. The picked picture becomes its
// poster: what people see before deciding whether to connect. // poster: what people see before deciding whether to connect.
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
colors = fieldColors(),
value = streamUrl.value, value = streamUrl.value,
onValueChange = { streamUrl.value = it }, onValueChange = { streamUrl.value = it },
label = { Text("Live stream link (optional)", fontSize = 12.sp) }, 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") }, title = { Text("New story") },
text = { text = {
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
placeholder = { Text("Say something") }, placeholder = { Text("Say something") },
@@ -14,6 +14,7 @@ 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.Brush
import androidx.compose.material3.OutlinedTextFieldDefaults
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
@@ -154,6 +155,50 @@ fun groundBrush(): Brush =
fun accentBrush(): Brush = fun accentBrush(): Brush =
Brush.verticalGradient(listOf(Palette.GradientTop, Palette.GradientBottom)) 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. */ /** Corner radii, matching `Theme.Radius` on iOS. */
object Radius { object Radius {
val small = 10.dp val small = 10.dp
@@ -129,6 +129,7 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) {
Text("Already have one?", fontWeight = FontWeight.Medium, fontSize = 16.sp) Text("Already have one?", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
OutlinedTextField( OutlinedTextField(
colors = fieldColors(),
value = phrase, value = phrase,
onValueChange = { phrase = it }, onValueChange = { phrase = it },
placeholder = { Text("Your 12 or 24 word recovery phrase", fontSize = 13.sp) }, placeholder = { Text("Your 12 or 24 word recovery phrase", fontSize = 13.sp) },