From 86c9628eeec3db6a61f127db8de15bd51e3b1e14 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Mon, 7 Sep 2026 23:59:34 +0200 Subject: [PATCH] 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 --- .../press/mantra/compose/ui/theme/Color.kt | 67 ++++++++- .../press/mantra/compose/ui/theme/Theme.kt | 130 ++++++++++++++++++ .../ui/theme/ColorSchemeContrastTest.kt | 96 +++++++++++++ docs/scripts/m3-audit.sh | 2 +- 4 files changed, 293 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt index 809f631c..3e650908 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index 7614bab4..6c4816ae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -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 diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt index d97a445b..4cc6bf1f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt @@ -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() + + 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() + + 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() + + 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() @@ -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 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 diff --git a/docs/scripts/m3-audit.sh b/docs/scripts/m3-audit.sh index 446753d4..842baf6a 100755 --- a/docs/scripts/m3-audit.sh +++ b/docs/scripts/m3-audit.sh @@ -31,7 +31,7 @@ BUDGET_BARE_CLICKABLE=33 # phase 3 drives to 0 BUDGET_NULL_DESCRIPTION=18 # phase 3 triages each one BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10 BUDGET_TITLE_CASE=-1 # phase 4 drives to 0 -BUDGET_UNSET_COLOR_ROLES=12 # phase 1 drives to 0 +BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07 # The M3 spacing scale: docs/material-design-conformance.md, "The numbers". # space0..space900. Anything outside this set is off-scale.