The screens nobody had rendered get their margins back

Going flush to the screen edges was right for chrome, bands and rows, and
wrong for every screen I had not looked at. Calls, the contact detail and
the story feed were all laid out assuming the 20dp frame that is gone, so
their content sat hard against both sides of the phone and their trailing
controls ran off the right edge entirely: the call-back button on a call
row was half a button, and the contact rail's three actions were clipped.

Calls rows and alert rows are `PortalRow` now, like the conversation list.
`DetailRow` carries its own gutter. The story feed and its empty state do
too.

Two bands on the contact screen were unlabelled strips. They say what they
are over now, which is what a band is for.

`SectionBand` in ChatScreens duplicated `PortalBand` from the day
PortalChrome was added; I wrote the second one and left the first. There
is one band again, and the 19 call sites point at it.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-17 18:11:07 -04:00
co-authored by Claude Opus 5
parent 5bbc328076
commit 79aefbe707
3 changed files with 27 additions and 62 deletions
@@ -143,15 +143,11 @@ fun ColumnScope.CallsScreen(
.firstOrNull { it.userId == entry.peerId }
?.let { it.displayName.ifBlank { it.handle } }
?: "user ${entry.peerId}"
Row(
PortalRow(
modifier =
Modifier
.fillMaxWidth()
.clickable { model.requestCall(entry.peerId) }
.padding(vertical = Kit.rowGap / 2)
.testTag("call-${entry.peerId}-${entry.at}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
Avatar(
name = name,
@@ -229,7 +225,6 @@ fun ColumnScope.CallsScreen(
)
}
}
Divider()
}
}
}
@@ -681,31 +681,31 @@ fun ColumnScope.AccountScreen(
// Directly under the picture: status is the other thing people expect to
// find first on an account screen, and it was last.
SectionBand("Your status")
PortalBand("Your status")
SettingsSection { PresencePicker(model, phase.presence) }
SectionBand("Stories")
PortalBand("Stories")
SettingsSection { StoryAudienceSection(model, phase) }
// Wallet and Files are bar destinations, in the same place iOS puts them.
SectionBand("App lock")
PortalBand("App lock")
SettingsSection { AppLockSection() }
SectionBand("Media")
PortalBand("Media")
SettingsSection { MediaQualitySection() }
SectionBand("Your devices")
PortalBand("Your devices")
SettingsSection { RegisteredDevicesSection(model, phase) }
SectionBand("History backup")
PortalBand("History backup")
SettingsSection { HistoryBackupSection(model) }
// Sits with the other settings and not under anything privacy-shaped.
SectionBand("Display")
PortalBand("Display")
SettingsSection { DisplaySection() }
SectionBand()
PortalBand()
// The door to the alerts inbox. Alerts left the bar on 2026-08-28 with the
// note that the screen "survives for the badge tap-through from Account",
// and this is that tap-through; without it the screen had no way in.
@@ -724,22 +724,22 @@ fun ColumnScope.AccountScreen(
}
SettingsSection { NotificationSection() }
SectionBand("Network")
PortalBand("Network")
SettingsSection { NetworkSection(model, phase.buddies) }
SectionBand("Parental controls")
PortalBand("Parental controls")
SettingsSection { ParentalControlsSection() }
SectionBand("Contact list")
PortalBand("Contact list")
SettingsSection { ContactListPrivacySection(model) }
SectionBand()
PortalBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
SectionBand()
PortalBand()
SettingsSection { MailboxesSection(model, phase) }
SectionBand()
PortalBand()
SettingsSection {
// The row is the label; the field must not repeat it.
SettingsRow(label = "Display name") {}
@@ -790,7 +790,7 @@ fun ColumnScope.AccountScreen(
// -- Security (§19) ------------------------------------------------------.
SectionBand()
PortalBand()
SettingsSection {
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server
@@ -850,17 +850,17 @@ fun ColumnScope.AccountScreen(
}
}
SectionBand()
PortalBand()
SettingsSection { AppVersionSection(model) }
SectionBand()
PortalBand()
SettingsSection { DiagnosticsSection() }
SectionBand()
PortalBand()
SettingsSection { DonateSection() }
// The destructive pair, in their own section.
SectionBand()
PortalBand()
SettingsSection {
SettingsRow(
label = "Sign out",
@@ -1316,39 +1316,6 @@ private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
}
}
/**
* The 16pt band the kit puts between sections.
*/
/** Internal, not private: the contact detail uses the same band. */
@Composable
internal fun SectionBandPublic() = SectionBand()
@Composable
private fun SectionBand(label: String? = null) {
// The portal's own section furniture: a blue band ruled top and bottom.
// A bare gap reads as an app with nothing between its sections; this
// reads as a page with headings.
Column(modifier = Modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
Box(
modifier =
Modifier
.fillMaxWidth()
.background(Portal.band())
.padding(horizontal = Space.md, vertical = if (label == null) Space.sm else 7.dp),
) {
if (label != null) {
Text(
label.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
}
}
/**
* One settings row: label, an optional sentence under it, and one control on
@@ -62,7 +62,7 @@ fun ColumnScope.ContactDetail(
}
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(horizontal = Space.sm, vertical = Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
@@ -182,14 +182,14 @@ fun ColumnScope.ContactDetail(
Spacer(Modifier.height(Space.xl))
SectionBandPublic()
PortalBand("Their details")
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow("Username", buddy.handle.ifBlank { ", " })
DetailRow("User ID", buddy.userId.toString())
DetailRow("Status", buddy.presence.ifBlank { "unknown" })
}
SectionBandPublic()
PortalBand("Managing this contact")
Column(Modifier.padding(vertical = Space.sm)) {
DetailRow(
"Clear this conversation",
@@ -264,7 +264,10 @@ private fun DetailRow(
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
.padding(vertical = Kit.bubblePadding),
// The screen is flush to its edges now, so the row carries its
// own gutter. Without it the label and the value sit hard
// against both sides of the phone.
.padding(horizontal = Space.md, vertical = Kit.bubblePadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.md),
) {