From 831d1c0ad463c2f6908658a24bdd9e02ef15e45f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 00:52:15 +0200 Subject: [PATCH] fix: give every tappable element a real target, and every icon a decided description Phase 3, second step, of docs/material-design-conformance.md. Two accessibility rules the tree had no way to hold: M3's 48x48dp touch target and 44x44dp pointer target, and its requirement that a decorative visual be *annotated* as decorative rather than merely left undescribed. **Nineteen `.clickable` chains had no minimum size, and three were text-sized.** `ArticleCard` and `LiveStreamCardContent` each make an author's name tappable -- a `labelMedium`, around 16dp tall -- and `LinkPreview` does the same to a `bodyLarge` url with 2dp of vertical padding. The other sixteen are cards, rows and full-screen boxes that are already far larger. `minimumInteractiveComponentSize()` is applied to all nineteen rather than to the three, because it is a no-op on anything already 48dp and that makes the rule checkable by a script instead of by measuring. Worth being precise about what it does, since the modifier is easy to describe wrongly: it reserves 48x48dp of **layout**, not of touch handling -- touch expansion happens at the input layer regardless. Layout is what keeps adjacent targets from overlapping, what satisfies M3's 8dp separation, and what a mouse pointer on the desktop build actually has to land on. **`Clickable.kt` had it built in and moved house.** The vendored ACINQ helper defaults to `RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as its content -- and its call sites wrap a 20dp emoji and a row of wallet text. It now applies the modifier unconditionally, before `.padding(internalPadding)`, since a size modifier after it would re-impose the smaller constraint. It also stopped declaring `package com.machankura.compose.ui.composable.widgets.buttons` while living under `press/mantra/`. That is the second of the three package namespaces the UI was spread across; `Type.kt` was the first. **Eighteen `contentDescription = null` were indistinguishable from eighteen oversights.** `null` is the *correct* API -- M3 asks that decorative visuals be "annotated as decorative in order to hide them in code", and null is how that annotation is spelled in Compose. The problem is that it reads identically whether somebody decided or never looked. So `Decorative` is introduced -- a `String?` that is null -- and fifteen sites now say `contentDescription = Decorative`. Same bytes, same behaviour, and the difference between a decision and a gap is now visible in the source and countable by the audit. Each of the fifteen has adjacent text saying what the icon says: a lock beside "Private to Ada", a check beside "The group has a shared key.", an icon inside a button whose label is right there. **Three were not decorative and now carry their state.** - `DkgRitualScreen`'s participant list -- a filled or empty circle beside each member. The name says who; only the icon says whether they have contributed. Now "Contributed" / "Not yet contributed". - `DkgRitualScreen`'s round header -- the title says which round and the count says how far along; only the icon says whether it finished. Now "Complete" / "In progress". - `ProposalListScreen`'s leading icon, which is the one this commit could not have left alone: the previous commit took the red away from the failure state on the highlighted card, because `error` is 2.67:1 there. The shape is now the only cue a sighted user gets and the description is the only cue anyone else gets. Now "Awaiting your signature" / "Signed" / "Failed" / "Waiting on others". Descriptions follow M3's rule -- name the purpose, not the picture, and never the role. "Contributed", not "green check", and never "Contributed icon", since the role is added automatically and a screen reader would say it twice. **Two new checks, replacing one that was asking the wrong question.** `docs/scripts/m3-touch-targets.py` finds `.clickable` chains with no minimum size, including chains broken across two lines. The audit used to count `.clickable` outright, which is not a defect count: a clickable `Card` is fine and a clickable `Text` is not, and only the modifier tells them apart. The audit also now separates `contentDescription = null` (untriaged, budget 0) from `Decorative` (decided, reported at 15). Both budgets ratcheted to 0, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged -- these are layout and semantics properties, and this repo has no Compose UI test infrastructure to assert them against a running composition. What stands in for it is the two scripts, which check the property that *can* be checked statically: that the modifier and the decision are present at every site. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 --- .../ui/composable/ChatRoomMessagingScreen.kt | 3 +- .../ui/composable/DkgApprovalScaffold.kt | 3 +- .../compose/ui/composable/DkgRitualScreen.kt | 21 +++++-- .../ui/composable/FrostSigningScreen.kt | 3 +- .../ui/composable/ProposalListScreen.kt | 9 ++- .../compose/ui/composable/SearchScreen.kt | 5 ++ .../ui/composable/SelectChatRoomTypeScreen.kt | 3 +- .../ui/composable/TranslationChapterScreen.kt | 4 +- .../ui/composable/widgets/Semantics.kt | 26 +++++++++ .../composable/widgets/buttons/Clickable.kt | 19 ++++++- .../composable/widgets/content/ArticleCard.kt | 7 ++- .../widgets/content/FullScreenImageViewer.kt | 2 + .../widgets/content/ImageWithContextMenu.kt | 2 + .../widgets/content/LightningInvoiceCard.kt | 3 +- .../composable/widgets/content/LinkPreview.kt | 6 +- .../widgets/content/LiveStreamCard.kt | 4 +- .../widgets/content/LiveStreamCardContent.kt | 4 +- .../composable/widgets/content/QuotedNote.kt | 4 +- .../widgets/detail/TextNoteEventDetail.kt | 2 + .../composable/widgets/feed/EventListView.kt | 4 +- .../widgets/feed/TextNoteFeedItem.kt | 7 ++- .../composable/widgets/wallet/WalletAvatar.kt | 2 +- .../widgets/wallet/WalletsSelector.kt | 2 +- .../ui/view/model/ChatMessageListViewModel.kt | 13 +++-- docs/scripts/m3-audit.sh | 16 ++++-- docs/scripts/m3-touch-targets.py | 56 +++++++++++++++++++ 26 files changed, 195 insertions(+), 35 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt create mode 100755 docs/scripts/m3-touch-targets.py diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index e5c5dffc..fb454184 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -57,6 +57,7 @@ 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 +import press.mantra.compose.ui.composable.widgets.Decorative @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -222,7 +223,7 @@ fun ChatRoomMessagingScreen( ) { Icon( Icons.Default.Lock, - contentDescription = null, + contentDescription = Decorative, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index ca9bc1fc..30352f13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -38,6 +38,7 @@ 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 +import press.mantra.compose.ui.composable.widgets.Decorative /** * The frame every ChillDKG approval screen sits in: the view model, the states @@ -137,7 +138,7 @@ internal fun DkgApprovalScaffold( Spacer(modifier = Modifier.height(MaterialTheme.spacing.space500)) Icon( imageVector = Icons.Default.CheckCircle, - contentDescription = null, + contentDescription = Decorative, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index f2c887eb..12715e88 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -76,6 +76,7 @@ import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative /** * The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17 @@ -165,7 +166,7 @@ fun DkgRitualScreen( ) } ) { - Icon(Icons.Default.Key, contentDescription = null) + Icon(Icons.Default.Key, contentDescription = Decorative) Text( text = when (pending) { DkgApprovalStep.HOST_KEY -> "Review and join" @@ -333,7 +334,7 @@ private fun RitualProgress( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.ErrorOutline, contentDescription = null) + Icon(Icons.Default.ErrorOutline, contentDescription = Decorative) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { Text("The ceremony was abandoned.", style = MaterialTheme.typography.titleSmall) session.failureReason?.let { @@ -404,7 +405,7 @@ private fun RitualProgress( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.CheckCircle, contentDescription = null) + Icon(Icons.Default.CheckCircle, contentDescription = Decorative) Text("The group has a shared key.", style = MaterialTheme.typography.titleSmall) } @@ -473,7 +474,7 @@ private fun RitualProgress( if (isActionPending) { CircularProgressIndicator(modifier = Modifier.size(20.dp)) } else { - Icon(Icons.Default.Groups, contentDescription = null) + Icon(Icons.Default.Groups, contentDescription = Decorative) Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) Text(text = "Create the #admins group") } @@ -526,13 +527,19 @@ private fun RitualRoster( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { + // Not decorative: the name beside it says who, and only the + // icon says whether they have contributed. Icon( imageVector = if (publicKey in hostKeyParticipants) { Icons.Default.CheckCircle } else { Icons.Default.RadioButtonUnchecked }, - contentDescription = null + contentDescription = if (publicKey in hostKeyParticipants) { + "Contributed" + } else { + "Not yet contributed" + } ) Text( @@ -572,9 +579,11 @@ private fun RitualStep( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { + // Not decorative: the title says which round, the count says how far + // along, and only the icon says whether it has finished. Icon( imageVector = if (isDone) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, - contentDescription = null + contentDescription = if (isDone) "Complete" else "In progress" ) Text(text = title, style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.weight(1f)) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 9aa1c7a1..1e7e9da2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -57,6 +57,7 @@ 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 +import press.mantra.compose.ui.composable.widgets.Decorative /** * One signing session, and the member's decision about it. @@ -275,7 +276,7 @@ fun FrostSigningScreen( !frostSigningViewModel.isActionPending.value, onClick = { frostSigningViewModel.approve(onNavigateBack) } ) { - Icon(Icons.Default.Draw, contentDescription = null) + Icon(Icons.Default.Draw, contentDescription = Decorative) Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) Text("Sign") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index 4574cbe6..32d966f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -270,7 +270,14 @@ private fun ProposalCard( Icons.Default.ErrorOutline else -> Icons.Default.HourglassEmpty }, - contentDescription = null, + // Carries the stage on its own, and carries it alone on the + // highlighted card where the failure state has no colour to spare. + contentDescription = when { + proposal.awaitsYou -> "Awaiting your signature" + proposal.session.stage == FrostSigningStage.COMPLETE -> "Signed" + proposal.session.stage == FrostSigningStage.FAILED -> "Failed" + else -> "Waiting on others" + }, tint = when { // `error` is 2.67:1 on the highlighted card, so on that one the // failure is carried by the icon shape and the supporting line diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt index 83b899d0..08ca2a73 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -176,6 +177,7 @@ fun SearchScreen( } }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToSearchResult.invoke( SearchResultRoute( @@ -218,6 +220,7 @@ fun SearchScreen( } }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToSearchResult.invoke( SearchResultRoute( @@ -257,6 +260,7 @@ fun SearchScreen( ) }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToProfile.invoke( NostrEventDetailRoute( @@ -362,6 +366,7 @@ fun SearchScreen( ) }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToSearchResult.invoke( SearchResultRoute( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index df062434..5b4ea830 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -56,6 +56,7 @@ import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative /** * Last step of group creation: convenient (one admin) or robust (everyone @@ -345,7 +346,7 @@ private fun ChatRoomTypeCard( Icon( imageVector = icon, - contentDescription = null + contentDescription = Decorative ) Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index a326f414..423a6b4f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -192,7 +193,8 @@ private fun ChunkTranslationRow( // there is no translation yet, the original text is shown greyed out as // a placeholder. It stays plain text, laid out like the original cell // beside it, rather than a button with its own shape and padding. - rightModifier = Modifier.clickable(onClick = onClick), + rightModifier = Modifier.minimumInteractiveComponentSize() + .clickable(onClick = onClick), right = { Row(verticalAlignment = Alignment.CenterVertically) { Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt new file mode 100644 index 00000000..fcfbe9e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.ui.composable.widgets + +/** + * An icon or image that adds nothing a screen reader user would miss. + * + * `contentDescription = null` is the correct Compose API for this -- M3 asks that + * decorative visuals be "annotated as decorative in order to hide them in code", and null + * is how that annotation is spelled. The problem with `null` is not what it does; it is + * that it looks identical whether somebody decided the icon was decorative or never + * thought about it, and 18 of them in this tree were indistinguishable. + * + * `contentDescription = Decorative` compiles to the same null and says which it was. It is + * also greppable, so docs/scripts/m3-audit.sh can count the ones still to triage. + * + * **Use it when the adjacent text already says what the icon says** -- a lock beside + * "Private to Ada", a check beside "The group has a shared key.", an icon inside a button + * whose label is right there. + * + * **Do not use it for an icon carrying state the text does not repeat**: a filled-versus- + * empty circle beside a member's name, a status icon in a list row. Those get a real + * description, and M3's rule for writing one is to name the purpose rather than the + * picture, and never to include the role -- "Contributed", not "green check", and never + * "Contributed icon". + */ +@Suppress("MayBeConstant") // a `const` cannot be nullable, and null is the point +val Decorative: String? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt index 3c6ffd73..ae12de87 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.machankura.compose.ui.composable.widgets.buttons +package press.mantra.compose.ui.composable.widgets.buttons import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Indication @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -38,6 +39,21 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import press.mantra.compose.ui.theme.spacing +/** + * A tappable surface with no chrome of its own -- vendored from ACINQ's phoenix app. + * + * Two things were changed when it moved into `press.mantra` from the `com.machankura` + * package it was still declaring: + * + * - **`minimumInteractiveComponentSize()` is applied unconditionally.** Its defaults are + * `RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as + * whatever is inside it, and the call sites here wrap a 20dp emoji and a row of wallet + * text. The modifier reserves 48x48dp of *layout* -- touch expansion happens at the + * input layer regardless -- which is what keeps adjacent targets from overlapping and + * is what a pointer on desktop has to hit. + * - The modifier ordering. It comes before `.padding(internalPadding)`, since a size + * modifier after it would re-impose the smaller constraint. + */ @Composable fun Clickable( onClick: () -> Unit, @@ -61,6 +77,7 @@ fun Clickable( border = border, contentColor = contentColor, modifier = modifier + .minimumInteractiveComponentSize() .clip(shape) .combinedClickable( onClick = onClick, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt index b5fbd13d..26d5aa9b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -53,7 +54,8 @@ private fun ArticleCard( .fillMaxWidth() .padding(vertical = MaterialTheme.spacing.space75) .then( - if (onArticleClick != null) Modifier.clickable { onArticleClick(30023, author, dTag) } + if (onArticleClick != null) Modifier.minimumInteractiveComponentSize() + .clickable { onArticleClick(30023, author, dTag) } else Modifier ) ) { @@ -141,7 +143,8 @@ private fun ArticleCard( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = if (onProfileClick != null) { - Modifier.clickable { onProfileClick(author) } + Modifier.minimumInteractiveComponentSize() + .clickable { onProfileClick(author) } } else Modifier ) if (publishedAt != null) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt index 91da8aa7..46b81e99 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil3.compose.AsyncImage import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize import press.mantra.compose.ui.theme.spacing @Composable @@ -64,6 +65,7 @@ fun FullScreenImageViewer( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.scrim) + .minimumInteractiveComponentSize() .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt index c6519816..7423711c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -49,6 +50,7 @@ internal fun ImageWithContextMenu(meta: MediaMeta, onFullScreen: () -> Unit) { .fillMaxWidth() .height(200.dp) .clip(RoundedCornerShape(12.dp)) + .minimumInteractiveComponentSize() .clickable { loaded = true }, color = MaterialTheme.colorScheme.surfaceVariant ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt index 68f471a1..833975d9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt @@ -34,6 +34,7 @@ import fr.acinq.lightning.payment.Bolt11Invoice import kotlinx.coroutines.delay import kotlinx.coroutines.launch import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative @Composable internal fun LightningInvoiceCard( @@ -109,7 +110,7 @@ internal fun LightningInvoiceCard( ) { Icon( Icons.Default.Bolt, - contentDescription = null, + contentDescription = Decorative, tint = primary, modifier = Modifier.size(20.dp) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt index b576e70f..3d9f4f13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,6 +26,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.ktor.http.Url import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative @Composable internal fun LinkPreview(url: String) { @@ -49,13 +51,14 @@ internal fun LinkPreview(url: String) { modifier = Modifier .fillMaxWidth() .padding(vertical = MaterialTheme.spacing.space50) + .minimumInteractiveComponentSize() .clickable { uriHandler.openUri(url) } ) { Column { data.image?.let { imageUrl -> AsyncImage( model = imageUrl, - contentDescription = null, + contentDescription = Decorative, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() @@ -120,6 +123,7 @@ internal fun LinkPreview(url: String) { overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(vertical = MaterialTheme.spacing.space25) + .minimumInteractiveComponentSize() .clickable { uriHandler.openUri(url) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt index e9fe3394..32b9ee83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -47,7 +48,8 @@ private fun LiveStreamCard( .fillMaxWidth() .padding(vertical = MaterialTheme.spacing.space75) .then( - if (onLiveStreamClick != null) Modifier.clickable { + if (onLiveStreamClick != null) Modifier.minimumInteractiveComponentSize() + .clickable { onLiveStreamClick(author, dTag, segmentRelayHints.firstOrNull()) } else Modifier diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt index 639c3df7..438ae59a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -149,7 +150,8 @@ internal fun LiveStreamCardContent( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = if (onProfileClick != null) { - Modifier.clickable { onProfileClick(author) } + Modifier.minimumInteractiveComponentSize() + .clickable { onProfileClick(author) } } else Modifier ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt index c38aa510..39eef02b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -73,7 +74,8 @@ fun QuotedNote( modifier = Modifier .fillMaxWidth() .then( - Modifier.clickable { effectiveNoteClick(localQuotedNostrEvent.nostrEvent.id) } + Modifier.minimumInteractiveComponentSize() + .clickable { effectiveNoteClick(localQuotedNostrEvent.nostrEvent.id) } ) ) { localQuotedNostrEvent.RenderNotePreview( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index 58ffab67..bdd1c9fa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -103,6 +104,7 @@ fun TextNoteEventDetail( Surface( modifier = Modifier .fillMaxWidth() + .minimumInteractiveComponentSize() .clickable { onNavigateToWriteAReply.invoke( localNostrEvent.nostrEvent.id diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt index e4fb7890..c0cea8d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,7 +42,8 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( .fillMaxWidth() .padding( horizontal = MaterialTheme.spacing.space125 - ).clickable( + ).minimumInteractiveComponentSize() + .clickable( enabled = true, onClick = { onNavigateToEvent.invoke(profile?.nostrEventId ?: nostrEvent.id) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt index 162fe61c..822e5d4b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment @@ -54,7 +55,8 @@ internal fun TextNoteFeedItem( ) Text( - modifier = Modifier.clickable( + modifier = Modifier.minimumInteractiveComponentSize() + .clickable( onClick = { onNavigateToEvent.invoke(localInReplyToNostrEvent.inReplyToRelation.inReplyToNostrEventId) } @@ -81,7 +83,8 @@ internal fun TextNoteFeedItem( ) { if (profile != null) { Row( - modifier = Modifier.fillMaxWidth().clickable( + modifier = Modifier.fillMaxWidth().minimumInteractiveComponentSize() + .clickable( onClick = { onNavigateToEvent.invoke(profile.nostrEventId) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt index 2058b68f..e20e6ad4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.machankura.compose.ui.composable.widgets.buttons.Clickable +import press.mantra.compose.ui.composable.widgets.buttons.Clickable import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet import press.mantra.compose.ui.theme.spacing diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index 92de6864..b21b735d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.machankura.compose.ui.composable.widgets.buttons.Clickable +import press.mantra.compose.ui.composable.widgets.buttons.Clickable import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.utils.preferences.GlobalPrefs diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index e7bc33f8..eafed371 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -92,6 +93,7 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative class ChatMessageListViewModel( initialChatMessageListUIState: ChatMessageListUIState, @@ -650,7 +652,7 @@ class ChatMessageListViewModel( ) { Icon( Icons.Default.Lock, - contentDescription = null, + contentDescription = Decorative, modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.primary ) @@ -724,7 +726,7 @@ class ChatMessageListViewModel( Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") }, leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null) + Icon(Icons.Default.Lock, contentDescription = Decorative) }, onClick = { participantFor(localChatMessage.chatMessage.senderPublicKey) @@ -864,7 +866,7 @@ private fun ProposalsAwaitingYouNotice( ) { Icon( Icons.Default.Draw, - contentDescription = null + contentDescription = Decorative ) Column( @@ -930,7 +932,7 @@ private fun PrivateMessageNotice( ) { Icon( Icons.Default.Lock, - contentDescription = null, + contentDescription = Decorative, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -1029,6 +1031,7 @@ private fun RitualNotice( Row( modifier = Modifier .fillMaxWidth() + .minimumInteractiveComponentSize() .clickable(onClick = onClick) .padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), @@ -1036,7 +1039,7 @@ private fun RitualNotice( ) { Icon( imageVector = icon, - contentDescription = null, + contentDescription = Decorative, tint = tint ) diff --git a/docs/scripts/m3-audit.sh b/docs/scripts/m3-audit.sh index ddd2ccce..22faa957 100755 --- a/docs/scripts/m3-audit.sh +++ b/docs/scripts/m3-audit.sh @@ -26,8 +26,8 @@ THEME="$UI/theme" # --------------------------------------------------------------------------- BUDGET_HARDCODED_COLOR=0 # phase 3: reached 2026-09-08 BUDGET_SPACING_LITERALS=0 # phase 2: reached 2026-09-08 -BUDGET_BARE_CLICKABLE=33 # phase 3 drives to 0 -BUDGET_NULL_DESCRIPTION=18 # phase 3 triages each one +BUDGET_BARE_CLICKABLE=0 # phase 3: reached 2026-09-08 +BUDGET_NULL_DESCRIPTION=0 # phase 3: reached 2026-09-08 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 @@ -169,10 +169,16 @@ note "hardcoded fontSize: $fontsize" # --------------------------------------------------------------------------- hdr 'Targets and labels (phase 3)' -clickable=$(count '\.clickable') -report 'bare Modifier.clickable' "$clickable" "$BUDGET_BARE_CLICKABLE" +# Counting `.clickable` was never the question -- a clickable Card is fine and a +# clickable Text is not, and only the minimum-size modifier tells them apart. +targets_left=$(python3 docs/scripts/m3-touch-targets.py | grep -oE '[0-9]+$') +report 'clickable chains with no minimum target' "$targets_left" "$BUDGET_BARE_CLICKABLE" +note 'run docs/scripts/m3-touch-targets.py --list to see them' +# `null` and `Decorative` compile to the same thing; the difference is that one of them +# is a decision. Untriaged icons are the count that matters. null_desc=$(count 'contentDescription = null') -report 'contentDescription = null' "$null_desc" "$BUDGET_NULL_DESCRIPTION" +report 'contentDescription = null (untriaged)' "$null_desc" "$BUDGET_NULL_DESCRIPTION" +note "marked Decorative: $(count 'contentDescription = Decorative')" icons=$(count 'Icon\(') note "Icon( call sites: $icons" min_size=$(count 'minimumInteractiveComponentSize') diff --git a/docs/scripts/m3-touch-targets.py b/docs/scripts/m3-touch-targets.py new file mode 100755 index 00000000..56470da1 --- /dev/null +++ b/docs/scripts/m3-touch-targets.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Find `.clickable` chains with no minimum interactive size. + +M3 asks for touch targets of at least 48x48dp and pointer targets of at least 44x44dp. +`Modifier.clickable` gives an element no minimum of its own, so a clickable `Text` is a +target the size of the text -- around 20dp here. + +`minimumInteractiveComponentSize()` is a no-op on anything already 48dp or larger, so the +rule this checks is simply that every `.clickable` chain has it: correct everywhere, and +textual enough to enforce. It reserves *layout* space; touch expansion happens at the +input layer regardless, and the layout is what stops adjacent targets overlapping and what +a pointer on desktop has to land on. + +Components that carry their own minimum -- IconButton, Button, Checkbox, ListItem and the +rest of material3 -- are not `.clickable` call sites and never appear here. + +Usage: m3-touch-targets.py [--list] +""" +import os, re, sys + +UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui' + + +def offenders(): + found = [] + for root, _, files in os.walk(UI): + for f in sorted(files): + if not f.endswith('.kt'): + continue + path = os.path.join(root, f) + lines = open(path, encoding='utf-8').read().split('\n') + for i, line in enumerate(lines): + if '.clickable' not in line: + continue + if line.lstrip().startswith('//') or line.startswith('import '): + continue + # The modifier may sit on this line or on the one above, since a chain + # broken across lines is the common shape. + window = '\n'.join(lines[max(0, i - 1):i + 1]) + if 'minimumInteractiveComponentSize' in window: + continue + found.append((path, i + 1, line.strip()[:90])) + return found + + +def main(): + found = offenders() + print(f' {"clickable chains with no minimum target":42s} {len(found):6d}') + if '--list' in sys.argv: + for path, line, ctx in found: + print(f' {path.replace(UI + "/", "")}:{line} {ctx}') + return 1 if found else 0 + + +if __name__ == '__main__': + sys.exit(main())