refactor: move the 89 off-grid spacing values onto the M3 scale

Phase 2, second step, of docs/material-design-conformance.md. 77 of the 89 literals that
were off M3's spacing scale sat in spacing positions and now read
`MaterialTheme.spacing.spaceNNN`; the remaining 12 are dimensions and are out of scope.
One drifted corner moved onto the shape scale.

**The mapping, and why each is the nearest stop rather than the nicest number.**

     5.dp  x10  -> space50   (4dp)   padding and gaps in dense rows
    15.dp  x14  -> space200  (16dp)  card and dialog padding, two gaps
    30.dp   x1  -> space400  (32dp)  the spacer under LoadingDataIndicator's spinner
    50.dp  x52  -> space600  (48dp)  the spacer above an empty or error message

Nearest-stop throughout, so the largest move is 2dp and most are 1. `5.dp` is equidistant
between `space50` and `space75`; it goes to 4dp because `spacedBy(4.dp)` is already the
idiom elsewhere in the tree and a scale with two answers for the same input is not one.

The 52 at 48dp are the same three lines copied into 16 files -- a `Spacer` pushing
"Something went wrong" down the screen. Phase 5 retires them into a shared empty-state
composable; migrating them first means that composable inherits a token rather than
another literal.

**One shape, and it is the argument for having a scale at all.**
`RoundedCornerShape(30.dp)` in `TextNoteEventDetail` was the only hand-written corner off
the M3 scale, at 30dp against `extraLarge`'s 28. Two units: invisible beside any single
other card, and exactly the drift that happens when the value is a literal. It is now
`MaterialTheme.shapes.extraLarge`, the first call site for the scale `Shape.kt` documented.

**Rewritten by a script that reads call shapes, not values, and it is checked in.**
`docs/scripts/m3-migrate-spacing.py` brace-matches three call shapes -- `padding(...)`/
`PaddingValues(...)`, `Arrangement.spacedBy(...)`, and a `.height()`/`.width()` whose
enclosing call is `Spacer(` -- and rewrites only literals that fall inside one. A
`.size(18.dp)` icon, a non-Spacer `.height()`, a `RoundedCornerShape` or a `BorderStroke`
can never be caught, which a regex over `\\d+\\.dp` would have done to all of them. It
inserts the two imports where they are missing and skips comment lines. Dry run by default.

**The audit was measuring the wrong thing, and this is where that showed.** It split
literals by value against a hardcoded `DIMENSION_EXEMPT` list -- and the split is not a
property of the value. `16.dp` is a spacing stop *and* a plausible icon size. `50.dp` was a
`Spacer` height in 52 places and a divider width in one, and no list of numbers separates
those. `docs/scripts/m3-spacing-positions.py` replaces it with the same brace-matching
parse the migration uses, so the audit and the migration agree by construction; the audit
now reports **353 spacing literals** left and 76 dimensions out of scope, and the exemption
table is gone.

That reframes phase 2's acceptance criterion into something checkable: spacing positions to
zero, dimensions untouched. The script exits 1 while any spacing literal remains.

**What is left off-scale, and why none of it is a defect.** Twelve dimensions: avatar sizes
at 35, 55, 70 and 75dp, icon sizes at 18 and 22dp, and a 50dp divider width. Avatar and
icon sizing is a component-spec question rather than a spacing one -- M3 gives icons 18/20/
24/40/48 and says nothing about avatars -- and the plan puts per-component specs after the
adaptive phase. They are reported rather than exempted so the number stays visible.

**Tests.** 942 pass, 594 jvm over 72 classes and 348 android over 44, unchanged --
this commit adds no assertions, and the ones it could add (`SpacingScaleTest`) landed with
the scale. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0.
Pixels move by at most 2dp, in 30 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-08 00:32:27 +02:00
1 parent 73d99f9a41
commit f7a732d68a
34 files changed
+397 -122

No files matched your search

@@ -67,6 +67,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.view.model.AddArtifactViewModel
import press.mantra.compose.ui.view.state.AddArtifactUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -102,7 +103,7 @@ fun AddArtifactScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addArtifactUIState.message,
@@ -52,6 +52,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddChapterViewModel
import press.mantra.compose.ui.view.state.AddChapterUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -87,7 +88,7 @@ fun AddChapterScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = addChapterUIState.message)
}
}
@@ -54,6 +54,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddDialectViewModel
import press.mantra.compose.ui.view.state.AddDialectUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -87,7 +88,7 @@ fun AddDialectScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addDialectUIState.message,
@@ -37,6 +37,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddMemberToChatRoomConfirmationViewModel
import press.mantra.compose.ui.view.state.AddMemberToChatRoomConfirmationUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -69,7 +70,7 @@ fun AddMemberToChatRoomConfirmationScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = addMemberToChatRoomConfirmationUIState.message,
@@ -57,6 +57,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel
import press.mantra.compose.ui.view.state.AddTranslationArtifactVersionUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -92,7 +93,7 @@ fun AddTranslationArtifactVersionScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = addTranslationUIState.message)
}
}
@@ -48,6 +48,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ArtifactDetailViewModel
import press.mantra.compose.ui.view.state.ArtifactDetailUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,7 +79,7 @@ fun ArtifactDetailScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = artifactDetailUIState.message)
}
}
@@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ChapterDetailViewModel
import press.mantra.compose.ui.view.state.ChapterDetailUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -67,7 +68,7 @@ fun ChapterDetailScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = chapterDetailUIState.message)
}
}
@@ -68,6 +68,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -101,7 +102,7 @@ fun ChatRoomDetailScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = chatRoomDetailUIState.message,
@@ -609,7 +610,7 @@ private fun ReindexMarmotGroupEventsButton(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp)
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)
) {
TextButton(
enabled = reindexState !is ChatRoomDetailViewModel.ReindexState.Running,
@@ -56,6 +56,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ChatRoomMessagingViewModel
import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -88,7 +89,7 @@ fun ChatRoomMessagingScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = chatRoomDetailUIState.message,
@@ -37,6 +37,7 @@ import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.StateFlow
import press.mantra.compose.ui.theme.spacing
/**
* The frame every ChillDKG approval screen sits in: the view model, the states
@@ -89,7 +90,7 @@ internal fun DkgApprovalScaffold(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
CircularProgressIndicator()
}
@@ -97,7 +98,7 @@ internal fun DkgApprovalScaffold(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = state.message, textAlign = TextAlign.Center)
}
@@ -112,7 +113,7 @@ internal fun DkgApprovalScaffold(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
CircularProgressIndicator()
}
@@ -75,6 +75,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import press.mantra.compose.ui.theme.spacing
/**
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
@@ -113,7 +114,7 @@ fun DkgRitualScreen(
modifier = Modifier.fillMaxWidth().padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center)
}
}
@@ -201,7 +202,7 @@ fun DkgRitualScreen(
},
actions = {
Text(
modifier = Modifier.padding(start = 15.dp),
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = "${dkgRitualViewModel.threshold.value} of $participantCount",
style = MaterialTheme.typography.labelLarge
)
@@ -328,7 +329,7 @@ private fun RitualProgress(
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(15.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -396,7 +397,7 @@ private fun RitualProgress(
)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(15.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
@@ -501,7 +502,7 @@ private fun RitualRoster(
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(15.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = "Members", style = MaterialTheme.typography.titleSmall)
@@ -563,7 +564,7 @@ private fun RitualStep(
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(15.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
@@ -602,8 +603,8 @@ private fun QuorumStepper(
onThresholdChange: (Int) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)
) {
Text(
text = "How many members will it take to sign?",
@@ -612,7 +613,7 @@ private fun QuorumStepper(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(15.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200),
verticalAlignment = Alignment.CenterVertically
) {
FilledIconButton(
@@ -56,6 +56,7 @@ import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.FrostSigningViewModel
import press.mantra.compose.ui.view.state.FrostSigningUIState
import press.mantra.compose.ui.theme.spacing
/**
* One signing session, and the member's decision about it.
@@ -123,7 +124,7 @@ fun FrostSigningScreen(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = state.message, textAlign = TextAlign.Center)
}
@@ -150,7 +151,7 @@ fun FrostSigningScreen(
.fillMaxSize()
.verticalScroll(scrollState)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(15.dp)
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)
) {
WhatIsBeingSigned(proposed, state.items.size)
@@ -307,7 +308,7 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
CircularProgressIndicator()
}
}
@@ -337,7 +338,7 @@ private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
return
}
Column(verticalArrangement = Arrangement.spacedBy(15.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)) {
if (events.size > 1) {
Text(
text = "${events.size} events, signed together",
@@ -366,7 +367,7 @@ private fun WhatIsBeingSigned(events: List<Event>, expected: Int) {
private fun OneThingBeingSigned(event: Event) {
val summary = ProposedEvent.summarize(event)
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) {
Text(text = summary.label, style = MaterialTheme.typography.labelMedium)
Text(text = summary.detail, style = MaterialTheme.typography.titleMedium)
@@ -54,6 +54,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.ProposalListViewModel
import press.mantra.compose.ui.view.state.ProposalListUIState
import press.mantra.compose.ui.theme.spacing
/**
* Everything the group has asked its shared key to sign.
@@ -119,7 +120,7 @@ fun ProposalListScreen(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
CircularProgressIndicator()
}
@@ -127,7 +128,7 @@ fun ProposalListScreen(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = state.message, textAlign = TextAlign.Center)
}
@@ -149,7 +150,7 @@ fun ProposalListScreen(
if (state.proposals.isEmpty()) {
item {
Text(
modifier = Modifier.fillMaxWidth().padding(top = 50.dp),
modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space600),
text = "This group has not been asked to sign anything yet.",
textAlign = TextAlign.Center
)
@@ -40,6 +40,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.SearchMemberToAddToChatRoomViewModel
import press.mantra.compose.ui.view.state.SearchMemberToAddToChatRoomUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -70,7 +71,7 @@ fun SearchMemberToAddToChatRoomScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = searchMemberToAddToChatRoomUIState.message,
@@ -118,7 +119,7 @@ fun SearchMemberToAddToChatRoomScreen(
)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(10.dp),
reverseLayout = true
) {
@@ -54,6 +54,7 @@ import press.mantra.compose.ui.view.model.SearchResultType
import press.mantra.compose.ui.view.model.SearchResultViewModel
import press.mantra.compose.ui.view.state.SearchResultListUIState
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -210,7 +211,7 @@ fun SearchResultScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -225,7 +226,7 @@ fun SearchResultScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "No events were found",
@@ -263,14 +264,14 @@ fun SearchResultScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Searching for ${searchViewModel.searchResultType.name.lowercase()} on \"${searchQuery.lowercase()}\"",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
@@ -42,6 +42,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.SelectChatRoomMembersViewModel
import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.theme.spacing
/**
* Second step of group creation: pick who is in the group.
@@ -77,7 +78,7 @@ fun SelectChatRoomMembersScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = selectChatRoomMembersUIState.message,
@@ -126,7 +127,7 @@ fun SelectChatRoomMembersScreen(
},
actions = {
Text(
modifier = Modifier.padding(start = 15.dp),
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = if (selectedCount > 0) {
"$selectedCount selected"
} else {
@@ -175,7 +176,7 @@ fun SelectChatRoomMembersScreen(
}
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(5.dp),
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(
@@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import press.mantra.compose.ui.theme.spacing
/**
* Last step of group creation: convenient (one admin) or robust (everyone
@@ -95,7 +96,7 @@ fun SelectChatRoomTypeScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = selectChatRoomTypeUIState.message,
@@ -156,7 +157,7 @@ fun SelectChatRoomTypeScreen(
},
actions = {
Text(
modifier = Modifier.padding(start = 15.dp),
modifier = Modifier.padding(start = MaterialTheme.spacing.space200),
text = when (selectChatRoomTypeUIState.members.size) {
0 -> "Just you for now"
1 -> "You and 1 other"
@@ -185,7 +186,7 @@ fun SelectChatRoomTypeScreen(
)
) {
Text(
modifier = Modifier.padding(15.dp),
modifier = Modifier.padding(MaterialTheme.spacing.space200),
text = "$name was created, but ${membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }} couldn't be added yet. Invite them again from the chat once they're on Torch.",
style = MaterialTheme.typography.bodyMedium
)
@@ -201,7 +202,7 @@ fun SelectChatRoomTypeScreen(
)
) {
Text(
modifier = Modifier.padding(15.dp),
modifier = Modifier.padding(MaterialTheme.spacing.space200),
text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.",
style = MaterialTheme.typography.bodyMedium
)
@@ -328,7 +329,7 @@ private fun ChatRoomTypeCard(
}
) {
Column(
modifier = Modifier.fillMaxWidth().padding(15.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
@@ -394,8 +395,8 @@ private fun QuorumPicker(
onQuorumChange: (Int) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 5.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)
) {
Text(
text = "How many admins have to approve a change?",
@@ -404,7 +405,7 @@ private fun QuorumPicker(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(15.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200),
verticalAlignment = Alignment.CenterVertically
) {
FilledIconButton(
@@ -52,6 +52,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.TranslateChunkViewModel
import press.mantra.compose.ui.view.state.TranslateChunkUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -89,7 +90,7 @@ fun TranslateChunkScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translateChunkUIState.message)
}
}
@@ -46,6 +46,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.TranslationArtifactVersionDetailViewModel
import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -80,7 +81,7 @@ fun TranslationArtifactVersionDetailScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translationDetailUIState.message)
}
}
@@ -46,6 +46,7 @@ import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.TranslationChapterViewModel
import press.mantra.compose.ui.view.state.ChunkTranslationPair
import press.mantra.compose.ui.view.state.TranslationChapterUIState
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -76,7 +77,7 @@ fun TranslationChapterScreen(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
Text(text = translationChapterUIState.message)
}
}
@@ -43,6 +43,7 @@ import press.mantra.compose.extensions.toFormattedTimeAndDateString
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlin.time.Clock
import press.mantra.compose.ui.theme.spacing
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
@@ -110,7 +111,7 @@ fun WriteNewNoteScreen(
if (writeNewNoteUIState.inReplyToNostrEvent != null) {
Text(
modifier = Modifier.padding(
start = 50.dp,
start = MaterialTheme.spacing.space600,
bottom = 10.dp,
),
@@ -233,7 +234,7 @@ fun WriteNewNoteScreen(
if (writeNewNoteUIState.quotedNostrEvent?.nostrEvent?.kind == TextNoteEvent.KIND) {
Card(
modifier = Modifier.padding(
start = 50.dp,
start = MaterialTheme.spacing.space600,
top = 10.dp
)
) {
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import press.mantra.compose.ui.theme.spacing
@Composable
fun LoadingDataIndicator(
@@ -37,7 +38,7 @@ fun LoadingDataIndicator(
text?.let {
Spacer(
modifier = Modifier.height(30.dp)
modifier = Modifier.height(MaterialTheme.spacing.space400)
)
Text(
@@ -107,7 +107,10 @@ fun TextNoteEventDetail(
localNostrEvent.nostrEvent.id
)
},
shape = RoundedCornerShape(30.dp),
// The one hand-written corner that was off the shape scale, at 30dp
// against extraLarge's 28. Two units, invisible on its own and exactly
// the drift a scale exists to stop.
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
@@ -1,6 +1,9 @@
package press.mantra.compose.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -88,3 +91,21 @@ data class Spacing(
}
val LocalSpacing = staticCompositionLocalOf { Spacing() }
/**
* `MaterialTheme.spacing.containerPadding`, to match `MaterialTheme.colorScheme.primary`.
*
* The alternative is `LocalSpacing.current`, which has to be read into a local before it
* can be used and so cannot appear inline in a `Modifier` chain. That difference decides
* whether a call site reads
*
* Modifier.padding(MaterialTheme.spacing.containerPadding)
*
* or acquires a `val spacing = LocalSpacing.current` several lines above, in whichever
* composable happens to enclose it. Over 500 call sites the second shape is what stops
* people using the scale.
*/
val MaterialTheme.spacing: Spacing
@Composable
@ReadOnlyComposable
get() = LocalSpacing.current
@@ -4,12 +4,14 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
@@ -414,6 +416,12 @@ val LocalExtendedColors = staticCompositionLocalOf {
ExtendedColors(bluePill = bluePillLight, redPill = redPillLight)
}
/** `MaterialTheme.extendedColors.redPill`, matching the [spacing] accessor. */
val MaterialTheme.extendedColors: ExtendedColors
@Composable
@ReadOnlyComposable
get() = LocalExtendedColors.current
internal fun extendedColorsFor(darkTheme: Boolean): ExtendedColors =
if (darkTheme) {
ExtendedColors(bluePill = bluePillDark, redPill = redPillDark)
@@ -91,6 +91,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
class ChatMessageListViewModel(
initialChatMessageListUIState: ChatMessageListUIState,
@@ -393,7 +394,7 @@ class ChatMessageListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -445,7 +446,7 @@ class ChatMessageListViewModel(
val awaitingYou = proposalsAwaitingYou
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(10.dp),
reverseLayout = true
) {
@@ -744,14 +745,14 @@ class ChatMessageListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Loading",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
@@ -850,7 +851,7 @@ private fun ProposalsAwaitingYouNotice(
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
class ChatRoomListViewModel(
initialChatRoomListUIState: ChatRoomListUIState,
@@ -150,7 +151,7 @@ class ChatRoomListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -232,14 +233,14 @@ class ChatRoomListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Loading",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -33,6 +34,7 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
class FeedListViewModel(
initialFeedListUIState: FeedListUIState,
@@ -116,7 +118,7 @@ class FeedListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -130,7 +132,7 @@ class FeedListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "No events were found",
@@ -167,14 +169,14 @@ class FeedListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Loading",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -29,6 +30,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
class FollowersListViewModel(
initialFollowingListUIState: press.mantra.compose.ui.view.state.FollowersListUIState,
@@ -110,7 +112,7 @@ class FollowersListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -124,7 +126,7 @@ class FollowersListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "No events were found",
@@ -155,14 +157,14 @@ class FollowersListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Loading",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -29,6 +30,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import press.mantra.compose.ui.theme.spacing
class FollowingListViewModel(
initialFollowingListUIState: press.mantra.compose.ui.view.state.FollowingListUIState,
@@ -109,7 +111,7 @@ class FollowingListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -123,7 +125,7 @@ class FollowingListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "No events were found",
@@ -154,14 +156,14 @@ class FollowingListViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Loading",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
@@ -34,6 +35,7 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlin.time.Instant
import press.mantra.compose.ui.theme.spacing
class InReplyToViewModel(
val nostrEvent: press.mantra.compose.database.model.NostrEvent,
@@ -120,7 +122,7 @@ class InReplyToViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Something went wrong",
@@ -134,7 +136,7 @@ class InReplyToViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "No events were found",
@@ -171,14 +173,14 @@ class InReplyToViewModel(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = "Loading",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
LoadingIndicator()
+12 -44
View File
@@ -25,23 +25,13 @@ THEME="$UI/theme"
# Budgets. "-1" means not yet budgeted -- reported, but never fails --check.
# ---------------------------------------------------------------------------
BUDGET_HARDCODED_COLOR=9 # phase 3 drives to 0 outside theme/
BUDGET_DP_LITERALS=-1 # phase 2 drives to ~0 outside theme/
BUDGET_OFF_SCALE_DP=-1 # phase 2 drives to 0
BUDGET_SPACING_LITERALS=353 # phase 2 drives to 0
BUDGET_BARE_CLICKABLE=33 # phase 3 drives to 0
BUDGET_NULL_DESCRIPTION=18 # phase 3 triages each one
BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10
BUDGET_TITLE_CASE=-1 # phase 4 drives to 0
BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07
# The M3 spacing scale: docs/material-design-conformance.md, "The numbers".
# space0..space900. Anything outside this set is off-scale.
ON_SCALE=(0 2 4 6 8 10 12 14 16 20 24 32 36 40 48 56 64 72)
# Dimensions rather than spacing -- an avatar, an image height, a hairline
# border. These are exempt from the off-scale count; keep the list short and
# justify additions in the commit that makes them.
DIMENSION_EXEMPT=(1 80 128 180 200 500)
fail_count=0
hdr() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
@@ -136,40 +126,18 @@ report 'colours derived with .copy(alpha =)' "$alpha" -1
# ---------------------------------------------------------------------------
hdr 'Spacing (phase 2)'
dp_all=$(grep -rhoE '\b[0-9]+\.dp' "$UI" --include=*.kt 2>/dev/null \
| grep -v "^$THEME/" | wc -l | tr -d ' ')
dp_outside_theme=$(grep -rhoE '\b[0-9]+\.dp' \
$(grep -rl '\.dp' "$UI" --include=*.kt 2>/dev/null | grep -v "^$THEME/") \
2>/dev/null | wc -l | tr -d ' ')
report '.dp literals outside theme/' "$dp_outside_theme" "$BUDGET_DP_LITERALS"
# Classified by call shape rather than by value, which is the only thing that says
# whether a given literal is spacing or a dimension: 16.dp is a spacing stop and also a
# plausible icon size, and 50.dp was a Spacer height in 53 places and a divider width in
# one. m3-spacing-positions.py does the parse; it exits 1 while any spacing literal is
# left, which is phase 2's acceptance criterion.
spacing_report=$(python3 docs/scripts/m3-spacing-positions.py)
spacing_left=$(echo "$spacing_report" | awk '/spacing positions/ {print $NF}')
dimensions=$(echo "$spacing_report" | grep 'dimension positions' | grep -oE '[0-9]+')
report 'dp literals in spacing positions' "$spacing_left" "$BUDGET_SPACING_LITERALS"
note "in dimension positions (out of scope): $dimensions"
note 'run docs/scripts/m3-spacing-positions.py --list to see them'
# Split the histogram into on-scale, exempt dimensions, and off-scale.
declare -A hist
while read -r n; do
hist[$n]=$(( ${hist[$n]:-0} + 1 ))
done < <(grep -rhoE '\b[0-9]+\.dp' \
$(grep -rl '\.dp' "$UI" --include=*.kt 2>/dev/null | grep -v "^$THEME/") \
2>/dev/null | sed 's/\.dp//')
on_scale_total=0; off_scale_total=0; exempt_total=0; off_scale_detail=""
for n in "${!hist[@]}"; do
c=${hist[$n]}
if printf '%s\n' "${ON_SCALE[@]}" | grep -qx "$n"; then
on_scale_total=$((on_scale_total + c))
elif printf '%s\n' "${DIMENSION_EXEMPT[@]}" | grep -qx "$n"; then
exempt_total=$((exempt_total + c))
else
off_scale_total=$((off_scale_total + c))
off_scale_detail="$off_scale_detail ${n}dp:${c}"
fi
done
note "on the M3 scale: $on_scale_total"
note "exempt dimensions: $exempt_total"
report 'off the M3 spacing scale' "$off_scale_total" "$BUDGET_OFF_SCALE_DP"
[[ -n $off_scale_detail ]] && note "off-scale:$off_scale_detail"
spacer_idiom=$(count 'height\(50\.dp\)')
note "Spacer(height(50.dp)) idiom: $spacer_idiom"
# ---------------------------------------------------------------------------
hdr 'Typography (phase 1)'
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Rewrite .dp literals that sit in spacing positions onto MaterialTheme.spacing.
Checked in because phase 2 runs it twice and the breakpoint phase will want it again.
Usage:
m3-migrate-spacing.py '{"5":"space50","15":"space200"}' # dry run
m3-migrate-spacing.py '{"5":"space50","15":"space200"}' --apply
Only three call shapes are touched, and each is matched with the literal in place so a
dimension can never be caught by accident:
padding(...) any of the overloads, including named start/end/top/bottom
Arrangement.spacedBy(N) horizontal or vertical
Spacer height/width a .height()/.width() whose enclosing call is Spacer(
Everything else -- .size(), a non-Spacer .height(), RoundedCornerShape, BorderStroke --
is a dimension and is left alone.
"""
import re, sys, io, os
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
MAPPING = {} # filled by the caller
DRY = '--apply' not in sys.argv
def token(value):
return MAPPING.get(value)
def spacer_spans(text):
"""Character ranges covered by a Spacer( ... ) call, brace-matched."""
spans = []
for m in re.finditer(r'\bSpacer\s*\(', text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def in_spans(pos, spans):
return any(a <= pos <= b for a, b in spans)
def padding_spans(text):
spans = []
for m in re.finditer(r'\.?\bpadding\s*\(|\bPaddingValues\s*\(', text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def spacedby_spans(text):
spans = []
for m in re.finditer(r'\bspacedBy\s*\(', text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def process(path):
text = io.open(path, encoding='utf-8').read()
original = text
changed = []
for _ in range(60): # spans shift after each edit; recompute
pads = padding_spans(text)
gaps = spacedby_spans(text)
spacers = spacer_spans(text)
# a .height()/.width() literal counts only inside a Spacer(
hw = [(m.start(1), m.end(1), m.group(1))
for m in re.finditer(r'\.(?:height|width)\s*\(\s*(\d+\.dp)\s*\)', text)
if in_spans(m.start(), spacers)]
edit = None
for m in re.finditer(r'\b(\d+)\.dp\b', text):
lit = m.group(0)
tok = token(m.group(1))
if tok is None:
continue
pos = m.start()
line_start = text.rfind('\n', 0, pos) + 1
line = text[line_start:text.find('\n', pos)]
if re.match(r'\s*(//|\*|/\*)', line): # a comment
continue
if in_spans(pos, pads) or in_spans(pos, gaps) or \
any(a <= pos < b for a, b, _ in hw):
edit = (m.start(), m.end(), tok, lit, line.strip()[:80])
break
if edit is None:
break
a, b, tok, lit, ctx = edit
text = text[:a] + f'MaterialTheme.spacing.{tok}' + text[b:]
changed.append((lit, tok, ctx))
if text != original:
if 'import androidx.compose.material3.MaterialTheme' not in text:
# insert alphabetically among the material3 imports, else after the last import
lines = text.split('\n')
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
for i, l in enumerate(lines):
if l.startswith('import androidx.compose.material3.') and l > 'import androidx.compose.material3.MaterialTheme':
idx = i - 1
break
lines.insert(idx + 1, 'import androidx.compose.material3.MaterialTheme')
text = '\n'.join(lines)
if 'import press.mantra.compose.ui.theme.spacing' not in text and \
not path.endswith('theme/Spacing.kt'):
lines = text.split('\n')
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
lines.insert(idx + 1, 'import press.mantra.compose.ui.theme.spacing')
text = '\n'.join(lines)
if not DRY:
io.open(path, 'w', encoding='utf-8').write(text)
return changed
if __name__ == '__main__':
import json
MAPPING.update(json.loads(sys.argv[1]))
total = 0
for root, _, files in os.walk(UI):
for f in sorted(files):
if not f.endswith('.kt'):
continue
p = os.path.join(root, f)
ch = process(p)
if ch:
print(f'{p.replace(UI + "/", "")}')
for lit, tok, ctx in ch:
print(f' {lit:>7s} -> {tok:18s} {ctx}')
total += len(ch)
print(f'\n{total} literal(s) {"would be " if DRY else ""}rewritten')
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Count .dp literals by whether they sit in a spacing position or a dimension one.
m3-audit.sh used to split them by value, exempting a list of numbers that "looked like"
dimensions -- which is wrong twice over. 16.dp is a spacing stop *and* a plausible icon
size, and 50.dp was a Spacer height in 53 places and a divider width in one. Only the call
shape says which, so this reads the shape.
spacing padding(...), PaddingValues(...), Arrangement.spacedBy(...), and a
.height()/.width() whose enclosing call is Spacer(
dimension .size(), a non-Spacer .height()/.width(), RoundedCornerShape(),
BorderStroke(), a `size =` argument
Spacing positions are what phase 2 drives to zero; dimensions are out of its scope and are
reported so the number is visible rather than exempted.
Usage: m3-spacing-positions.py [--list]
"""
import os, re, sys
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
THEME = os.path.join(UI, 'theme')
def _spans(text, opener):
"""Brace-matched character ranges of every call matching `opener`."""
spans = []
for m in re.finditer(opener, text):
depth, i = 0, m.end() - 1
while i < len(text):
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
if depth == 0:
spans.append((m.start(), i))
break
i += 1
return spans
def classify(path):
text = open(path, encoding='utf-8').read()
pads = _spans(text, r'\.?\bpadding\s*\(|\bPaddingValues\s*\(')
gaps = _spans(text, r'\bspacedBy\s*\(')
spacers = _spans(text, r'\bSpacer\s*\(')
def inside(pos, spans):
return any(a <= pos <= b for a, b in spans)
hw = [
(m.start(1), m.end(1))
for m in re.finditer(r'\.(?:height|width)\s*\(\s*(\d+\.dp)\s*\)', text)
if inside(m.start(), spacers)
]
spacing, dimension = [], []
for m in re.finditer(r'\b(\d+)\.dp\b', text):
pos = m.start()
line_start = text.rfind('\n', 0, pos) + 1
line = text[line_start:text.find('\n', pos)]
if re.match(r'\s*(//|\*|/\*)', line):
continue
entry = (path, text[:pos].count('\n') + 1, m.group(0), line.strip()[:90])
if inside(pos, pads) or inside(pos, gaps) or any(a <= pos < b for a, b in hw):
spacing.append(entry)
else:
dimension.append(entry)
return spacing, dimension
def main():
spacing, dimension = [], []
for root, _, files in os.walk(UI):
if root.startswith(THEME):
continue
for f in sorted(files):
if f.endswith('.kt'):
s, d = classify(os.path.join(root, f))
spacing += s
dimension += d
print(f' {"dp literals in spacing positions":42s} {len(spacing):6d}')
print(f' {"dp literals in dimension positions":42s} {len(dimension):6d} (out of scope)')
if '--list' in sys.argv:
for path, line, lit, ctx in spacing:
print(f' {path.replace(UI + "/", "")}:{line} {lit:>7s} {ctx}')
return 1 if spacing else 0
if __name__ == '__main__':
sys.exit(main())