feat: hold every screen's content to a readable line, and centre it in the window

Phase 6, step 5, first half. Every one of the 40 screens rendered a single column
that filled whatever width it was given, so on a 1800dp desktop window a
paragraph became a 1800dp line -- long enough that the eye loses the start of the
next one -- and a six-character text field stretched to 1700dp.

M3: *"across all breakpoints, adjust margins and type styles to keep text between
40–60 characters per line."*

**The measure is derived, not written down.** `readableContentWidth()` is
`bodyLarge`'s font size converted through the current density, times half an em
per character, times sixty: 480dp at the default text size. Writing `480.dp`
instead would be the same number today and wrong for anybody who has turned text
size up -- at 200% the same column holds thirty characters, silently, because the
text still fits. Deriving it means the column widens with the type and keeps its
sixty. `AverageCharacterAdvance` is the one estimate in it, named and documented,
because a proportional face has no character width and half an em is the standard
figure for mixed-case Latin prose.

Only the ceiling is enforced. The floor needs nothing: a 400dp compact window
less its two 16dp margins holds about 46 characters, which is inside the range,
and no cap can add characters to a window that has none. There is a test for
exactly that, so the claim is checked rather than asserted in a comment.

**The column is centred; the text is not.** Those are opposite things and it is
worth being explicit, because "centre it" is how the second one gets done by
accident. A centred column still has one straight leading edge for every row,
avatar and icon to align to, which is what the grids-and-spacing page asks for.
Centred text has none. The 91 `TextAlign.Center` uses are a separate question and
a separate commit.

**Applied at 49 sites in one pass**, at the point every screen consumes its
`Scaffold`'s padding -- the one place in each file that is reliably the top of the
content. Below 480dp it is not a cap, an inset or a centring; it is nothing, so
no phone layout moves.

**Verified by measuring a real composition, not by reading the code.**
`readableContent()` is `fillMaxWidth` then `wrapContentWidth` then `widthIn`, and
every permutation of those three compiles and renders something that looks right
in a phone-width preview. This needed `compose.desktop.uiTestJUnit4` in `jvmTest`
-- pinned to the same 1.11.1 as the rest of Compose Multiplatform, test-only --
and `runDesktopComposeUiTest(width = 1400)`, which gives a window that genuinely
is 1400 pixels across at density 1.

Four assertions, and they bite: swapping the last two modifiers makes the
1400dp case report `Actual width is 1400.0.dp, expected 480.0.dp`, which is the
"centred but never capped" failure the doc comment names. The same test also
pins `currentBreakpoint()` to the real window at all five widths -- 400, 700,
1000, 1400, 1800 -- with the screen margin following. A version of it that
measured the parent's constraints rather than the window would answer `Compact`
everywhere and pass every unit test in the suite.

37 theme tests green; android and desktop both compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-08 07:31:33 +02:00
1 parent b5bb0042b1
commit 05e80bf099
44 files changed
+380 -29

No files matched your search

+9
View File
@@ -131,6 +131,15 @@ kotlin {
// needs a coroutine, and a scheduler, to assert it in.
implementation(libs.kotlinx.coroutinesTest)
}
jvmTest.dependencies {
// A layout modifier cannot be asserted by reading it. `readableContent()` is
// three modifiers whose order decides whether the content is capped, centred,
// both or neither, and every ordering compiles and renders something -- so the
// check has to be a measurement of a real composition. Pinned to the same
// version as the rest of Compose Multiplatform; test-only.
implementation(compose.desktop.uiTestJUnit4)
implementation(libs.kotlin.testJunit)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
@@ -69,6 +69,7 @@ import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ButtonDefaults
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -121,6 +122,7 @@ fun ActiveProfileScreen(
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
@@ -85,6 +85,7 @@ import mantra.composeapp.generated.resources.source_dialect
import mantra.composeapp.generated.resources.url
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -229,7 +230,7 @@ fun AddArtifactScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
// TODO: Check that we have direct message relays for this user...
Column(
@@ -66,6 +66,7 @@ import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_
import mantra.composeapp.generated.resources.add_chapter_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -217,7 +218,7 @@ fun AddChapterScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!addChapterUIState.canSign) {
@@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.language
import mantra.composeapp.generated.resources.propose_dialect
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -195,7 +196,7 @@ fun AddDialectScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receiv
import mantra.composeapp.generated.resources.to_join_the_chat_room
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -135,7 +136,7 @@ fun AddMemberToChatRoomConfirmationScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
@@ -67,6 +67,7 @@ import mantra.composeapp.generated.resources.translate_into_which_dialect
import mantra.composeapp.generated.resources.translate
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -218,7 +219,7 @@ fun AddTranslationArtifactVersionScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (!addTranslationUIState.canSign) {
@@ -66,6 +66,7 @@ import mantra.composeapp.generated.resources.chapter
import mantra.composeapp.generated.resources.words_characters
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -120,7 +121,7 @@ fun ArtifactDetailScreen(
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
// Chapters
@@ -50,6 +50,7 @@ import mantra.composeapp.generated.resources.chunks
import mantra.composeapp.generated.resources.words_characters
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -103,7 +104,7 @@ fun ChapterDetailScreen(
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
item {
@@ -41,6 +41,7 @@ import mantra.composeapp.generated.resources.what_should_people_know_about_you
import mantra.composeapp.generated.resources.what_will_be_discussed_in_this_chat_room
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@Composable
fun ChatRoomCreationScreen(
@@ -57,7 +58,7 @@ fun ChatRoomCreationScreen(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxWidth()
) {
Column(
@@ -94,6 +94,7 @@ import mantra.composeapp.generated.resources.recovered_of_event_s
import mantra.composeapp.generated.resources.recovered_of_still_unreadable
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -146,7 +147,7 @@ fun ChatRoomDetailScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
@@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.private_message_to
import mantra.composeapp.generated.resources.private_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -150,7 +151,7 @@ fun ChatRoomMessagingScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth()
@@ -61,6 +61,7 @@ import mantra.composeapp.generated.resources.you_are_about_to_create_a_nostr_pro
import mantra.composeapp.generated.resources.you_will_be_in_full_control_of_this_profile
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -85,6 +86,7 @@ fun CreateProfileScreen(
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -45,6 +45,7 @@ import mantra.composeapp.generated.resources.back
import mantra.composeapp.generated.resources.not_now
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
/**
* The frame every ChillDKG approval screen sits in: the view model, the states
@@ -95,7 +96,7 @@ internal fun DkgApprovalScaffold(
) { padding ->
when (val state = dkgRitualViewModel.dkgRitualUIState) {
is DkgRitualUIState.Loading -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -103,7 +104,7 @@ internal fun DkgApprovalScaffold(
}
is DkgRitualUIState.Error -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -118,7 +119,7 @@ internal fun DkgApprovalScaffold(
// on the way in, which looks exactly like the screen not working.
if (state.session == null) {
Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -138,6 +139,7 @@ internal fun DkgApprovalScaffold(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.readableContent()
.padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space150)
@@ -168,6 +170,7 @@ internal fun DkgApprovalScaffold(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.readableContent()
.verticalScroll(rememberScrollState())
.padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.containerPadding),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)
@@ -101,6 +101,7 @@ import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_w
import mantra.composeapp.generated.resources.shared_key_for
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
/**
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
@@ -240,6 +241,7 @@ fun DkgRitualScreen(
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space125),
@@ -71,6 +71,7 @@ import mantra.composeapp.generated.resources.signed_their_part
import mantra.composeapp.generated.resources.events_signed_together
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
/**
* One signing session, and the member's decision about it.
@@ -136,7 +137,7 @@ fun FrostSigningScreen(
is FrostSigningUIState.Loading -> Loading(padding)
is FrostSigningUIState.Error -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -163,6 +164,7 @@ fun FrostSigningScreen(
Column(
modifier = Modifier
.padding(padding)
.readableContent()
.fillMaxSize()
.verticalScroll(scrollState)
.padding(MaterialTheme.spacing.space250),
@@ -320,7 +322,7 @@ fun FrostSigningScreen(
@Composable
private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -65,6 +65,7 @@ import mantra.composeapp.generated.resources.something_went_wrong
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -96,7 +97,7 @@ fun HomeScreen(
HomeScreenUIState.Error -> {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
ErrorState()
@@ -173,7 +174,7 @@ fun HomeScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
PrimaryScrollableTabRow(
modifier = Modifier.padding(MaterialTheme.spacing.space125),
@@ -19,6 +19,7 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.functionality_coming_soon_2
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@Composable
fun ImplementationPendingScreen(
@@ -27,7 +28,7 @@ fun ImplementationPendingScreen(
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.space250).fillMaxSize(),
@@ -58,6 +58,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import mantra.composeapp.generated.resources.key_package_published
import mantra.composeapp.generated.resources.key_package_rotated
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -116,6 +117,7 @@ fun KeyPackageManagementScreen(
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
@@ -31,6 +31,7 @@ import mantra.composeapp.generated.resources.sign_in
import mantra.composeapp.generated.resources.sign_in_to_torch_via_nsec_or_remote_signer
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@Composable
fun LandingScreen(
@@ -41,6 +42,7 @@ fun LandingScreen(
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@Composable
fun LoadingScreen(
@@ -18,6 +19,7 @@ fun LoadingScreen(
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
press.mantra.compose.ui.composable.widgets.LoadingDataIndicator(
text = text
@@ -41,6 +41,7 @@ import mantra.composeapp.generated.resources.we_couldn_t_find_your_nostr_event_p
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -107,7 +108,7 @@ fun NostrEventDetailScreen(
else -> {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -65,6 +65,7 @@ import mantra.composeapp.generated.resources.waiting_for_you
import mantra.composeapp.generated.resources.of_them_could_not_be_read
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
/**
* Everything the group has asked its shared key to sign.
@@ -128,7 +129,7 @@ fun ProposalListScreen(
) { padding ->
when (val state = proposalListViewModel.proposalListUIState) {
is ProposalListUIState.Loading -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -136,7 +137,7 @@ fun ProposalListScreen(
}
is ProposalListUIState.Error -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
@@ -155,7 +156,7 @@ fun ProposalListScreen(
}
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize().padding(MaterialTheme.spacing.space250),
modifier = Modifier.padding(padding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
if (state.proposals.isEmpty()) {
@@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.search_member_functionality
import mantra.composeapp.generated.resources.search_message_functionality_will_be_here
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -104,7 +105,7 @@ fun SearchMemberToAddToChatRoomScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth(),
@@ -55,6 +55,7 @@ import mantra.composeapp.generated.resources.next_with
import mantra.composeapp.generated.resources.selected
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
/**
* Second step of group creation: pick who is in the group.
@@ -153,7 +154,7 @@ fun SelectChatRoomMembersScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
if (selectChatRoomMembersUIState.profiles.isEmpty()) {
Column(
@@ -72,6 +72,7 @@ import mantra.composeapp.generated.resources.was_created_but_its_shared_key_cere
import mantra.composeapp.generated.resources.you_and_others
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
/**
* Last step of group creation: convenient (one admin) or robust (everyone
@@ -189,6 +190,7 @@ fun SelectChatRoomTypeScreen(
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space125),
@@ -54,6 +54,7 @@ import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -106,6 +107,7 @@ fun ShareProfileScreen(
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
@@ -46,6 +46,7 @@ import mantra.composeapp.generated.resources.this_will_give_you_read_only_access
import mantra.composeapp.generated.resources.this_will_give_you_write_access_to_the
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -66,6 +67,7 @@ fun SignInToProfileScreen(
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -33,6 +33,7 @@ import mantra.composeapp.generated.resources.view_invites
import mantra.composeapp.generated.resources.who_will_you_be_passing_the_aux_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@Composable
fun SocialPreconditionScreen(
@@ -69,7 +70,7 @@ fun SocialPreconditionScreen(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
@@ -64,6 +64,7 @@ import mantra.composeapp.generated.resources.translated_text
import mantra.composeapp.generated.resources.translation
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -197,6 +198,7 @@ fun TranslateChunkScreen(
Column(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.spacing.space250),
@@ -59,6 +59,7 @@ import mantra.composeapp.generated.resources.chapter
import mantra.composeapp.generated.resources.chunks_translated
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -117,7 +118,7 @@ fun TranslationArtifactVersionDetailScreen(
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
// Chapters the source version has and this translation
@@ -54,6 +54,7 @@ import mantra.composeapp.generated.resources.chapter_translation
import mantra.composeapp.generated.resources.this_chapter_has_no_chunks
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -107,7 +108,7 @@ fun TranslationChapterScreen(
}
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
// Header row: dialect names.
item {
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.everything_is_cryptographical_sound
import mantra.composeapp.generated.resources.torch_will_be_broadcast_what_you_publish_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -31,6 +32,7 @@ fun UnannouncedProfileScreen() {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.events_are_indexed_so_that_we_can_d
import mantra.composeapp.generated.resources.everything_is_cryptographical_sound_just_2
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -31,6 +32,7 @@ fun UnindexedProfileScreen() {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.all_broadcasts_are_queued_so_that_w
import mantra.composeapp.generated.resources.everything_is_cryptographical_sound_just_3
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -31,6 +32,7 @@ fun UnqueuedProfileScreen() {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -26,6 +26,7 @@ import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_
import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -45,6 +46,7 @@ fun UnqueuedProfileSynchronizationScreen(
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.as_long_as_you_control_your_keys_th
import mantra.composeapp.generated.resources.your_profile_is_almost_ready_just_getting_it
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -31,6 +32,7 @@ fun UnsignedProfileScreen() {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_
import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -33,6 +34,7 @@ fun UnsyncedProfileScreen(
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -58,6 +58,7 @@ import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send
import mantra.composeapp.generated.resources.replying_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
@@ -86,6 +87,7 @@ fun WriteNewNoteScreen(
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
.readableContent()
) {
Column(
@@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.send_message
import mantra.composeapp.generated.resources.unfollow
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -146,7 +147,7 @@ fun MetadataEventDetail(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
LazyColumn(
@@ -58,6 +58,7 @@ import mantra.composeapp.generated.resources.reply_to
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -197,7 +198,7 @@ fun TextNoteEventDetail(
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
) {
LazyColumn(
@@ -0,0 +1,90 @@
package press.mantra.compose.ui.theme
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* M3's upper bound on line length: *"across all breakpoints, adjust margins and type
* styles to keep text between 40–60 characters per line."*
*
* The lower bound is not enforced here, and does not need to be. A window narrower than
* this holds fewer characters by arithmetic, and no cap can add any -- 40 is a floor on
* the type scale and the margin, which the compact margin of 16dp already respects: a
* 400dp phone at the default text size holds about 46 characters of `bodyLarge`.
*/
const val MaxCharactersPerLine = 60
/**
* The width of one character of English prose, as a fraction of the font size.
*
* There is no exact answer -- a proportional face has no character width -- and this is
* the standard estimate for mixed-case Latin text with its spaces: half an em. It is the
* number to change if the app ever takes a font whose average advance is visibly wider or
* narrower than the system stack's, and changing it moves the measure on every screen at
* once, which is the reason it is a named constant rather than a `480.dp` somewhere.
*/
private const val AverageCharacterAdvance = 0.5f
/**
* The widest a column of [charactersPerLine] characters should be.
*
* Derived from the type scale rather than fixed, and that is the point: `sp` carries the
* reader's font scale, so at 200% text size the column doubles and still holds sixty
* characters. A hardcoded `480.dp` would hold thirty, which is the failure the 40–60 rule
* is about -- and it would fail silently, because the text still fits.
*/
@Composable
fun readableContentWidth(charactersPerLine: Int = MaxCharactersPerLine): Dp {
val bodySize = MaterialTheme.typography.bodyLarge.fontSize
// `bodyLarge` is declared in sp and this is the only role prose is set in, but a type
// scale is a value somebody can edit, and an em-relative or unspecified size here
// would convert to nonsense rather than fail. 16dp is what the baseline scale gives.
val bodySizeDp = if (bodySize.isSp) with(LocalDensity.current) { bodySize.toDp() } else 16.dp
return readableWidthFor(bodySizeDp, charactersPerLine)
}
/**
* [readableContentWidth] without the composition, so the arithmetic can be asserted.
*/
fun readableWidthFor(fontSize: Dp, charactersPerLine: Int = MaxCharactersPerLine): Dp =
fontSize * AverageCharacterAdvance * charactersPerLine
/**
* Holds this element to a readable measure and centres it in whatever space it is given.
*
* The one change that decides whether a screen written for a phone survives a 1800dp
* window. Without it a paragraph runs the full width and becomes unreadable -- the eye
* loses the start of the next line -- and a form's fields stretch to 1700dp for a
* six-character input. With it the content keeps the proportions it was designed at and
* the window's extra width becomes margin, which is what M3's single-pane canonical
* layout does.
*
* The centring is of the *column*, not of the text inside it. Those are opposite things:
* a centred column still has a straight leading edge for every row, avatar and icon to
* align to, which is what the grids-and-spacing page asks for. Centred text has none.
*
* Applied at the top of a `Scaffold`'s content, so a screen gets it once:
*
* Scaffold(...) { innerPadding ->
* Column(modifier = Modifier.padding(innerPadding).readableContent()) { … }
* }
*
* A no-op on any window narrower than the measure, which is every phone.
*/
@Composable
fun Modifier.readableContent(charactersPerLine: Int = MaxCharactersPerLine): Modifier =
this
// fillMaxWidth first so that the centring has the whole window to centre within;
// wrapContentWidth then relaxes the minimum so the child may be narrower, and
// widthIn caps it. Reordering these silently loses either the cap or the centring.
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.widthIn(max = readableContentWidth(charactersPerLine))
@@ -0,0 +1,70 @@
package press.mantra.compose.ui.theme
import androidx.compose.ui.unit.dp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The reading measure against M3's 40–60 characters per line.
*
* The whole point of deriving the cap rather than writing `480.dp` is that it follows the
* reader's text size, and that is the property no screenshot catches: a hardcoded column
* still looks correct at 200% text scale, it just holds thirty characters instead of
* sixty.
*/
class ReadableMeasureTest {
@Test
fun `sixty characters of the default body size is 480dp`() {
// bodyLarge is 16sp in the baseline scale, and 1sp is 1dp at the default text
// size. Half an em per character times sixty.
assertEquals(480.dp, readableWidthFor(16.dp))
}
@Test
fun `the measure follows the text size rather than the window`() {
// A reader at 200% text size gets a column twice as wide, and still sixty
// characters. This is the assertion a constant cannot pass, and the reason the
// function takes a font size at all.
assertEquals(
readableWidthFor(16.dp) * 2f,
readableWidthFor(32.dp),
"doubling the text size did not double the measure",
)
}
@Test
fun `the cap is the top of M3's range and not the bottom`() {
// 40 is the floor and 60 the ceiling; a max-width enforces the ceiling. Capping
// at 40 would be the same mistake in the other direction -- a column too narrow
// to read comfortably on any window wide enough to matter.
assertEquals(MaxCharactersPerLine, 60)
assertTrue(readableWidthFor(16.dp, 40) < readableWidthFor(16.dp, 60))
}
@Test
fun `a phone window holds a line inside the range`() {
// The check that the floor needs no enforcement. A 400dp compact window less its
// two 16dp margins is 368dp, which at 8dp a character is 46 -- inside 40 to 60,
// so the cap is a no-op there and nothing has to widen anything.
val compactContent = 400.dp - spacingFor(Breakpoint.Compact).screenMargin * 2f
val charactersPerLine = compactContent / (readableWidthFor(16.dp) / 60f)
assertTrue(
charactersPerLine in 40f..60f,
"a 400dp window fits $charactersPerLine characters, outside M3's 40-60",
)
assertTrue(
compactContent < readableWidthFor(16.dp),
"the measure is narrower than a phone, so it would crop rather than cap",
)
}
@Test
fun `a desktop window is held to the measure rather than filled`() {
// The case this exists for. 1800dp of window, 480dp of text, and the remaining
// 1320dp becomes margin instead of a line nobody can track back to its start.
assertTrue(readableWidthFor(16.dp) < 1800.dp)
}
}
@@ -0,0 +1,122 @@
package press.mantra.compose.ui.theme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.runDesktopComposeUiTest
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* What the theme does to a real composition in a window of a given width.
*
* [BreakpointTest] and [ReadableMeasureTest] assert arithmetic. This asserts the two
* things arithmetic cannot reach, in a window that genuinely is 1400 pixels wide:
*
* - that `currentBreakpoint()` reads the window at all. It goes through
* `currentWindowDpSize()`, `LocalWindowInfo` and `LocalDensity`, and a version of it
* that measured the wrong thing -- the parent's constraints, say -- would return
* `Compact` everywhere and pass every unit test in the suite.
* - that `readableContent()` caps *and* centres. It is `fillMaxWidth` then
* `wrapContentWidth` then `widthIn`, and every permutation of those three compiles and
* renders something plausible at phone width. Swap the last two and the content is
* centred but never capped; drop the first and it is capped but hugs the left edge.
*
* The test density is 1, so a pixel is a dp and the window widths below are the ones the
* phase's acceptance criterion names.
*/
@OptIn(ExperimentalTestApi::class)
class ReadableContentLayoutJvmTest {
/** 60 characters of 16sp `bodyLarge` at the default text scale. */
private val measure = 480.dp
@Test
fun `a desktop window caps the content and centres it`() = inWindow(1400) {
// (1400 - 480) / 2 each side. The window's extra width becomes margin, which is
// what M3's single-pane canonical layout does -- not a 1400dp line of text, and
// not a 480dp column pinned against one edge.
onNodeWithTag(Content).assertWidthIsEqualTo(measure)
onNodeWithTag(Content).assertLeftPositionInRootIsEqualTo(460.dp)
}
@Test
fun `a phone window is left exactly as it was`() = inWindow(400) {
// The claim that let this be applied to all 49 screen roots in one pass: below the
// measure it is not a cap, an inset or a centring -- it is nothing. If this fails,
// every phone layout in the app moved.
onNodeWithTag(Content).assertWidthIsEqualTo(400.dp)
onNodeWithTag(Content).assertLeftPositionInRootIsEqualTo(0.dp)
}
@Test
fun `the measure itself is the last width left alone`() = inWindow(480) {
onNodeWithTag(Content).assertWidthIsEqualTo(measure)
onNodeWithTag(Content).assertLeftPositionInRootIsEqualTo(0.dp)
}
@Test
fun `the theme classifies the window it is actually in`() {
listOf(
400 to Breakpoint.Compact,
700 to Breakpoint.Medium,
1000 to Breakpoint.Expanded,
1400 to Breakpoint.Large,
1800 to Breakpoint.ExtraLarge,
).forEach { (windowWidth, expected) ->
var seen: Breakpoint? = null
var margin: Dp? = null
runDesktopComposeUiTest(width = windowWidth, height = 900) {
setContent {
TorchTheme {
seen = MaterialTheme.breakpoint
margin = MaterialTheme.spacing.screenMargin
}
}
}
assertEquals(expected, seen, "a ${windowWidth}dp window")
assertEquals(
if (expected == Breakpoint.Compact) 16.dp else 24.dp,
margin,
"the screen margin in a ${windowWidth}dp window",
)
}
}
/**
* Composes a `readableContent()` column in a window [windowWidth] pixels across.
*
* The tagged node is a child of the modifier rather than the modifier's own node:
* `fillMaxWidth` makes that node the full window, and it is the child inside the cap
* whose width and position are the thing under test.
*/
private fun inWindow(windowWidth: Int, assertions: androidx.compose.ui.test.ComposeUiTest.() -> Unit) =
runDesktopComposeUiTest(width = windowWidth, height = 900) {
setContent { TaggedContent() }
assertions()
}
@Composable
private fun TaggedContent() {
TorchTheme {
Box(Modifier.readableContent()) {
Box(Modifier.fillMaxWidth().height(8.dp).testTag(Content))
}
}
}
private companion object {
const val Content = "content"
}
}