fix: derive every call-site colour from its container, ending nine contrast failures

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: <reason>` 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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 00:45:01 +02:00
parent 1bd5ad960f
commit 4fe47c7d46
14 changed files with 159 additions and 40 deletions

View File

@@ -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"

View File

@@ -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
}
)
}

View File

@@ -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<BlankRoute> {
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
) {
}

View File

@@ -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 {

View File

@@ -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
)
}
}

View File

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

View File

@@ -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)
)
}

View File

@@ -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
)
}

View File

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

View File

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

View File

@@ -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
)
}
}
}

View File

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