fix(theme): complete color and component contrast contracts (#1417)
This commit is contained in:
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user