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