refactor: remove redundant color attributes and improve color contrast handling

This commit is contained in:
Antoine Jaury
2026-10-19 15:53:25 +02:00
parent 0add1176db
commit 34cb8d24a4
10 changed files with 150 additions and 16 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- improve accessibility globally in the app
- add scrollable support for subtitle preferences bottom sheet
- improve color contrast handling
### Fixed
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
@@ -51,7 +51,6 @@ fun OnboardingLevelPage(
Text(
text = stringResource(Res.string.onboarding_level_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
LevelOption(
@@ -110,7 +109,6 @@ private fun LevelOption(
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -32,7 +32,7 @@ fun OnboardingPageIndicator(
if (index == currentPage) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
MaterialTheme.colorScheme.onBackground
},
),
)
@@ -241,7 +241,6 @@ private fun PlayerTopBar(
Text(
text = translation,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -10,6 +10,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@@ -69,13 +70,12 @@ fun LabeledSlider(
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).alpha(contentAlpha),
)
Text(
modifier = Modifier.alpha(contentAlpha),
text = valueLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
)
@@ -51,7 +51,6 @@ fun PreferenceToggle(
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -60,7 +60,6 @@ fun RecordView(
Text(
text = translation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -4,7 +4,7 @@ import androidx.compose.ui.graphics.Color
// region Light
internal val md_light_primary = Color(0xFFFF9201)
internal val md_light_onPrimary = Color(0xFFFFFFFF)
internal val md_light_onPrimary = Color(0xFF000000)
internal val md_light_primaryContainer = Color(0xFFFFEBD1)
internal val md_light_onPrimaryContainer = Color(0xFF1C1613)
@@ -25,10 +25,10 @@ internal val md_light_onErrorContainer = Color(0xFF410002)
internal val md_light_background = Color(0xFFF5FAFB)
internal val md_light_onBackground = Color(0xFF171D1E)
internal val md_light_surface = Color(0xFFF5FAFB)
internal val md_light_surface = Color(0xFFFFFFFF)
internal val md_light_onSurface = Color(0xFF171D1E)
internal val md_light_surfaceVariant = Color(0xFF91949C)
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
internal val md_light_surfaceVariant = Color(0xFFFAFEFF)
internal val md_light_onSurfaceVariant = Color(0xFF000000)
internal val md_light_outline = Color(0xFF6F797B)
internal val md_light_outlineVariant = Color(0xFFBFC8CA)
@@ -64,7 +64,7 @@ internal val md_dark_onBackground = Color(0xFFDEE3E4)
internal val md_dark_surface = Color(0xFF0E1415)
internal val md_dark_onSurface = Color(0xFFDEE3E4)
internal val md_dark_surfaceVariant = Color(0xFF3F484A)
internal val md_dark_onSurfaceVariant = Color(0xFFBFC8CA)
internal val md_dark_onSurfaceVariant = Color(0xFFFFFFFF)
internal val md_dark_outline = Color(0xFF899294)
internal val md_dark_outlineVariant = Color(0xFF3F484A)
@@ -0,0 +1,139 @@
package bzh.ajaury.chombev.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import kotlin.math.round
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
/**
* Checks the color pairs of both themes against the WCAG 2.2 AA contrast ratios: 4.5:1 for text
* (1.4.3) and 3:1 for meaningful non-text elements such as borders (1.4.11).
*
* Each failure lists every offending pair at once, with its ratio, so a palette change can be fixed
* in one go. The palette lives in commonMain, so checking it here covers every platform.
*/
class ColorContrastTest {
@Test
fun lightColorSchemeMeetsWcagAa() {
assertMeetsWcagAa(ChomBevLightColorScheme, themeName = "light")
}
@Test
fun darkColorSchemeMeetsWcagAa() {
assertMeetsWcagAa(ChomBevDarkColorScheme, themeName = "dark")
}
@Test
fun contrastRatioMatchesWcagReferenceValues() {
assertEquals(21.0, contrastRatio(Color.Black, Color.White), absoluteTolerance = 0.01)
assertEquals(1.0, contrastRatio(Color.White, Color.White), absoluteTolerance = 0.01)
// Reference value from the WebAIM contrast checker: #767676 is the lightest gray passing
// 4.5:1 on white.
assertEquals(4.54, contrastRatio(Color(0xFF767676), Color.White), absoluteTolerance = 0.01)
// The ratio does not depend on which color is the foreground.
assertEquals(contrastRatio(Color.Red, Color.White), contrastRatio(Color.White, Color.Red))
}
private fun assertMeetsWcagAa(scheme: ColorScheme, themeName: String) {
val failures = contrastPairs(scheme).mapNotNull { pair ->
val ratio = contrastRatio(pair.foreground, pair.background)
if (ratio >= pair.minRatio) {
null
} else {
"${pair.name}: ${ratio.format()}:1 (minimum ${pair.minRatio}:1)"
}
}
if (failures.isNotEmpty()) {
fail(
"The $themeName color scheme fails WCAG AA contrast for:\n" +
failures.joinToString(separator = "\n") { " - $it" },
)
}
}
private fun contrastPairs(scheme: ColorScheme): List<ContrastPair> = with(scheme) {
val surfaces = listOf(
"surface" to surface,
"surfaceDim" to surfaceDim,
"surfaceBright" to surfaceBright,
"surfaceContainerLowest" to surfaceContainerLowest,
"surfaceContainerLow" to surfaceContainerLow,
"surfaceContainer" to surfaceContainer,
"surfaceContainerHigh" to surfaceContainerHigh,
"surfaceContainerHighest" to surfaceContainerHighest,
)
buildList {
// Content colors on the color they are paired with.
text("onPrimary", onPrimary, "primary", primary)
text("onPrimaryContainer", onPrimaryContainer, "primaryContainer", primaryContainer)
text("onSecondary", onSecondary, "secondary", secondary)
text("onSecondaryContainer", onSecondaryContainer, "secondaryContainer", secondaryContainer)
text("onTertiary", onTertiary, "tertiary", tertiary)
text("onTertiaryContainer", onTertiaryContainer, "tertiaryContainer", tertiaryContainer)
text("onError", onError, "error", error)
text("onErrorContainer", onErrorContainer, "errorContainer", errorContainer)
text("onBackground", onBackground, "background", background)
text("onSurfaceVariant", onSurfaceVariant, "surfaceVariant", surfaceVariant)
text("inverseOnSurface", inverseOnSurface, "inverseSurface", inverseSurface)
// Snackbar action.
text("inversePrimary", inversePrimary, "inverseSurface", inverseSurface)
surfaces.forEach { (surfaceName, surfaceColor) ->
text("onSurface", onSurface, surfaceName, surfaceColor)
text("onSurfaceVariant", onSurfaceVariant, surfaceName, surfaceColor)
// Colored text: text buttons, slider values, error messages.
text("primary", primary, surfaceName, surfaceColor)
text("error", error, surfaceName, surfaceColor)
// Borders of text fields and outlined components.
nonText("outline", outline, surfaceName, surfaceColor)
}
}
}
private fun MutableList<ContrastPair>.text(
foregroundName: String,
foreground: Color,
backgroundName: String,
background: Color,
) {
add(ContrastPair("$foregroundName on $backgroundName", foreground, background, TEXT_MIN_RATIO))
}
private fun MutableList<ContrastPair>.nonText(
foregroundName: String,
foreground: Color,
backgroundName: String,
background: Color,
) {
add(ContrastPair("$foregroundName on $backgroundName", foreground, background, NON_TEXT_MIN_RATIO))
}
private data class ContrastPair(
val name: String,
val foreground: Color,
val background: Color,
val minRatio: Double,
)
private companion object {
const val TEXT_MIN_RATIO = 4.5
const val NON_TEXT_MIN_RATIO = 3.0
/** WCAG contrast ratio, from 1 (identical) to 21 (black on white). */
fun contrastRatio(first: Color, second: Color): Double {
// Color.luminance() is the WCAG relative luminance of the sRGB color.
val lighter = maxOf(first.luminance(), second.luminance())
val darker = minOf(first.luminance(), second.luminance())
return (lighter + 0.05) / (darker + 0.05)
}
fun Double.format(): String {
val hundredths = round(this * 100).toInt()
return "${hundredths / 100}.${(hundredths % 100).toString().padStart(2, '0')}"
}
}
}