test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test
Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,11 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.example.ui.theme.AuxTypography
|
||||
|
||||
private val lightScheme = lightColorScheme(
|
||||
// The six schemes are `internal` rather than `private` so ColorSchemeContrastTest can
|
||||
// walk the real objects. Testing a copy rebuilt in the test from Color.kt would assert
|
||||
// the palette and miss the wiring, which is the half that has gone wrong before -- a
|
||||
// role pointed at the neighbouring value reads fine in isolation.
|
||||
internal val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
@@ -48,7 +52,7 @@ private val lightScheme = lightColorScheme(
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
private val darkScheme = darkColorScheme(
|
||||
internal val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
@@ -86,7 +90,7 @@ private val darkScheme = darkColorScheme(
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
private val mediumContrastLightColorScheme = lightColorScheme(
|
||||
internal val mediumContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightMediumContrast,
|
||||
onPrimary = onPrimaryLightMediumContrast,
|
||||
primaryContainer = primaryContainerLightMediumContrast,
|
||||
@@ -124,7 +128,7 @@ private val mediumContrastLightColorScheme = lightColorScheme(
|
||||
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastLightColorScheme = lightColorScheme(
|
||||
internal val highContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightHighContrast,
|
||||
onPrimary = onPrimaryLightHighContrast,
|
||||
primaryContainer = primaryContainerLightHighContrast,
|
||||
@@ -162,7 +166,7 @@ private val highContrastLightColorScheme = lightColorScheme(
|
||||
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
|
||||
)
|
||||
|
||||
private val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
internal val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkMediumContrast,
|
||||
onPrimary = onPrimaryDarkMediumContrast,
|
||||
primaryContainer = primaryContainerDarkMediumContrast,
|
||||
@@ -200,7 +204,7 @@ private val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastDarkColorScheme = darkColorScheme(
|
||||
internal val highContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkHighContrast,
|
||||
onPrimary = onPrimaryDarkHighContrast,
|
||||
primaryContainer = primaryContainerDarkHighContrast,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.pow
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Every colour pair the six declared schemes promise, measured.
|
||||
*
|
||||
* M3's accessibility foundation gives two thresholds: small text needs 4.5:1 against
|
||||
* its background, and large text or a meaningful non-text boundary needs 3:1. Disabled
|
||||
* states are exempt. See docs/material-design-conformance.md, "The numbers".
|
||||
*
|
||||
* This passes as written -- the generated palette is sound, and the tightest pair in
|
||||
* the tree is `onPrimaryContainer` on `primaryContainer` at 4.61:1 light. It exists so
|
||||
* that a later edit to Color.kt or to the wiring in Theme.kt cannot quietly break one,
|
||||
* because a broken pair is invisible in review: the two hex values look unrelated, and
|
||||
* the failure only appears on a device, in one theme, to someone who cannot read it.
|
||||
*
|
||||
* It walks the real `ColorScheme` objects rather than rebuilding them from Color.kt, so
|
||||
* it also covers the wiring. A role pointed at its neighbour's value -- `surfaceContainerHigh
|
||||
* = surfaceContainerHighestLight` -- is a plausible slip that reads fine in isolation.
|
||||
*
|
||||
* What is deliberately *not* asserted here:
|
||||
*
|
||||
* - **Monotonicity across the contrast ladder.** The obvious invariant, that
|
||||
* high-contrast beats medium beats default for every pair, is false and correctly
|
||||
* so. In the light high-contrast scheme `surfaceContainerHighest` goes darker to
|
||||
* separate it from `surface`, which lowers its ratio against `onSurface` (13.30 ->
|
||||
* 12.29) while raising the one that matters. Ten pairs move that way. The floor is
|
||||
* the invariant; the ladder is not.
|
||||
* - **`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.
|
||||
*/
|
||||
class ColorSchemeContrastTest {
|
||||
|
||||
private val schemes: List<Pair<String, ColorScheme>> = listOf(
|
||||
"light" to lightScheme,
|
||||
"dark" to darkScheme,
|
||||
"light medium-contrast" to mediumContrastLightColorScheme,
|
||||
"dark medium-contrast" to mediumContrastDarkColorScheme,
|
||||
"light high-contrast" to highContrastLightColorScheme,
|
||||
"dark high-contrast" to highContrastDarkColorScheme,
|
||||
)
|
||||
|
||||
/**
|
||||
* Container role paired with the content role M3 assigns to it. Text drawn on the
|
||||
* first is drawn in the second, so each of these is a small-text pairing.
|
||||
*/
|
||||
private val textPairs: List<Triple<String, (ColorScheme) -> Color, (ColorScheme) -> Color>> =
|
||||
listOf(
|
||||
Triple("onPrimary on primary", { s: ColorScheme -> s.primary }, { s: ColorScheme -> s.onPrimary }),
|
||||
Triple("onPrimaryContainer on primaryContainer", { s: ColorScheme -> s.primaryContainer }, { s: ColorScheme -> s.onPrimaryContainer }),
|
||||
Triple("onSecondary on secondary", { s: ColorScheme -> s.secondary }, { s: ColorScheme -> s.onSecondary }),
|
||||
Triple("onSecondaryContainer on secondaryContainer", { s: ColorScheme -> s.secondaryContainer }, { s: ColorScheme -> s.onSecondaryContainer }),
|
||||
Triple("onTertiary on tertiary", { s: ColorScheme -> s.tertiary }, { s: ColorScheme -> s.onTertiary }),
|
||||
Triple("onTertiaryContainer on tertiaryContainer", { s: ColorScheme -> s.tertiaryContainer }, { s: ColorScheme -> s.onTertiaryContainer }),
|
||||
Triple("onError on error", { s: ColorScheme -> s.error }, { s: ColorScheme -> s.onError }),
|
||||
Triple("onErrorContainer on errorContainer", { s: ColorScheme -> s.errorContainer }, { s: ColorScheme -> s.onErrorContainer }),
|
||||
Triple("onBackground on background", { s: ColorScheme -> s.background }, { s: ColorScheme -> s.onBackground }),
|
||||
Triple("onSurface on surface", { s: ColorScheme -> s.surface }, { s: ColorScheme -> s.onSurface }),
|
||||
Triple("onSurfaceVariant on surfaceVariant", { s: ColorScheme -> s.surfaceVariant }, { s: ColorScheme -> s.onSurfaceVariant }),
|
||||
Triple("inverseOnSurface on inverseSurface", { s: ColorScheme -> s.inverseSurface }, { s: ColorScheme -> s.inverseOnSurface }),
|
||||
)
|
||||
|
||||
/**
|
||||
* The tonal surfaces. All eight carry `onSurface` content -- there is no
|
||||
* `onSurfaceContainer` role -- so every one of them is a text background, and a
|
||||
* scheme that darkens one of them without checking is how this breaks.
|
||||
*/
|
||||
private val tonalSurfaces: List<Pair<String, (ColorScheme) -> Color>> = listOf(
|
||||
"surfaceDim" to { s: ColorScheme -> s.surfaceDim },
|
||||
"surfaceBright" to { s: ColorScheme -> s.surfaceBright },
|
||||
"surfaceContainerLowest" to { s: ColorScheme -> s.surfaceContainerLowest },
|
||||
"surfaceContainerLow" to { s: ColorScheme -> s.surfaceContainerLow },
|
||||
"surfaceContainer" to { s: ColorScheme -> s.surfaceContainer },
|
||||
"surfaceContainerHigh" to { s: ColorScheme -> s.surfaceContainerHigh },
|
||||
"surfaceContainerHighest" to { s: ColorScheme -> s.surfaceContainerHighest },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every content role reads at 4_5 to 1 on its container`() {
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
schemes.forEach { (schemeName, scheme) ->
|
||||
textPairs.forEach { (pairName, container, content) ->
|
||||
val ratio = contrastRatio(container(scheme), content(scheme))
|
||||
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 `onSurface reads at 4_5 to 1 on every tonal surface`() {
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
schemes.forEach { (schemeName, scheme) ->
|
||||
tonalSurfaces.forEach { (surfaceName, surface) ->
|
||||
val ratio = contrastRatio(surface(scheme), scheme.onSurface)
|
||||
if (ratio < SMALL_TEXT_MINIMUM) {
|
||||
failures += "$schemeName: onSurface on $surfaceName is ${ratio.format()}:1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outline separates from every surface it is drawn on at 3 to 1`() {
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
schemes.forEach { (schemeName, scheme) ->
|
||||
(listOf("surface" to { s: ColorScheme -> s.surface }) + tonalSurfaces)
|
||||
.forEach { (surfaceName, surface) ->
|
||||
val ratio = contrastRatio(surface(scheme), scheme.outline)
|
||||
if (ratio < NON_TEXT_MINIMUM) {
|
||||
failures += "$schemeName: outline on $surfaceName is ${ratio.format()}:1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.isEmpty(), "below 3:1 —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a filled container stands off the surface behind it at 3 to 1`() {
|
||||
// M3 asks for 3:1 between a clustered interactive container and its background.
|
||||
// A standalone element such as a FAB is exempt by prominence, but `primary` and
|
||||
// `error` are both used for buttons that sit beside other buttons.
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
schemes.forEach { (schemeName, scheme) ->
|
||||
listOf(
|
||||
"primary" to scheme.primary,
|
||||
"error" to scheme.error,
|
||||
).forEach { (roleName, role) ->
|
||||
val ratio = contrastRatio(scheme.surface, role)
|
||||
if (ratio < NON_TEXT_MINIMUM) {
|
||||
failures += "$schemeName: $roleName on surface is ${ratio.format()}:1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.isEmpty(), "below 3:1 —\n" + failures.joinToString("\n"))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** WCAG 2.x, small text. */
|
||||
const val SMALL_TEXT_MINIMUM = 4.5
|
||||
|
||||
/** WCAG 2.x, large text and meaningful non-text elements. */
|
||||
const val NON_TEXT_MINIMUM = 3.0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG relative luminance of one sRGB channel.
|
||||
*
|
||||
* The 0.03928 knee and the 2.4 exponent are the specification's, not an approximation
|
||||
* of gamma 2.2 -- swapping in the simpler curve moves borderline pairs by enough to
|
||||
* change a verdict, which is the whole point of this file.
|
||||
*/
|
||||
private fun channelLuminance(component: Float): Double {
|
||||
val c = component.toDouble()
|
||||
return if (c <= 0.03928) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
}
|
||||
|
||||
private fun Color.relativeLuminance(): Double =
|
||||
0.2126 * channelLuminance(red) +
|
||||
0.7152 * channelLuminance(green) +
|
||||
0.0722 * channelLuminance(blue)
|
||||
|
||||
/**
|
||||
* Contrast ratio between two opaque colours, 1.0 to 21.0.
|
||||
*
|
||||
* Both arguments must be opaque. A translucent colour has no ratio of its own -- it has
|
||||
* one only once composited over something -- so composite it first and pass the result.
|
||||
* Phase 3 needs that for the `.copy(alpha = 0.5f)` call sites; the schemes here are all
|
||||
* fully opaque.
|
||||
*/
|
||||
internal fun contrastRatio(a: Color, b: Color): Double {
|
||||
val la = a.relativeLuminance()
|
||||
val lb = b.relativeLuminance()
|
||||
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
/** Two decimal places, without pulling in a platform formatter. */
|
||||
private fun Double.format(): String {
|
||||
val scaled = (this * 100).toInt()
|
||||
return "${scaled / 100}.${(scaled % 100).toString().padStart(2, '0')}"
|
||||
}
|
||||
Reference in New Issue
Block a user