Android: the layout, not just the parts inside it

The last pass changed row internals and left the screen architecture
alone, and the screen architecture is what makes the kit look like the
kit. Reported back as "looks exactly the same and nothing like the
sketch", which was accurate.

Four cards -- a presence picker, a paragraph about what stories are,
contacts, groups -- each on its own white surface with a heading. The
kit's messages screen is one flat list under a title, a search box and
a strip of stories. No amount of restyling the rows inside those cards
was going to get there; the cards were the thing.

🔴 The type scale was the other half, and it was invisible from inside
a single screen. Material 3's defaults are considerably larger than the
kit's, so a section heading rendered at 24sp where the kit says 16 and
every screen inherited it. Set once in the theme, which fixes screens
nobody has touched.

Stories was a heading, a Post button, and -- with nothing posted, which
is the normal state -- three lines of prose explaining what a story is.
Prose where the design has a row of circles is the loudest possible
signal that this is not the same app. It is a strip of faces now, with
the add-circle always first so there is always somewhere to tap.

The tab bar is the kit's floating pill rather than a row of text
buttons; it is the most recognisable single element in the design.
Group rows carry the same avatar and the same 72dp pitch as contact
rows, so the two read as one list rather than two that follow each
other. The presence picker moved to Account, where the kit would put
it, instead of being dropped with the card it lived in.

The buddy list is plain rows in the page's own scroll now. It was a
lazy list bounded at 320dp -- a small scrolling window inside a
scrolling page, which is exactly what a flat list is not. The bound
existed because a lazy list in a scrolling Column is measured with
infinite height and crashes; rendering rows directly sidesteps that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 13:58:08 -04:00
co-authored by Claude Opus 5
parent b48d71a75a
commit 7218a9b175
3 changed files with 236 additions and 103 deletions
@@ -75,7 +75,11 @@ import androidx.compose.foundation.layout.heightIn
* else's whereabouts, and this product should not make one it cannot support. * else's whereabouts, and this product should not make one it cannot support.
*/ */
@Composable @Composable
fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) { fun ColumnScope.BuddyList(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
query: String = "",
) {
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf("") }
@@ -84,7 +88,7 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("Contacts", style = MaterialTheme.typography.headlineSmall) Text("Contacts", style = MaterialTheme.typography.titleMedium)
TextButton(onClick = { adding = !adding }, modifier = Modifier.testTag("add-contact")) { TextButton(onClick = { adding = !adding }, modifier = Modifier.testTag("add-contact")) {
Text(if (adding) "Cancel" else "Add") Text(if (adding) "Cancel" else "Add")
} }
@@ -174,8 +178,23 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
// the full height it is offered, so a single contact still reserved a // the full height it is offered, so a single contact still reserved a
// fixed slab of empty white. A max lets the card size to its content // fixed slab of empty white. A max lets the card size to its content
// and start scrolling only once the list is actually long. // and start scrolling only once the list is actually long.
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp).testTag("buddies")) { // 🔴 **Plain rows in the page's own scroll, not a bounded lazy list.**
items(phase.buddies, key = { it.userId }) { buddy -> // The list lived in a card with `heightIn(max = 320.dp)` because a lazy
// list inside a scrolling Column is measured with infinite height and
// crashes. Rendering the rows directly sidesteps that entirely — and the
// 320 cap was itself a compromise that made the list a small scrolling
// window inside a scrolling page, which is exactly what the kit's flat
// list is not.
//
// ⚠️ No recycling. Fine for a contact list of tens; if this ever holds
// hundreds it wants a single lazy list owning the whole screen's scroll,
// not a nested one.
val shown = phase.buddies.filter {
query.isBlank() ||
it.displayName.contains(query, true) || it.handle.contains(query, true)
}
Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) {
shown.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle } val name = buddy.displayName.ifBlank { buddy.handle }
.ifBlank { "user ${buddy.userId}" } .ifBlank { "user ${buddy.userId}" }
Row( Row(
@@ -317,6 +336,8 @@ private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerV
fun ColumnScope.GroupsSection( fun ColumnScope.GroupsSection(
model: MessengerViewModel, model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn, phase: MessengerViewModel.Phase.SignedIn,
/** Filters this section from the one search box above the whole list. */
query: String = "",
) { ) {
var creating by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
@@ -327,7 +348,7 @@ fun ColumnScope.GroupsSection(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("Groups", style = MaterialTheme.typography.headlineSmall) Text("Groups", style = MaterialTheme.typography.titleMedium)
TextButton( TextButton(
onClick = { creating = !creating }, onClick = { creating = !creating },
modifier = Modifier.testTag("new-group"), modifier = Modifier.testTag("new-group"),
@@ -388,21 +409,32 @@ fun ColumnScope.GroupsSection(
) )
} }
phase.groups.forEach { group -> val shownGroups = phase.groups.filter {
query.isBlank() || it.name.contains(query, true)
}
shownGroups.forEach { group ->
val thread = io.flamenet.e2e.Vault.threadForGroup(group.id) val thread = io.flamenet.e2e.Vault.threadForGroup(group.id)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { model.openGroup(group) } .clickable { model.openGroup(group) }
.padding(vertical = 12.dp) // ⚠️ Same 72dp pitch as a contact row, from the same tokens. A
// group row measured differently sat at a different rhythm in
// the same list, which reads as two lists that happen to
// follow each other.
.padding(vertical = Kit.rowGap / 2)
.testTag("group-${group.id}"), .testTag("group-${group.id}"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) { ) {
// The same avatar shape as a contact, so the two kinds of row read
// as one list. A group has no presence, so no dot.
Avatar(name = group.name.ifBlank { "Group" }, online = false)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
group.name.ifBlank { "Group" }, group.name.ifBlank { "Group" },
fontWeight = if (thread in phase.unread) FontWeight.Bold else null, fontSize = 16.sp,
fontWeight = if (thread in phase.unread) FontWeight.Bold else FontWeight.Medium,
) )
Text( Text(
// "You" first: you are a member of your own group and are // "You" first: you are a member of your own group and are
@@ -414,7 +446,9 @@ fun ColumnScope.GroupsSection(
?.let { b -> b.displayName.ifBlank { b.handle } } ?.let { b -> b.displayName.ifBlank { b.handle } }
?: "user $id" ?: "user $id"
}, },
style = MaterialTheme.typography.bodySmall, fontSize = 14.sp,
color = Palette.SecondaryText,
maxLines = 1,
) )
} }
if (thread in phase.unread) { if (thread in phase.unread) {
@@ -535,6 +569,12 @@ fun ColumnScope.AccountScreen(
SettingsCard { SettingsCard {
Text("Profile", style = MaterialTheme.typography.titleMedium) Text("Profile", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm)) Spacer(Modifier.height(Space.sm))
// ⚠️ Moved here from the contacts screen. The kit's list screen has no
// presence control on it, and this is where the other facts about you
// already live — but it is a setting, not decoration, so it is kept
// rather than dropped along with the card it used to sit in.
PresencePicker(model, phase.presence)
Spacer(Modifier.height(Space.lg))
OutlinedTextField( OutlinedTextField(
value = displayName, value = displayName,
onValueChange = { displayName = it }, onValueChange = { displayName = it },
@@ -1245,44 +1285,78 @@ fun ColumnScope.StoryStrip(
LaunchedEffect(Unit) { model.refreshStories() } LaunchedEffect(Unit) { model.refreshStories() }
Row( // 🔴 **A strip of faces, which is what the kit's "Recent" row is.** This
modifier = Modifier.fillMaxWidth(), // was a heading, a Post button, and — with nothing posted, which is the
horizontalArrangement = Arrangement.SpaceBetween, // normal state — three lines of prose explaining what a story is. Prose
verticalAlignment = Alignment.CenterVertically, // where the design has a row of circles is the single loudest thing
) { // telling you this is not the same app. The explanation still exists; it
Text("Stories", style = MaterialTheme.typography.titleMedium) // moved to the one place it is actually needed, under the button that
TextButton( // posts one.
onClick = { pickStory.launch("image/*") }, //
modifier = Modifier.testTag("post-story"), // ⚠️ The add-circle is always first and always present, so the strip never
) { Text("Post") } // collapses to nothing and there is always somewhere to tap.
}
if (phase.stories.isEmpty()) {
Text(
"Nothing here. A story goes to your contacts and disappears after a day — on your "
+ "device and theirs, because their app deletes it too. It is not a secret you "
+ "can take back.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("stories-empty"),
)
return
}
LazyRow( LazyRow(
modifier = Modifier.fillMaxWidth().testTag("stories"), modifier = Modifier.fillMaxWidth().testTag("stories"),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(Space.md),
) { ) {
item {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.testTag("post-story"),
) {
Box(
modifier = Modifier
.size(Kit.avatar)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { pickStory.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Text("+", fontSize = 22.sp, color = MaterialTheme.colorScheme.primary)
}
Text("Post", fontSize = 12.sp, color = Palette.SecondaryText)
}
}
items(phase.stories, key = { it.id }) { story -> items(phase.stories, key = { it.id }) { story ->
val label = phase.buddies.firstOrNull { it.userId == story.authorId } val label = phase.buddies.firstOrNull { it.userId == story.authorId }
?.let { it.displayName.ifBlank { it.handle } } ?.let { it.displayName.ifBlank { it.handle } }
?: if (story.mine) "You" else "#${story.authorId}" ?: if (story.mine) "You" else "#${story.authorId}"
TextButton( Column(
onClick = { model.openStory(story) }, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.testTag("story-${story.id}"), modifier = Modifier.testTag("story-${story.id}"),
) { ) {
// Unseen is marked rather than styled away: a strip where read Box(
// and unread look alike is a strip nobody comes back to. modifier = Modifier
Text(if (story.seen) label else "• $label") .size(Kit.avatar)
.clip(CircleShape)
// Unseen is marked rather than styled away: a strip
// where read and unread look alike is a strip nobody
// comes back to. A ring, since there is no room for a
// bullet beside a 48dp circle.
.background(
if (story.seen) Palette.AvatarFill
else MaterialTheme.colorScheme.primary
)
.padding(if (story.seen) 0.dp else 2.dp)
.clip(CircleShape)
.background(Palette.AvatarFill)
.clickable { model.openStory(story) },
contentAlignment = Alignment.Center,
) {
Text(
label.take(1).uppercase(),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
label,
fontSize = 12.sp,
color = Palette.SecondaryText,
maxLines = 1,
modifier = Modifier.widthIn(max = Kit.avatar + 8.dp),
)
} }
} }
} }
@@ -4,6 +4,16 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.draw.clip
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.background
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -155,11 +165,16 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// BELOW the lazy list, not just the list itself. And // BELOW the lazy list, not just the list itself. And
// keep the buddy list bounded — remove that height and // keep the buddy list bounded — remove that height and
// this does crash. // this does crash.
// ⚠️ Tighter than it was. Every section already carries
// its own padding, so a 12dp gap between them on top of
// that left the list airy in a way the kit is not — its
// rows sit on a 72dp pitch with nothing extra between
// the groups of them.
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(Space.sm),
) { ) {
when (current.screen) { when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
@@ -347,8 +362,18 @@ private fun BottomNav(
phase: MessengerViewModel.Phase.SignedIn, phase: MessengerViewModel.Phase.SignedIn,
) { ) {
val unreadNotes = phase.notifications.count { !it.read } val unreadNotes = phase.notifications.count { !it.read }
// 🔴 **A floating pill, not a full-width strip.** The kit's tab bar is a
// 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall
// band — it reads as a control resting on the page rather than as the
// bottom of the window. This is the single most recognisable thing about
// the design and the app had a plain row of text buttons.
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.heightIn(min = Kit.pillHeight)
.clip(RoundedCornerShape(Kit.pillRadius))
.background(MaterialTheme.colorScheme.surfaceVariant),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS, NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
@@ -389,76 +414,88 @@ private fun ColumnScope.SignedIn(
model: MessengerViewModel, model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn, phase: MessengerViewModel.Phase.SignedIn,
) { ) {
Text(phase.handle.ifBlank { "Signed in" }, style = MaterialTheme.typography.headlineMedium) var query by remember { mutableStateOf("") }
Text("${phase.relay} · ${phase.deviceId.take(8)}",
style = MaterialTheme.typography.bodySmall, // 🔴 **A list, not a stack of explanatory cards.** This screen used to be
// four cards — a presence picker, a paragraph about what stories are, the
// contacts, the groups — each on its own white surface with a heading. The
// kit's messages screen is one flat list under a title, a search box and a
// strip of stories, and no amount of restyling the ROWS inside those cards
// was ever going to make it read like the design. The cards were the
// problem. See `DESIGN.md`.
//
// ⚠️ The presence picker moved to Account rather than being deleted: the
// kit has no such control on its list screen, and Account is where the
// other things about you already live.
// App bar, per the kit: title at 24 bold, actions on the trailing edge.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
phase.handle.ifBlank { "Signed in" },
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Text(
"${phase.relay} · ${phase.deviceId.take(8)}",
fontSize = 12.sp,
color = Palette.SecondaryText, color = Palette.SecondaryText,
modifier = Modifier.testTag("device-id")) modifier = Modifier.testTag("device-id"),
)
}
}
// ⚠️ **Screen level, not inside Contacts.** There is ONE error slot shared
// by everything on this screen, and it used to be rendered inside
// BuddyList — so a story that failed to post, or a group that could not be
// created, appeared underneath the Contacts heading and read as a contacts
// problem. Observed with a rate-limited story post reported under Contacts.
phase.error?.let { phase.error?.let {
// ⚠️ **Screen level, not inside Contacts.** There is ONE error slot
// shared by everything here, and it used to render inside the buddy
// list — so a story that failed to post appeared under the Contacts
// heading and read as a contacts problem.
Text( Text(
it, it,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("error").padding(top = Space.sm), fontSize = 14.sp,
modifier = Modifier.testTag("error"),
) )
} }
// Each section on its own card. ⚠️ The sections themselves are unchanged — // Search, as the kit's 48-tall pill. Filters what is already here; it does
// same composables, same testTags, same order — because this is a skin over // not ask the relay anything, so it costs nothing and needs no button.
// screens that work, and moving them would mean re-pointing the UI tests Box(
// for a visual change. modifier = Modifier
Spacer(Modifier.height(Space.lg)) .fillMaxWidth()
Surface( .heightIn(min = 48.dp)
shape = RoundedCornerShape(Radius.medium), .clip(RoundedCornerShape(Kit.pillRadius))
color = MaterialTheme.colorScheme.surface, .background(MaterialTheme.colorScheme.surfaceVariant)
shadowElevation = 2.dp, .padding(horizontal = Kit.gutter, vertical = 12.dp),
modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart,
) { ) {
Column(Modifier.padding(Space.lg)) { PresencePicker(model, phase.presence) } if (query.isEmpty()) {
Text("Search", fontSize = 14.sp, color = Palette.SecondaryText)
}
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
textStyle = LocalTextStyle.current.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth().testTag("search"),
)
} }
Spacer(Modifier.height(Space.md)) StoryStrip(model, phase)
Surface(
shape = RoundedCornerShape(Radius.medium), HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f))
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp, GroupsSection(model, phase, query)
modifier = Modifier.fillMaxWidth(), BuddyList(model, phase, query)
) {
Column(Modifier.padding(Space.lg)) { StoryStrip(model, phase) }
} }
Spacer(Modifier.height(Space.md))
// ⚠️ **Bounded on purpose.** BuddyList's LazyColumn carries `weight(1f)`,
// which used to resolve against the whole signed-in Column. Inside a card
// with no height of its own it resolves against infinity instead, so the
// contacts card grew to fill the screen and pushed Groups and the tab bar
// off the bottom entirely. A max height keeps the list scrollable inside
// its own card and leaves the rest of the screen reachable.
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(Space.lg)) { BuddyList(model, phase) }
}
Spacer(Modifier.height(Space.md))
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(Space.lg)) { GroupsSection(model, phase) }
}
Spacer(Modifier.height(Space.lg))
}
@Composable @Composable
private fun ColumnScope.VaultUnreadable( private fun ColumnScope.VaultUnreadable(
@@ -132,16 +132,38 @@ private val FlamenetShapes = Shapes(
* cover redistribution, and the platform default already reads well at these * cover redistribution, and the platform default already reads well at these
* sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`. * sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`.
*/ */
/**
* The design kit's type scale, applied globally.
*
* 🔴 **This is the single biggest reason the app did not look like the kit.**
* Material 3's defaults are considerably larger than the kit's, and every
* screen inherits them — so a section heading rendered at 24sp where the kit
* says 16, and a body line at 16 where the kit says 14. Restyling individual
* screens against those defaults is pushing against the whole theme; setting
* the scale here fixes every screen at once, including ones nobody has
* touched yet.
*
* Names on the left are Material's; sizes on the right are the kit's. See
* `DESIGN.md` — H1 40, H2 32, H3 24, H4 20, Sub 16/14/12, Body 16/14,
* Description 12/10.
*/
private val FlamenetTypography = Typography().let { base -> private val FlamenetTypography = Typography().let { base ->
base.copy( base.copy(
headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold), headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold),
headlineMedium = base.headlineMedium.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold), headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold),
titleLarge = base.titleLarge.copy(fontSize = 22.sp, fontWeight = FontWeight.SemiBold), headlineSmall = base.headlineSmall.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold),
titleMedium = base.titleMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold), titleLarge = base.titleLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold),
// Sub 3 — what a section label is, not a headline.
titleMedium = base.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium),
titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
bodyLarge = base.bodyLarge.copy(fontSize = 16.sp), bodyLarge = base.bodyLarge.copy(fontSize = 16.sp),
bodyMedium = base.bodyMedium.copy(fontSize = 15.sp), bodyMedium = base.bodyMedium.copy(fontSize = 14.sp),
labelLarge = base.labelLarge.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), bodySmall = base.bodySmall.copy(fontSize = 12.sp),
labelSmall = base.labelSmall.copy(letterSpacing = 0.6.sp, fontWeight = FontWeight.SemiBold), labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
labelMedium = base.labelMedium.copy(fontSize = 12.sp),
// Description 2 — the timestamp above a bubble.
labelSmall = base.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp,
fontWeight = FontWeight.Normal),
) )
} }