fix: assign every ColorScheme role, so no component can fall back to Material lavender
Phase 1, step 1 of docs/material-design-conformance.md. `Theme.kt` assigned 36 of the
49 roles `androidx.compose.material3.ColorScheme` declares. The other thirteen took
`lightColorScheme()`/`darkColorScheme()` defaults, and for twelve of them that default
is the Material baseline palette: `primaryFixed` -> `ColorLightTokens.PrimaryFixed` ->
`PaletteTokens.Primary90` -> **#EADDFF**. Lavender, in an app whose primary is
`#000000`, in both themes, in all six schemes.
Nothing in the tree reads a fixed role today, which is why nobody has seen it. That
also means it could not have been found by looking at the app -- it springs the first
time an expressive component reaches for one, and it will look like a rendering bug
rather than a missing assignment.
**The tones were computed, not chosen.** M3 defines the family by tone: `xFixed` =
tone 90, `xFixedDim` = 80, `onXFixed` = 10, `onXFixedVariant` = 30, and ColorLightTokens
and ColorDarkTokens carry identical values for all twelve -- theme-independence is what
"fixed" means. Tone is CIE L*, so for a chroma-0 palette a tone is exactly the sRGB grey
at that L*, and inverting L* -> Y -> sRGB reproduces this palette's own greys **to the
byte**:
tone 0 #000000 primaryLight
tone 10 #1B1B1B primaryContainerLight, onSurfaceLight
tone 20 #303030 onPrimaryDark, inverseSurfaceLight
tone 40 #5E5E5E inversePrimaryDark
tone 80 #C6C6C6 primaryDark, inversePrimaryLight
tone 90 #E2E2E2 onSurfaceDark, surfaceContainerHighestLight
tone 95 #F1F1F1 inverseOnSurfaceLight
tone 100 #FFFFFF onPrimaryLight
Eight independent hits. The primary and tertiary palettes are the standard M3 neutral
tonal palette at chroma 0, so their fixed families are derived rather than invented.
**The secondary palette is gold at Lab hue 87.5 degrees, and its dark half is maximum
in-gamut chroma at that hue.** Generating tones off that ramp regenerates
`onSecondaryDark` (#3D2F00, tone 20) and `secondaryLight` (#745B00, tone 40) byte for
byte, which is what licenses using it for tones 10 (#241A00) and 30 (#584400).
Its tones 90 and 80 are **reused rather than regenerated**. The palette already ships
#FFDE82 at tone 90 (as `secondaryDark`) and the brand gold #EFBF04 at tone 80 (as
`secondaryContainer`, identical in light and dark -- someone hand-set it, no generator
emits that). Regenerating would have produced #FFDF99 and #F1C100: a second gold two
units from the one already on screen, indistinguishable in isolation and wrong beside
it. A near-duplicate brand colour is worse than none.
**Sanity check on the whole derivation.** The four ratios these families produce land
within 0.1 of M3's own baseline fixed family --
onFixed on Fixed 13.30 (baseline 13.32)
onFixedVariant on Fixed 7.17 (baseline 7.23)
onFixed on FixedDim 10.08 (baseline 10.08)
onFixedVariant on Dim 5.44 (baseline 5.47)
-- because tone, not hue, sets the ratio. Two palettes with nothing in common landing
on the same four numbers is the check that the tone mapping is right.
**Containers hold across the contrast setting; content darkens.** That is the move
`Color.kt` already makes everywhere else -- `onSurfaceLight` goes #1B1B1B -> #111111 ->
#000000 while `surfaceLight` stays #F9F9F9 through all three -- so the fixed family
follows it: content tones 10/30, then 5/20, then 0/10. The weakest pair ladders
5.44 -> 7.73 -> 10.08. Shifting the containers instead would have moved the brand-visible
half for a setting that is about legibility.
**`surfaceTint` is the thirteenth, and it was never a defect.** Its default is `primary`,
which is correct: `surfaceColorAtElevation` composites it over `surface` at 2-8% alpha,
so an elevated light surface darkens toward primary and an elevated dark one lightens --
M3's own behaviour, and this app sets no elevations anywhere, so nothing reads it. It is
assigned explicitly anyway, with that reasoning in a comment, so that "every role is
assigned" is a property a reader can check by looking rather than by knowing which
omissions were deliberate. m3-audit.sh reports the two kinds apart for the same reason.
**Three new assertions, and the two that matter cannot be satisfied by accident.**
`ColorSchemeContrastTest` grows from 4 to 7:
- both content roles on both fixed containers at 4.5:1, across all six schemes;
- the fixed roles are the same colour in light and dark, which is the definition and
would otherwise only fail on a screen that puts one beside a themed surface;
- no role is left at the Material baseline palette -- the twelve baseline hex values
read out of `PaletteTokens.kt` and asserted absent.
Verified by deleting `primaryFixed = primaryFixed,` from `lightScheme` alone: two tests
fail, naming the role and printing back `Color(0.917, 0.866, 1.0)`. Reverted.
**Audit budget ratcheted 12 -> 0**, dated in the file. Per the header's contract that is
the only direction a budget moves, and the commit that lowers it is the one that earns it.
**Tests.** 920 pass, 583 jvm over 70 classes and 337 android over 42, up from 914/580/337
-- three new assertions counted once per target. `:composeApp:compileDebugKotlinAndroid`
builds, `m3-audit.sh --check` exits 0. No visual change: every role that had a value keeps
it, and the thirteen that gain one were rendering baseline defaults nothing reads yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b0ce8d73b
commit
86c9628eee
4 files changed
+293
-2
No files matched your search
+96
@@ -116,6 +116,83 @@ class ColorSchemeContrastTest {
|
||||
assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both content roles read on both fixed containers at 4_5 to 1`() {
|
||||
// `onXFixedVariant` is the lower-emphasis of the two and `xFixedDim` the darker
|
||||
// container, so onVariant-on-Dim is the corner that decides the family. It is
|
||||
// 5.44:1 in the default schemes -- real headroom, but not much, and a tone
|
||||
// chosen by eye rather than computed would land under it.
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
schemes.forEach { (schemeName, scheme) ->
|
||||
listOf(
|
||||
"primary" to Quad(scheme.primaryFixed, scheme.primaryFixedDim, scheme.onPrimaryFixed, scheme.onPrimaryFixedVariant),
|
||||
"secondary" to Quad(scheme.secondaryFixed, scheme.secondaryFixedDim, scheme.onSecondaryFixed, scheme.onSecondaryFixedVariant),
|
||||
"tertiary" to Quad(scheme.tertiaryFixed, scheme.tertiaryFixedDim, scheme.onTertiaryFixed, scheme.onTertiaryFixedVariant),
|
||||
).forEach { (family, roles) ->
|
||||
listOf(
|
||||
"on${family}Fixed on ${family}Fixed" to contrastRatio(roles.fixed, roles.on),
|
||||
"on${family}FixedVariant on ${family}Fixed" to contrastRatio(roles.fixed, roles.onVariant),
|
||||
"on${family}Fixed on ${family}FixedDim" to contrastRatio(roles.dim, roles.on),
|
||||
"on${family}FixedVariant on ${family}FixedDim" to contrastRatio(roles.dim, roles.onVariant),
|
||||
).forEach { (pairName, ratio) ->
|
||||
if (ratio < SMALL_TEXT_MINIMUM) {
|
||||
failures += "$schemeName: $pairName is ${ratio.format()}:1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fixed roles are the same colour in light and dark`() {
|
||||
// What "fixed" means. A fixed container that shifted with the theme would be an
|
||||
// ordinary container with a misleading name, and the bug would only show on a
|
||||
// screen that puts one beside a themed surface.
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
listOf(
|
||||
"default" to (lightScheme to darkScheme),
|
||||
"medium-contrast" to (mediumContrastLightColorScheme to mediumContrastDarkColorScheme),
|
||||
"high-contrast" to (highContrastLightColorScheme to highContrastDarkColorScheme),
|
||||
).forEach { (level, pair) ->
|
||||
val (light, dark) = pair
|
||||
fixedRoles.forEach { (roleName, role) ->
|
||||
if (role(light) != role(dark)) {
|
||||
failures += "$level: $roleName is ${role(light)} light, ${role(dark)} dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.isEmpty(), "fixed roles differ across themes —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no role is left at the Material baseline palette`() {
|
||||
// A role omitted from lightColorScheme() takes its baseline default, which for
|
||||
// the twelve *Fixed* roles is PaletteTokens.Primary90 and friends -- lavender,
|
||||
// in an app whose primary is pure black. Nothing in the tree reads a fixed role
|
||||
// yet, so this cannot be caught by looking at the app; it has to be asserted.
|
||||
val baselineFixed = setOf(
|
||||
Color(0xFFEADDFF), Color(0xFFD0BCFF), Color(0xFF21005D), Color(0xFF4F378B), // primary
|
||||
Color(0xFFE8DEF8), Color(0xFFCCC2DC), Color(0xFF1D192B), Color(0xFF4A4458), // secondary
|
||||
Color(0xFFFFD8E4), Color(0xFFEFB8C8), Color(0xFF31111D), Color(0xFF633B48), // tertiary
|
||||
)
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
schemes.forEach { (schemeName, scheme) ->
|
||||
fixedRoles.forEach { (roleName, role) ->
|
||||
if (role(scheme) in baselineFixed) {
|
||||
failures += "$schemeName: $roleName is still the baseline ${role(scheme)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.isEmpty(), "unassigned roles —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outline separates from every surface it is drawn on at 3 to 1`() {
|
||||
val failures = mutableListOf<String>()
|
||||
@@ -155,6 +232,25 @@ class ColorSchemeContrastTest {
|
||||
assertTrue(failures.isEmpty(), "below 3:1 —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
/** The twelve theme-independent roles, by name, for the two tests that walk them all. */
|
||||
private val fixedRoles: List<Pair<String, (ColorScheme) -> Color>> = listOf(
|
||||
"primaryFixed" to { s: ColorScheme -> s.primaryFixed },
|
||||
"primaryFixedDim" to { s: ColorScheme -> s.primaryFixedDim },
|
||||
"onPrimaryFixed" to { s: ColorScheme -> s.onPrimaryFixed },
|
||||
"onPrimaryFixedVariant" to { s: ColorScheme -> s.onPrimaryFixedVariant },
|
||||
"secondaryFixed" to { s: ColorScheme -> s.secondaryFixed },
|
||||
"secondaryFixedDim" to { s: ColorScheme -> s.secondaryFixedDim },
|
||||
"onSecondaryFixed" to { s: ColorScheme -> s.onSecondaryFixed },
|
||||
"onSecondaryFixedVariant" to { s: ColorScheme -> s.onSecondaryFixedVariant },
|
||||
"tertiaryFixed" to { s: ColorScheme -> s.tertiaryFixed },
|
||||
"tertiaryFixedDim" to { s: ColorScheme -> s.tertiaryFixedDim },
|
||||
"onTertiaryFixed" to { s: ColorScheme -> s.onTertiaryFixed },
|
||||
"onTertiaryFixedVariant" to { s: ColorScheme -> s.onTertiaryFixedVariant },
|
||||
)
|
||||
|
||||
/** One fixed family: the two containers and the two content roles that sit on them. */
|
||||
private data class Quad(val fixed: Color, val dim: Color, val on: Color, val onVariant: Color)
|
||||
|
||||
private companion object {
|
||||
/** WCAG 2.x, small text. */
|
||||
const val SMALL_TEXT_MINIMUM = 4.5
|
||||
|
||||
Reference in new issue
Block a user