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())