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:
@@ -221,9 +221,74 @@ val surfaceContainerDarkHighContrast = Color(0xFF303030)
|
||||
val surfaceContainerHighDarkHighContrast = Color(0xFF3B3B3B)
|
||||
val surfaceContainerHighestDarkHighContrast = Color(0xFF474747)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixed colour roles
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The twelve *Fixed* roles are theme-independent by definition: a container that
|
||||
// stays light in both light and dark, with content that stays dark on it. That is
|
||||
// why they carry no Light/Dark suffix here. M3 defines them by tone --
|
||||
// `xFixed` = tone 90, `xFixedDim` = tone 80, `onXFixed` = tone 10,
|
||||
// `onXFixedVariant` = tone 30 (ColorLightTokens.kt, material3 1.10.0-alpha05, whose
|
||||
// light and dark token files carry identical values for all twelve).
|
||||
//
|
||||
// Before this block they were simply absent, so `lightColorScheme()` defaulted them
|
||||
// to `PaletteTokens.Primary90` and friends -- #EADDFF, Material baseline lavender --
|
||||
// in a monochrome app, in both themes.
|
||||
//
|
||||
// **How these were derived.** Tone is CIE L*, so a tone of a chroma-0 palette is
|
||||
// exactly the sRGB grey at that L*. Inverting L* -> Y -> sRGB reproduces this
|
||||
// palette's existing greys to the byte: tone 0 = #000000 (primaryLight), tone 10 =
|
||||
// #1B1B1B (primaryContainerLight, onSurfaceLight), tone 20 = #303030 (onPrimaryDark),
|
||||
// tone 40 = #5E5E5E (inversePrimaryDark), tone 80 = #C6C6C6 (primaryDark), tone 90 =
|
||||
// #E2E2E2 (onSurfaceDark), tone 95 = #F1F1F1 (inverseOnSurfaceLight), tone 100 =
|
||||
// #FFFFFF. So the neutral family below is computed, not chosen.
|
||||
//
|
||||
// The secondary palette is gold at Lab hue 87.5 degrees, and its dark half is
|
||||
// maximum in-gamut chroma at that hue -- which regenerates onSecondaryDark (#3D2F00,
|
||||
// tone 20) and secondaryLight (#745B00, tone 40) byte for byte. Tones 10 and 30 below
|
||||
// come off the same ramp. Its tones 90 and 80 are not regenerated but reused: the
|
||||
// palette already ships #FFDE82 at tone 90 (secondaryDark) and the brand gold #EFBF04
|
||||
// at tone 80 (secondaryContainer, in both themes). Generating them instead would have
|
||||
// produced #FFDF99 and #F1C100 -- a second, almost identical gold two units away from
|
||||
// the one already on screen, which is worse than no gold at all.
|
||||
//
|
||||
// Sanity check on the derivation: the four ratios these families produce land within
|
||||
// 0.1 of M3's own baseline fixed family (13.30/7.17/10.08/5.44 here against
|
||||
// 13.32/7.23/10.08/5.47 for source #6750A4). Tone, not hue, sets the ratio.
|
||||
|
||||
val primaryFixed = Color(0xFFE2E2E2) // neutral tone 90
|
||||
val primaryFixedDim = Color(0xFFC6C6C6) // neutral tone 80
|
||||
val secondaryFixed = Color(0xFFFFDE82) // gold tone 90, = secondaryDark
|
||||
val secondaryFixedDim = Color(0xFFEFBF04) // gold tone 80, = secondaryContainer
|
||||
// The tertiary family is a copy of primary throughout this palette -- compare
|
||||
// tertiaryLight to primaryLight. Regenerate these two with primary's, or give
|
||||
// tertiary its own hue and regenerate all six.
|
||||
val tertiaryFixed = Color(0xFFE2E2E2)
|
||||
val tertiaryFixedDim = Color(0xFFC6C6C6)
|
||||
|
||||
// Content on the fixed containers. The containers hold across the contrast setting --
|
||||
// they are the brand-visible half -- and the content darkens, which is the same move
|
||||
// Color.kt already makes for onSurface (#1B1B1B -> #111111 -> #000000) and
|
||||
// onSurfaceVariant. Tones 10/30, then 5/20, then 0/10.
|
||||
val onPrimaryFixed = Color(0xFF1B1B1B) // neutral tone 10
|
||||
val onPrimaryFixedVariant = Color(0xFF474747) // neutral tone 30
|
||||
val onSecondaryFixed = Color(0xFF241A00) // gold tone 10
|
||||
val onSecondaryFixedVariant = Color(0xFF584400) // gold tone 30
|
||||
val onTertiaryFixed = Color(0xFF1B1B1B)
|
||||
val onTertiaryFixedVariant = Color(0xFF474747)
|
||||
|
||||
val onPrimaryFixedMediumContrast = Color(0xFF111111) // neutral tone 5
|
||||
val onPrimaryFixedVariantMediumContrast = Color(0xFF303030) // neutral tone 20
|
||||
val onSecondaryFixedMediumContrast = Color(0xFF171000) // gold tone 5
|
||||
val onSecondaryFixedVariantMediumContrast = Color(0xFF3D2F00) // gold tone 20
|
||||
val onTertiaryFixedMediumContrast = Color(0xFF111111)
|
||||
val onTertiaryFixedVariantMediumContrast = Color(0xFF303030)
|
||||
|
||||
|
||||
val onPrimaryFixedHighContrast = Color(0xFF000000) // neutral tone 0
|
||||
val onPrimaryFixedVariantHighContrast = Color(0xFF1B1B1B) // neutral tone 10
|
||||
val onSecondaryFixedHighContrast = Color(0xFF000000) // gold tone 0 -- no
|
||||
val onSecondaryFixedVariantHighContrast = Color(0xFF241A00) // chroma survives L*=0
|
||||
val onTertiaryFixedHighContrast = Color(0xFF000000)
|
||||
val onTertiaryFixedVariantHighContrast = Color(0xFF1B1B1B)
|
||||
|
||||
|
||||
@@ -50,6 +50,27 @@ internal val lightScheme = lightColorScheme(
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
// Explicit only so that no role is left to a default. This is the value
|
||||
// lightColorScheme()/darkColorScheme() would have supplied anyway, and it is
|
||||
// right: surfaceColorAtElevation composites surfaceTint over surface at 2-8%
|
||||
// alpha, so an elevated light surface darkens toward primary and an elevated
|
||||
// dark one lightens toward it, which is M3's own behaviour. Nothing reads it
|
||||
// today -- the app sets no elevations anywhere.
|
||||
surfaceTint = primaryLight,
|
||||
// Theme-independent by definition -- these carry no Light/Dark variant. See the
|
||||
// "Fixed colour roles" block in Color.kt for how the tones were derived.
|
||||
primaryFixed = primaryFixed,
|
||||
primaryFixedDim = primaryFixedDim,
|
||||
onPrimaryFixed = onPrimaryFixed,
|
||||
onPrimaryFixedVariant = onPrimaryFixedVariant,
|
||||
secondaryFixed = secondaryFixed,
|
||||
secondaryFixedDim = secondaryFixedDim,
|
||||
onSecondaryFixed = onSecondaryFixed,
|
||||
onSecondaryFixedVariant = onSecondaryFixedVariant,
|
||||
tertiaryFixed = tertiaryFixed,
|
||||
tertiaryFixedDim = tertiaryFixedDim,
|
||||
onTertiaryFixed = onTertiaryFixed,
|
||||
onTertiaryFixedVariant = onTertiaryFixedVariant,
|
||||
)
|
||||
|
||||
internal val darkScheme = darkColorScheme(
|
||||
@@ -88,6 +109,27 @@ internal val darkScheme = darkColorScheme(
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
// Explicit only so that no role is left to a default. This is the value
|
||||
// lightColorScheme()/darkColorScheme() would have supplied anyway, and it is
|
||||
// right: surfaceColorAtElevation composites surfaceTint over surface at 2-8%
|
||||
// alpha, so an elevated light surface darkens toward primary and an elevated
|
||||
// dark one lightens toward it, which is M3's own behaviour. Nothing reads it
|
||||
// today -- the app sets no elevations anywhere.
|
||||
surfaceTint = primaryDark,
|
||||
// Theme-independent by definition -- these carry no Light/Dark variant. See the
|
||||
// "Fixed colour roles" block in Color.kt for how the tones were derived.
|
||||
primaryFixed = primaryFixed,
|
||||
primaryFixedDim = primaryFixedDim,
|
||||
onPrimaryFixed = onPrimaryFixed,
|
||||
onPrimaryFixedVariant = onPrimaryFixedVariant,
|
||||
secondaryFixed = secondaryFixed,
|
||||
secondaryFixedDim = secondaryFixedDim,
|
||||
onSecondaryFixed = onSecondaryFixed,
|
||||
onSecondaryFixedVariant = onSecondaryFixedVariant,
|
||||
tertiaryFixed = tertiaryFixed,
|
||||
tertiaryFixedDim = tertiaryFixedDim,
|
||||
onTertiaryFixed = onTertiaryFixed,
|
||||
onTertiaryFixedVariant = onTertiaryFixedVariant,
|
||||
)
|
||||
|
||||
internal val mediumContrastLightColorScheme = lightColorScheme(
|
||||
@@ -126,6 +168,28 @@ internal val mediumContrastLightColorScheme = lightColorScheme(
|
||||
surfaceContainer = surfaceContainerLightMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
|
||||
// Explicit only so that no role is left to a default. This is the value
|
||||
// lightColorScheme()/darkColorScheme() would have supplied anyway, and it is
|
||||
// right: surfaceColorAtElevation composites surfaceTint over surface at 2-8%
|
||||
// alpha, so an elevated light surface darkens toward primary and an elevated
|
||||
// dark one lightens toward it, which is M3's own behaviour. Nothing reads it
|
||||
// today -- the app sets no elevations anywhere.
|
||||
surfaceTint = primaryLightMediumContrast,
|
||||
// Theme-independent by definition -- these carry no Light/Dark variant. See the
|
||||
// "Fixed colour roles" block in Color.kt for how the tones were derived.
|
||||
// Content darkens with the contrast setting; the containers hold.
|
||||
primaryFixed = primaryFixed,
|
||||
primaryFixedDim = primaryFixedDim,
|
||||
onPrimaryFixed = onPrimaryFixedMediumContrast,
|
||||
onPrimaryFixedVariant = onPrimaryFixedVariantMediumContrast,
|
||||
secondaryFixed = secondaryFixed,
|
||||
secondaryFixedDim = secondaryFixedDim,
|
||||
onSecondaryFixed = onSecondaryFixedMediumContrast,
|
||||
onSecondaryFixedVariant = onSecondaryFixedVariantMediumContrast,
|
||||
tertiaryFixed = tertiaryFixed,
|
||||
tertiaryFixedDim = tertiaryFixedDim,
|
||||
onTertiaryFixed = onTertiaryFixedMediumContrast,
|
||||
onTertiaryFixedVariant = onTertiaryFixedVariantMediumContrast,
|
||||
)
|
||||
|
||||
internal val highContrastLightColorScheme = lightColorScheme(
|
||||
@@ -164,6 +228,28 @@ internal val highContrastLightColorScheme = lightColorScheme(
|
||||
surfaceContainer = surfaceContainerLightHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
|
||||
// Explicit only so that no role is left to a default. This is the value
|
||||
// lightColorScheme()/darkColorScheme() would have supplied anyway, and it is
|
||||
// right: surfaceColorAtElevation composites surfaceTint over surface at 2-8%
|
||||
// alpha, so an elevated light surface darkens toward primary and an elevated
|
||||
// dark one lightens toward it, which is M3's own behaviour. Nothing reads it
|
||||
// today -- the app sets no elevations anywhere.
|
||||
surfaceTint = primaryLightHighContrast,
|
||||
// Theme-independent by definition -- these carry no Light/Dark variant. See the
|
||||
// "Fixed colour roles" block in Color.kt for how the tones were derived.
|
||||
// Content darkens with the contrast setting; the containers hold.
|
||||
primaryFixed = primaryFixed,
|
||||
primaryFixedDim = primaryFixedDim,
|
||||
onPrimaryFixed = onPrimaryFixedHighContrast,
|
||||
onPrimaryFixedVariant = onPrimaryFixedVariantHighContrast,
|
||||
secondaryFixed = secondaryFixed,
|
||||
secondaryFixedDim = secondaryFixedDim,
|
||||
onSecondaryFixed = onSecondaryFixedHighContrast,
|
||||
onSecondaryFixedVariant = onSecondaryFixedVariantHighContrast,
|
||||
tertiaryFixed = tertiaryFixed,
|
||||
tertiaryFixedDim = tertiaryFixedDim,
|
||||
onTertiaryFixed = onTertiaryFixedHighContrast,
|
||||
onTertiaryFixedVariant = onTertiaryFixedVariantHighContrast,
|
||||
)
|
||||
|
||||
internal val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
@@ -202,6 +288,28 @@ internal val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
surfaceContainer = surfaceContainerDarkMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
|
||||
// Explicit only so that no role is left to a default. This is the value
|
||||
// lightColorScheme()/darkColorScheme() would have supplied anyway, and it is
|
||||
// right: surfaceColorAtElevation composites surfaceTint over surface at 2-8%
|
||||
// alpha, so an elevated light surface darkens toward primary and an elevated
|
||||
// dark one lightens toward it, which is M3's own behaviour. Nothing reads it
|
||||
// today -- the app sets no elevations anywhere.
|
||||
surfaceTint = primaryDarkMediumContrast,
|
||||
// Theme-independent by definition -- these carry no Light/Dark variant. See the
|
||||
// "Fixed colour roles" block in Color.kt for how the tones were derived.
|
||||
// Content darkens with the contrast setting; the containers hold.
|
||||
primaryFixed = primaryFixed,
|
||||
primaryFixedDim = primaryFixedDim,
|
||||
onPrimaryFixed = onPrimaryFixedMediumContrast,
|
||||
onPrimaryFixedVariant = onPrimaryFixedVariantMediumContrast,
|
||||
secondaryFixed = secondaryFixed,
|
||||
secondaryFixedDim = secondaryFixedDim,
|
||||
onSecondaryFixed = onSecondaryFixedMediumContrast,
|
||||
onSecondaryFixedVariant = onSecondaryFixedVariantMediumContrast,
|
||||
tertiaryFixed = tertiaryFixed,
|
||||
tertiaryFixedDim = tertiaryFixedDim,
|
||||
onTertiaryFixed = onTertiaryFixedMediumContrast,
|
||||
onTertiaryFixedVariant = onTertiaryFixedVariantMediumContrast,
|
||||
)
|
||||
|
||||
internal val highContrastDarkColorScheme = darkColorScheme(
|
||||
@@ -240,6 +348,28 @@ internal val highContrastDarkColorScheme = darkColorScheme(
|
||||
surfaceContainer = surfaceContainerDarkHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
|
||||
// Explicit only so that no role is left to a default. This is the value
|
||||
// lightColorScheme()/darkColorScheme() would have supplied anyway, and it is
|
||||
// right: surfaceColorAtElevation composites surfaceTint over surface at 2-8%
|
||||
// alpha, so an elevated light surface darkens toward primary and an elevated
|
||||
// dark one lightens toward it, which is M3's own behaviour. Nothing reads it
|
||||
// today -- the app sets no elevations anywhere.
|
||||
surfaceTint = primaryDarkHighContrast,
|
||||
// Theme-independent by definition -- these carry no Light/Dark variant. See the
|
||||
// "Fixed colour roles" block in Color.kt for how the tones were derived.
|
||||
// Content darkens with the contrast setting; the containers hold.
|
||||
primaryFixed = primaryFixed,
|
||||
primaryFixedDim = primaryFixedDim,
|
||||
onPrimaryFixed = onPrimaryFixedHighContrast,
|
||||
onPrimaryFixedVariant = onPrimaryFixedVariantHighContrast,
|
||||
secondaryFixed = secondaryFixed,
|
||||
secondaryFixedDim = secondaryFixedDim,
|
||||
onSecondaryFixed = onSecondaryFixedHighContrast,
|
||||
onSecondaryFixedVariant = onSecondaryFixedVariantHighContrast,
|
||||
tertiaryFixed = tertiaryFixed,
|
||||
tertiaryFixedDim = tertiaryFixedDim,
|
||||
onTertiaryFixed = onTertiaryFixedHighContrast,
|
||||
onTertiaryFixedVariant = onTertiaryFixedVariantHighContrast,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
||||
Reference in New Issue
Block a user