From 4fe47c7d46b6c8d07e848f8690164fa119127997 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 00:45:01 +0200 Subject: [PATCH] fix: derive every call-site colour from its container, ending nine contrast failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3, first step, of docs/material-design-conformance.md. The generated palette was already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every failure in the app came from a colour reached for at the call site instead of derived from what it sits on. **The worst one made the app's most important rows invisible.** `ProposalListScreen` put a `ListItem` inside a `Card` and overrode only the card's container: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so `LocalContentColor` inside the card was correct. `ListItem` does not read `LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. Measured on that card: headline (onSurface) 1.00:1 invisible leading icon (primary) 1.22:1 supporting (onSurfaceVariant) 1.84:1 "could not be read" (error) 2.67:1 onPrimaryContainer 4.61:1 the only one that worked Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` -- the proposals waiting on your signature. Dark was fine throughout, because there `primaryContainer` is black, so this only ever showed in the light scheme. The card's colours are now computed once and everything inside derives from `cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the "Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would read more like "this needs you", and that is a design call recorded in a comment rather than taken here. On the highlighted card the failure state loses its red, because `error` is 2.67:1 there. The signal survives in the icon and in the sentence "could not be read", which is the more robust cue anyway and the only one available to somebody who cannot distinguish the red. **`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer` with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help. **Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.** The audit called them caption text; they are `CircularProgressIndicator` colours, so the threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant` at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to become one. **The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1, under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be read and is 6.46:1. **The avatar picker used a content colour as a background.** `onSurface` at 50% composited to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being straight about the limit: that role is 1.65:1 against the surface in this palette, which M3 accepts because its own selected states carry a second cue, an outline or a checkmark. This grid has neither. Adding one is component work, and the comment and the plan both say so rather than leaving it looking finished. **Three colours stay hardcoded, and each says why at the site.** A new `// m3-color-exempt: ` marker, matching the spacing convention from phase 2, and the audit honours it: - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance contrast between the modules and their background, and under dynamic colour `onSurface`/`surface` could be two mid tones and unscannable. - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so no 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. - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With no placeholder the surface is known and the role is used. Exemptions belong at the call site: the reason travels with the code and a reviewer sees it in the diff that adds it, rather than in a list of file names in the audit script. **Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's `Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every one of this app's six schemes. Same bytes, and the value now travels with the theme. **A new assertion for the case the others structurally cannot catch.** A translucent container has no contrast ratio of its own -- it has one only once composited -- so `ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted containers over `surface` and measures the result, in all six schemes, naming the call site in the failure. The pairings this commit *fixed* are not restated: once the proposal card derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion already walks. **Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 --- .../compose/ui/composable/HomeScreen.kt | 8 +-- .../ui/composable/ProposalListScreen.kt | 57 ++++++++++++++----- .../ui/composable/navigation/MantraNavHost.kt | 7 ++- .../ui/composable/widgets/QRCodeView.kt | 4 ++ .../composable/widgets/content/ArticleCard.kt | 4 +- .../widgets/content/FullScreenImageViewer.kt | 9 ++- .../widgets/content/LiveStreamCardContent.kt | 9 ++- .../widgets/content/LoadingAsyncImage.kt | 5 ++ .../widgets/content/QuotedAddressableNote.kt | 2 +- .../composable/widgets/content/QuotedNote.kt | 2 +- .../composable/widgets/wallet/WalletAvatar.kt | 18 +++++- .../ui/theme/ColorSchemeContrastTest.kt | 46 ++++++++++++++- docs/material-design-conformance.md | 2 +- docs/scripts/m3-audit.sh | 26 +++++++-- 14 files changed, 159 insertions(+), 40 deletions(-) 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