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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user