diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index b2943b28..2df7cac9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -110,11 +110,11 @@ fun HomeScreen( Scaffold( modifier = Modifier, topBar = { + // No colour override. It was `containerColor = primaryContainer` with + // `titleContentColor = primary`, which in the light scheme is #000000 + // on #1B1B1B -- 1.22:1, a black title on a near-black bar. The + // default is `surface`/`onSurface` and needs no help. TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary - ), title = { Text( "Torch" 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 9df6c947..4574cbe6 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 @@ -226,18 +226,40 @@ private fun ProposalCard( "None of its events could be read" } + // Every colour inside this card is derived from the card, rather than reached for + // independently, because ListItem does not inherit LocalContentColor -- its headline + // comes from ListTokens.ItemLabelTextColor, which is `onSurface`. With the container + // at `primaryContainer` and the headline at `onSurface`, the light scheme drew + // #1B1B1B on #1B1B1B: 1.00:1, invisible, and applied to exactly the proposals that + // await your signature. The icon tint (`primary`) was 1.22:1, the supporting text + // 1.84:1 and the error line 2.67:1. Only the dark scheme was legible, because there + // `primaryContainer` is black. + val cardColors = if (proposal.awaitsYou) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + } + // `primaryContainer` is kept as the highlight so this stays a fix rather than a + // restyle. It is a near-black card in the light scheme, and `secondaryContainer` -- + // the brand gold, 4.56:1 against its own content -- would read more like "this needs + // you". That is a design call, not an accessibility one. + val cardContentColor = cardColors.contentColor + Card( onClick = onClick, - colors = if (proposal.awaitsYou) { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - } else { - CardDefaults.cardColors() - } + colors = cardColors ) { ListItem( - colors = ListItemDefaults.colors(containerColor = Color.Transparent), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = cardContentColor, + overlineColor = cardContentColor, + supportingColor = cardContentColor, + leadingIconColor = cardContentColor, + trailingIconColor = cardContentColor, + ), leadingContent = { Icon( imageVector = when { @@ -250,10 +272,13 @@ private fun ProposalCard( }, contentDescription = null, tint = when { - proposal.session.stage == FrostSigningStage.FAILED -> - MaterialTheme.colorScheme.error - proposal.awaitsYou -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant + // `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 + // rather than by colour -- which is the more robust signal + // anyway, and the only one available to a monochrome display. + proposal.session.stage == FrostSigningStage.FAILED && + !proposal.awaitsYou -> MaterialTheme.colorScheme.error + else -> cardContentColor } ) }, @@ -266,7 +291,7 @@ private fun ProposalCard( Text( text = "Review", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary + color = cardContentColor ) } @@ -302,7 +327,11 @@ private fun ProposalCard( if (proposal.unreadable > 0) { Text( text = "${proposal.unreadable} of them could not be read", - color = MaterialTheme.colorScheme.error + color = if (proposal.awaitsYou) { + cardContentColor + } else { + MaterialTheme.colorScheme.error + } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index f4a39fe4..931641a2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.composable.navigation import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -674,7 +675,11 @@ fun MantraNavHost( composable { Surface( modifier = Modifier.fillMaxSize(), - color = Color.Black + // Nothing is drawn on it, so this is a backdrop rather than a surface + // carrying content. `scrim` is the role for that, and is #000000 in every + // one of this app's schemes -- so the pixels are unchanged and the value + // now moves with the theme instead of standing outside it. + color = MaterialTheme.colorScheme.scrim ) { } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt index 38909501..80f9d258 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt @@ -65,6 +65,10 @@ class QRCodeBackgroundPainter( fun QRCodeView( data: String, ) { + // m3-color-exempt: a QR code is read by a camera, not a person. Scanners need + // maximum luminance contrast between the modules and their background, so these + // are black and white rather than onSurface and surface -- which in a dynamic + // colour scheme could be two mid tones and unscannable. val qrCodeColor = if (isSystemInDarkTheme()) { Color.White } else { 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 e03997cc..b5fbd13d 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 @@ -65,7 +65,7 @@ private fun ArticleCard( CircularProgressIndicator( modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( @@ -152,7 +152,7 @@ private fun ArticleCard( ) }", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } 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 d2702a65..91da8aa7 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 @@ -63,7 +63,7 @@ fun FullScreenImageViewer( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .background(MaterialTheme.colorScheme.scrim) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -92,7 +92,12 @@ fun FullScreenImageViewer( .padding(MaterialTheme.spacing.containerPadding) ) { val buttonColors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Black.copy(alpha = 0.5f), + // m3-color-exempt: this button floats over an arbitrary + // photograph, so no scheme role is safe behind it. A translucent + // scrim with white on it is M3's own full-screen media treatment + // and the only pairing that holds over both a white sky and a + // black one. + containerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), contentColor = Color.White ) 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 e0ae6f04..639c3df7 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 @@ -40,7 +40,7 @@ internal fun LiveStreamCardContent( CircularProgressIndicator( modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( @@ -84,13 +84,16 @@ internal fun LiveStreamCardContent( if (status == "live") { Surface( shape = RoundedCornerShape(4.dp), - color = Color(0xFFE53935), + // Was #E53935 with a white label: 4.23:1, under the floor + // for text this size. `error` is the role for a red that has + // to be read, and carries its own `onError`. + color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(end = MaterialTheme.spacing.compactPadding) ) { Text( text = "LIVE", style = MaterialTheme.typography.labelSmall, - color = Color.White, + color = MaterialTheme.colorScheme.onError, modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space75, vertical = MaterialTheme.spacing.space25) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt index b64475c0..87337626 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt @@ -62,6 +62,11 @@ internal fun LoadingAsyncImage( CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, + // m3-color-exempt: over a blurhash placeholder the backdrop is an + // arbitrary blurred image, so no role is reliably legible on it. + // White is the conventional choice and holds on all but a white + // photograph. Without a placeholder the surface is known and the + // role is used. color = if (blurPainter != null) Color.White else MaterialTheme.colorScheme.primary ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt index c717f558..d0c16d3c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt @@ -60,7 +60,7 @@ internal fun QuotedAddressableNote( .width(14.dp) .height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( 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 2ff943a5..c38aa510 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 @@ -99,7 +99,7 @@ fun QuotedNote( .width(14.dp) .height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( 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 b7304daf..2058b68f 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 @@ -103,8 +103,22 @@ fun ColumnScope.AvatarPicker( showPickerDialog = false onAvatarChange(emoji) }) { - val mutedBgColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - WalletAvatar(emoji, backgroundColor = if (emoji == avatar) mutedBgColor else Color.Transparent) + // `secondaryContainer` is M3's role for a selected item, and it + // replaces `onSurface` at 50% -- a content colour used as a + // background, which composited to a mid grey 2.49:1 from the + // unselected cells beside it. + // + // The tonal container is not itself high-contrast against the + // surface in this palette (1.65:1), which M3 accepts because its + // own selected states carry a second cue -- an outline or a + // checkmark. This grid has neither, and adding one is component + // work rather than a colour fix. Recorded in + // docs/material-design-conformance.md. + val selectedBackground = MaterialTheme.colorScheme.secondaryContainer + WalletAvatar( + emoji, + backgroundColor = if (emoji == avatar) selectedBackground else Color.Transparent + ) } } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt index e9a6f267..e81928c1 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import kotlin.math.pow import kotlin.test.Test import kotlin.test.assertTrue @@ -34,9 +35,12 @@ import kotlin.test.assertTrue * - **`outlineVariant`.** It reads 1.61:1 against surface, which looks alarming and is * not a defect: M3's own baseline sits in the same range, and outlineVariant is a * decorative divider. `outline`, the meaningful-boundary role, is asserted at 3:1. - * - **Call-site pairings.** Seven of those are below threshold today, the worst at - * 1.00:1. They belong to phase 3 of the conformance plan and get their own table - * there; asserting them now would mean checking in a red test. + * - **Call sites that pair two roles the scheme already covers.** Once + * `ProposalListScreen` derives its `ListItem` colours from its `Card`, the pairing it + * produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion + * already walks. Restating it here would double the maintenance and catch nothing. + * What *is* asserted per call site is the composited case, below, because a + * translucent container has no ratio until it is put over something. */ class ColorSchemeContrastTest { @@ -248,6 +252,42 @@ class ColorSchemeContrastTest { ) } + @Test + fun `translucent containers still carry their content at 4_5 to 1 once composited`() { + // Call sites that tint a container with `.copy(alpha = …)`. A translucent colour + // has no ratio of its own, so the only way to check one is to composite it over + // what is actually behind it and measure that -- which is why these could not be + // caught by the role-pair assertions above, and why one of them was missed for + // as long as it was. + // + // Each row names the call site so a failure says where to go. `surface` is the + // backdrop in every case; a screen that puts one of these on a tonal surface + // instead would need its own row. + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + listOf( + Triple( + "ChatRoomMessagingScreen: private-message field", + scheme.primaryContainer.copy(alpha = 0.3f), + scheme.onSurface, + ), + Triple( + "UnsupportedKindBadge / LinkPreview: tinted badge", + scheme.surfaceVariant.copy(alpha = 0.3f), + scheme.onSurfaceVariant, + ), + ).forEach { (site, container, content) -> + val ratio = contrastRatio(container.compositeOver(scheme.surface), content) + if (ratio < SMALL_TEXT_MINIMUM) { + failures += "$schemeName: $site is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 4.5:1 once composited —\n" + failures.joinToString("\n")) + } + @Test fun `outline separates from every surface it is drawn on at 3 to 1`() { val failures = mutableListOf() diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index 867ea1e0..d5f93f28 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -151,7 +151,7 @@ Measured against the light scheme: |---|---|---|---| | `ProposalListScreen.kt:228` | `ListItem` headline (`onSurface`) on a `Card` of `primaryContainer` | **1.00:1** | 4.5:1 | | `HomeScreen.kt:113` | `titleContentColor = primary` on `containerColor = primaryContainer` | **1.22:1** | 4.5:1 | -| `ArticleCard.kt:67`, `QuotedNote.kt:101`, `QuotedAddressableNote.kt:62`, `LiveStreamCardContent.kt:42` | `onSurfaceVariant.copy(alpha = 0.5f)` on surface | 2.49:1 | 4.5:1 | +| `ArticleCard.kt:67`, `QuotedNote.kt:101`, `QuotedAddressableNote.kt:62`, `LiveStreamCardContent.kt:42` | `onSurfaceVariant.copy(alpha = 0.5f)` on surface — these four are `CircularProgressIndicator` colours, not text, so the threshold is 3:1 rather than 4.5:1. Still under it. | 2.49:1 | 3:1 | | `CreateProfileScreen.kt:329` | `Color.DarkGray` on `BluePill` | 2.90:1 | 4.5:1 | | `ArticleCard.kt:154` | `onSurfaceVariant.copy(alpha = 0.7f)` on surface | 3.96:1 | 4.5:1 | | `CreateProfileScreen.kt:311` | `Color.White` on `RedPill` (75% alpha over surface) | 3.50:1 | 4.5:1 | diff --git a/docs/scripts/m3-audit.sh b/docs/scripts/m3-audit.sh index 30c93099..ddd2ccce 100755 --- a/docs/scripts/m3-audit.sh +++ b/docs/scripts/m3-audit.sh @@ -24,7 +24,7 @@ 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_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 @@ -55,6 +55,23 @@ report() { # commit explained what it had replaced. NOT_A_COMMENT='^[^:]*:[[:space:]]*(//|\*|/\*)' +# A colour that genuinely cannot come from a role -- a QR code's modules, a control +# floating over an arbitrary photograph -- is marked at the site with +# `// m3-color-exempt: ` on the lines above it, the same convention +# m3-spacing-positions.py uses. `grep -A` pulls the following lines in so the marker +# above a literal suppresses it; the reason travels with the code rather than living +# in a list of file names in this script. +hardcoded_colours() { + grep -rE -A 8 'm3-color-exempt' "$UI" --include=*.kt 2>/dev/null \ + | grep -E 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \ + | sed 's/^\([^-:]*\)[-:]/\1:/' | sort -u > /tmp/.m3-exempt-lines.$$ + grep -rE 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \ + "$UI" --include=*.kt 2>/dev/null \ + | grep -v "^$THEME/" | grep -vE "$NOT_A_COMMENT" \ + | grep -vxFf /tmp/.m3-exempt-lines.$$ 2>/dev/null + rm -f /tmp/.m3-exempt-lines.$$ +} + # Count matches across the UI tree, optionally excluding the theme package. # $1 pattern, $2 "exclude-theme" | "all" count() { @@ -113,12 +130,9 @@ report 'roles falling to the baseline palette' "$unset_baseline" "$BUDGET_UNSET_ [[ -n $baseline_list ]] && note "lavender:$baseline_list" [[ -n $derived_list ]] && note "derived (not a defect):$derived_list" -hardcoded=$(count 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' exclude-theme) +hardcoded=$(hardcoded_colours | wc -l | tr -d ' ') report 'hardcoded Color outside theme/' "$hardcoded" "$BUDGET_HARDCODED_COLOR" -# No -n, so the line is path:content and NOT_A_COMMENT's single-colon prefix matches. -[[ $hardcoded -gt 0 ]] && grep -rE 'Color\(0x|Color\.(Red|Blue|Green|Gray|LightGray|DarkGray|White|Black|Yellow|Magenta|Cyan)' \ - "$UI" --include=*.kt | grep -v "^$THEME/" | grep -vE "$NOT_A_COMMENT" \ - | cut -d: -f1 | sort -u | sed "s|$UI/| |" +[[ $hardcoded -gt 0 ]] && hardcoded_colours | cut -d: -f1 | sort -u | sed "s|$UI/| |" alpha=$(count '\.copy\(alpha') report 'colours derived with .copy(alpha =)' "$alpha" -1