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,81 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.resolveStatusColors
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class StatusBadgeColorTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun latteBadgesRenderStatusFillsAndMatchingOnColors() {
val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }
composeTestRule.setContent {
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) {
Column {
types.forEach { type ->
StatusBadge(type.name, type, Modifier.testTag("badge_$type"))
}
}
}
}
val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false)
types.forEach { type ->
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb())
}
}
@Test
fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() {
composeTestRule.setContent {
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) {
Column {
SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working")
SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting")
}
}
}
val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false)
assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb())
assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb())
}
private fun assertRenderedPair(
tag: String,
background: Int,
foreground: Int,
) {
// PR #1417: verify pixels as well as the palette mapping, so a renderer
// switching back to accent-on-container cannot leave the contrast test green.
val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap()
assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb())
assertTrue(
"$tag must render the matching on-status text/icon",
(0 until pixels.height).any { y ->
(0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground }
},
)
}
}
@@ -0,0 +1,238 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.ThemeRegistry
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
@MediumTest
class ThemeComponentGalleryTest(
private val preset: ThemePreset,
private val dark: Boolean,
) {
@get:Rule
val composeTestRule = createComposeRule()
private var samples = emptyList<Sample>()
@Test
fun enabledControlsRenderTheirAccessibleDefaultStateColors() {
composeTestRule.setContent {
HermesControlTheme(
themePreset = preset,
themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT,
) {
Gallery()
}
}
composeTestRule.waitForIdle()
// Assert the public defaults from the installed Compose version, not a copied token table.
assertTrue("gallery must publish its state contracts", samples.isNotEmpty())
samples.forEach { sample ->
val a = sample.foreground.luminance()
val b = sample.background.luminance()
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum)
}
// Pixel evidence catches defaults that differ from the actual rendered state (including focus).
samples.groupBy { it.tag }.forEach { (tag, contracts) ->
val pixels =
composeTestRule
.onNodeWithTag(tag)
.performScrollTo()
.captureToImage()
.toPixelMap()
val rendered =
buildSet {
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) add(pixels[x, y].toArgb())
}
}
contracts.forEach { sample ->
assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered)
if (sample.checkBackground) {
assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered)
}
}
}
}
@Composable
private fun Gallery() {
val c = MaterialTheme.colorScheme
val switches = SwitchDefaults.colors()
val radios = RadioButtonDefaults.colors()
val checks = CheckboxDefaults.colors()
val sliders = SliderDefaults.colors()
val fields = OutlinedTextFieldDefaults.colors()
val segments = SegmentedButtonDefaults.colors()
val focus = remember { FocusRequester() }
SideEffect {
samples =
listOf(
Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true),
Sample("switch_on", switches.checkedTrackColor, c.surface),
Sample(
"switch_off",
switches.uncheckedThumbColor,
switches.uncheckedTrackColor,
checkBackground = true,
),
Sample("switch_off", switches.uncheckedBorderColor, c.surface),
Sample("radio_on", radios.selectedColor, c.surface),
Sample("radio_off", radios.unselectedColor, c.surface),
Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true),
Sample("check_on", checks.checkedBorderColor, c.surface),
Sample("check_off", checks.uncheckedBorderColor, c.surface),
Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true),
Sample(
"field_normal",
fields.unfocusedIndicatorColor,
fields.unfocusedContainerColor.compositeOver(c.surface),
),
Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f),
Sample(
"field_focus",
fields.focusedIndicatorColor,
fields.focusedContainerColor.compositeOver(c.surface),
),
Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f),
Sample(
"field_error",
fields.errorIndicatorColor,
fields.errorContainerColor.compositeOver(c.surface),
),
Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f),
Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true),
Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor),
Sample(
"segment_off",
segments.inactiveContentColor,
segments.inactiveContainerColor.compositeOver(c.surface),
4.5f,
),
Sample("segment_off", segments.inactiveBorderColor, c.surface),
Sample("primary_text", c.primary, c.surface, 4.5f),
Sample("error_text", c.error, c.surface, 4.5f),
)
}
Surface(color = c.surface, modifier = Modifier.fillMaxSize()) {
Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) {
Text("$preset ${if (dark) "dark" else "light"}")
Row {
Switch(true, {}, Modifier.testTag("switch_on"))
Switch(false, {}, Modifier.testTag("switch_off"))
RadioButton(true, {}, Modifier.testTag("radio_on"))
RadioButton(false, {}, Modifier.testTag("radio_off"))
}
Row {
Checkbox(true, {}, Modifier.testTag("check_on"))
Checkbox(false, {}, Modifier.testTag("check_off"))
}
Slider(0.5f, {}, Modifier.testTag("slider"))
OutlinedTextField("Value", {
}, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") })
OutlinedTextField(
"Value",
{
},
Modifier
.fillMaxWidth()
.focusRequester(
focus,
).testTag("field_focus"),
readOnly = true,
label = { Text("Focused") },
)
OutlinedTextField("Value", {
}, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = {
Text("Error")
}, supportingText = { Text("Error text") })
SingleChoiceSegmentedButtonRow {
SegmentedButton(
true,
{},
SegmentedButtonDefaults.itemShape(0, 2),
Modifier.testTag("segment_on"),
) { Text("Selected") }
SegmentedButton(
false,
{
},
SegmentedButtonDefaults.itemShape(
1,
2,
),
Modifier.testTag("segment_off"),
) { Text("Unselected") }
}
Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary)
Text("Error foreground", Modifier.testTag("error_text"), color = c.error)
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}
private data class Sample(
val tag: String,
val foreground: Color,
val background: Color,
val minimum: Float = 3f,
val checkBackground: Boolean = false,
)
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0} dark={1}")
fun presets(): List<Array<Any>> =
ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf<Any>(preset, it) } }
}
}