fix(theme): complete color and component contrast contracts (#1417)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 21:14:56 +03:00
1 parent 9529a7e32c
commit 56bb3a88c2
22 files changed
+1013 -318

No files matched your search

@@ -0,0 +1,64 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import org.junit.Assert.assertTrue
import org.junit.Test
/** Enabled-state contract for the pinned Material 3 defaults, verified again by the device gallery. */
class ThemeComponentContrastTest {
@Test
fun everyResolvedPresetSupportsEnabledComponentStates() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val c = resolveColorScheme(preset, dark)
// PR #1417: these are component-state combinations, not just on-color pairs.
val graphics =
listOf(
"switch on thumb/track" to (c.onPrimary to c.primary),
"switch on track/surface" to (c.primary to c.surface),
"switch off thumb/track" to (c.outline to c.surfaceContainerHighest),
"switch off border/surface" to (c.outline to c.surface),
"radio selected/surface" to (c.primary to c.surface),
"radio unselected/surface" to (c.onSurfaceVariant to c.surface),
"checkbox selected/surface" to (c.primary to c.surface),
"checkbox checkmark/box" to (c.onPrimary to c.primary),
"checkbox unselected/surface" to (c.onSurfaceVariant to c.surface),
"slider active/inactive" to (c.primary to c.secondaryContainer),
"field normal border/surface" to (c.outline to c.surface),
"field focused border/surface" to (c.primary to c.surface),
"field error border/surface" to (c.error to c.surface),
"segment selected border/container" to (c.outline to c.secondaryContainer),
"segment unselected border/surface" to (c.outline to c.surface),
)
val text =
listOf(
"primary foreground/surface" to (c.primary to c.surface),
"primary foreground/background" to (c.primary to c.background),
"error foreground/surface" to (c.error to c.surface),
"error foreground/background" to (c.error to c.background),
"field normal label/surface" to (c.onSurfaceVariant to c.surface),
"field focused label/surface" to (c.primary to c.surface),
"field error label/surface" to (c.error to c.surface),
"segment selected content/container" to (c.onSecondaryContainer to c.secondaryContainer),
"segment unselected content/surface" to (c.onSurface to c.surface),
)
graphics.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 3f) }
text.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 4.5f) }
}
}
}
private fun checkPair(
preset: ThemePreset,
dark: Boolean,
name: String,
pair: Pair<Color, Color>,
minimum: Float,
) {
val a = pair.first.luminance()
val b = pair.second.luminance()
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
assertTrue("$preset dark=$dark $name is $ratio, expected >= $minimum", ratio >= minimum)
}
}
@@ -1,67 +1,164 @@
package com.m57.hermescontrol.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import com.m57.hermescontrol.theme.presets.AmoledTheme
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
import com.m57.hermescontrol.theme.presets.DefaultTheme
import com.m57.hermescontrol.theme.presets.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.statusBadgeColors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.reflect.Modifier
/**
* Guards the palette template's color invariants:
* 1. Every shipped error slot pair meets >= 3:1 WCAG contrast.
* 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode.
* 2. ThemeMode combinations are validated at construction time.
* 3. One-mode themes resolve and fall back to the default theme correctly.
* 3. Every Material role is explicitly mapped; fixed roles are mode-independent.
* 4. One-mode themes resolve and fall back to the default theme correctly.
*/
class ThemePaletteTest {
private val themes =
listOf(
ThemePreset.DEFAULT to DefaultTheme,
ThemePreset.MONOCHROME to MonochromeTheme,
ThemePreset.GRUVBOX to GruvboxTheme,
ThemePreset.CATPPUCCIN to CatppuccinTheme,
ThemePreset.AMOLED to AmoledTheme,
)
@Test
fun registryCoversEveryPresetExactlyOnce() {
assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset })
}
@Test
fun errorSlotPairsMeetContrastInEveryShippedMode() {
themes.forEach { (preset, theme) ->
fun textSlotPairsMeetContrastInEveryResolvedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val scheme = theme.schemeFor(dark) ?: return@forEach
assertTrue(
"$preset dark=$dark onError/error contrast must be >= 3:1",
contrast(scheme.onError, scheme.error) >= 3f,
)
assertTrue(
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1",
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f,
)
val c = resolveColorScheme(preset, dark)
val pairs =
listOf(
"onPrimary/primary" to (c.onPrimary to c.primary),
"onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer),
"onSecondary/secondary" to (c.onSecondary to c.secondary),
"onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer),
"onTertiary/tertiary" to (c.onTertiary to c.tertiary),
"onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer),
"onBackground/background" to (c.onBackground to c.background),
"onSurface/surface" to (c.onSurface to c.surface),
"onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant),
"onSurface/surfaceDim" to (c.onSurface to c.surfaceDim),
"onSurface/surfaceBright" to (c.onSurface to c.surfaceBright),
"onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest),
"onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow),
"onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer),
"onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh),
"onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest),
"inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface),
"onError/error" to (c.onError to c.error),
"onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer),
"onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed),
"onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed),
"onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim),
"onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim),
"onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed),
"onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed),
"onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim),
"onSecondaryFixedVariant/secondaryFixedDim" to
(c.onSecondaryFixedVariant to c.secondaryFixedDim),
"onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed),
"onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed),
"onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim),
"onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim),
)
pairs.forEach { (name, colors) ->
val ratio = contrast(colors.first, colors.second)
assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
}
}
}
}
@Test
fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val colors = resolveStatusColors(preset, dark)
StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type ->
// PR #1417: test the mapping consumed by both reusable badge renderers.
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
val ratio = contrast(foreground, background)
assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
}
}
}
}
@Test
fun statusBadgesUseMatchingStatusFillsAndOnColors() {
val c = dummyColors().status
assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c))
assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c))
assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c))
assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c))
}
@Test
fun fixedRolesDoNotChangeBetweenShippedModes() {
ThemeRegistry.forEach { (preset, theme) ->
val dark = theme.darkScheme
val light = theme.lightScheme
if (dark != null && light != null) {
val darkRoles = colorFields(dark).filterKeys { "Fixed" in it }
val lightRoles = colorFields(light).filterKeys { "Fixed" in it }
assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles)
}
}
}
@Test
fun everyMaterialColorRoleIsMappedFromThePalette() {
// Distinct sentinel values catch swapped roles and accidental Material defaults.
val colors = dummyColors()
val expected = colorFields(colors).toMutableMap()
expected["surfaceTint"] = expected.getValue("primary")
expected.putAll(
colorFields(colors.status).filterKeys {
it in
setOf(
"error",
"onError",
"errorContainer",
"onErrorContainer",
)
},
)
val theme = buildTheme(colors, colors)
listOf(theme.darkScheme, theme.lightScheme).forEach { scheme ->
assertEquals(expected, colorFields(requireNotNull(scheme)))
}
}
private fun colorFields(value: Any): Map<String, Long> =
value.javaClass.declaredFields
.filter {
it.type == java.lang.Long.TYPE &&
!Modifier.isStatic(it.modifiers)
}.associate { field ->
field.isAccessible = true
field.name to field.getLong(value)
}
/**
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
* on the screen background (HermesScaffold containerColor = background),
* so the full-bleed text pairs must hold against [ColorScheme.background]:
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
* (The header deliberately avoids `primary`: Nord's light mode reuses its
* pastel Frost accent as primary, which cannot reach 3:1 on a light
* background.)
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
* NORD, which the [themes] list above predates) is covered.
* Header text uses onSurfaceVariant independently of the theme's accent.
* The registry covers every shipped preset and its fallback mode.
*/
@Test
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
ThemePreset.entries.forEach { preset ->
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
val scheme = resolveColorScheme(preset, darkTheme = dark)
assertTrue(
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
contrast(scheme.onSurface, scheme.background) >= 4.5f,
@@ -84,18 +181,18 @@ class ThemePaletteTest {
@Test
fun everyPresetResolvesItsDeclaredModes() {
themes.forEach { (preset, theme) ->
ThemeRegistry.forEach { (preset, theme) ->
listOf(true, false).forEach { dark ->
if (theme.schemeFor(dark) != null) {
assertTrue(
"$preset dark=$dark scheme must resolve",
resolveColorScheme(preset, darkTheme = dark) != null,
)
assertTrue(
"$preset dark=$dark status must resolve",
resolveStatusColors(preset, darkTheme = dark) != null,
)
}
assertSame(
"$preset dark=$dark must resolve its palette or Default fallback",
theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark),
resolveColorScheme(preset, dark),
)
assertSame(
"$preset dark=$dark must resolve its status or Default fallback",
theme.statusFor(dark) ?: DefaultTheme.statusFor(dark),
resolveStatusColors(preset, dark),
)
}
}
}
@@ -169,50 +266,64 @@ class ThemePaletteTest {
private fun dummyColors(): PaletteColors =
PaletteColors(
primary = Color.White,
onPrimary = Color.Black,
primaryContainer = Color.White,
onPrimaryContainer = Color.Black,
secondary = Color.White,
onSecondary = Color.Black,
secondaryContainer = Color.White,
onSecondaryContainer = Color.Black,
tertiary = Color.White,
onTertiary = Color.Black,
tertiaryContainer = Color.White,
onTertiaryContainer = Color.Black,
background = Color.White,
onBackground = Color.Black,
surface = Color.White,
onSurface = Color.Black,
surfaceVariant = Color.White,
onSurfaceVariant = Color.Black,
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color.White,
surfaceContainer = Color.White,
surfaceContainerHigh = Color.White,
surfaceContainerHighest = Color.White,
inverseSurface = Color.Black,
inverseOnSurface = Color.White,
inversePrimary = Color.Black,
outline = Color.Gray,
outlineVariant = Color.Gray,
scrim = Color.Black,
primary = Color(0xFF000000L + 1),
onPrimary = Color(0xFF000000L + 2),
primaryContainer = Color(0xFF000000L + 3),
onPrimaryContainer = Color(0xFF000000L + 4),
secondary = Color(0xFF000000L + 5),
onSecondary = Color(0xFF000000L + 6),
secondaryContainer = Color(0xFF000000L + 7),
onSecondaryContainer = Color(0xFF000000L + 8),
tertiary = Color(0xFF000000L + 9),
onTertiary = Color(0xFF000000L + 10),
tertiaryContainer = Color(0xFF000000L + 11),
onTertiaryContainer = Color(0xFF000000L + 12),
background = Color(0xFF000000L + 13),
onBackground = Color(0xFF000000L + 14),
surface = Color(0xFF000000L + 15),
onSurface = Color(0xFF000000L + 16),
surfaceVariant = Color(0xFF000000L + 17),
onSurfaceVariant = Color(0xFF000000L + 18),
surfaceDim = Color(0xFF000000L + 19),
surfaceBright = Color(0xFF000000L + 20),
primaryFixed = Color(0xFF000000L + 21),
primaryFixedDim = Color(0xFF000000L + 22),
onPrimaryFixed = Color(0xFF000000L + 23),
onPrimaryFixedVariant = Color(0xFF000000L + 24),
secondaryFixed = Color(0xFF000000L + 25),
secondaryFixedDim = Color(0xFF000000L + 26),
onSecondaryFixed = Color(0xFF000000L + 27),
onSecondaryFixedVariant = Color(0xFF000000L + 28),
tertiaryFixed = Color(0xFF000000L + 29),
tertiaryFixedDim = Color(0xFF000000L + 30),
onTertiaryFixed = Color(0xFF000000L + 31),
onTertiaryFixedVariant = Color(0xFF000000L + 32),
surfaceContainerLowest = Color(0xFF000000L + 33),
surfaceContainerLow = Color(0xFF000000L + 34),
surfaceContainer = Color(0xFF000000L + 35),
surfaceContainerHigh = Color(0xFF000000L + 36),
surfaceContainerHighest = Color(0xFF000000L + 37),
inverseSurface = Color(0xFF000000L + 38),
inverseOnSurface = Color(0xFF000000L + 39),
inversePrimary = Color(0xFF000000L + 40),
outline = Color(0xFF000000L + 41),
outlineVariant = Color(0xFF000000L + 42),
scrim = Color(0xFF000000L + 43),
status =
HermesStatusColors(
success = Color.Green,
successContainer = Color.White,
onSuccess = Color.Black,
warning = Color.Yellow,
warningContainer = Color.White,
onWarning = Color.Black,
error = Color.Red,
errorContainer = Color.White,
onError = Color.Black,
onErrorContainer = Color.Black,
info = Color.Blue,
infoContainer = Color.White,
onInfo = Color.Black,
success = Color(0xFF000000L + 100),
successContainer = Color(0xFF000000L + 101),
onSuccess = Color(0xFF000000L + 102),
warning = Color(0xFF000000L + 103),
warningContainer = Color(0xFF000000L + 104),
onWarning = Color(0xFF000000L + 105),
error = Color(0xFF000000L + 106),
errorContainer = Color(0xFF000000L + 107),
onError = Color(0xFF000000L + 108),
onErrorContainer = Color(0xFF000000L + 109),
info = Color(0xFF000000L + 110),
infoContainer = Color(0xFF000000L + 111),
onInfo = Color(0xFF000000L + 112),
),
)
}