From 34cb8d24a4bf010ff384e4df1152db2c90baedf9 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 19 Oct 2026 15:53:25 +0200 Subject: [PATCH] refactor: remove redundant color attributes and improve color contrast handling --- CHANGELOG.md | 1 + .../ui/components/OnboardingInfoPage.kt | 1 - .../ui/components/OnboardingLevelPage.kt | 2 - .../ui/components/OnboardingPageIndicator.kt | 2 +- .../ajaury/chombev/player/ui/PlayerScreen.kt | 1 - .../player/ui/components/LabeledSlider.kt | 8 +- .../preferences/ui/PreferenceToggle.kt | 1 - .../ajaury/chombev/records/ui/RecordView.kt | 1 - .../kotlin/bzh/ajaury/chombev/theme/Color.kt | 10 +- .../ajaury/chombev/theme/ColorContrastTest.kt | 139 ++++++++++++++++++ 10 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 shared/src/jvmTest/kotlin/bzh/ajaury/chombev/theme/ColorContrastTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 98432ab..724f6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt index 2216f18..c0a0aac 100644 --- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingInfoPage.kt @@ -46,7 +46,6 @@ fun OnboardingInfoPage( Text( text = description, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt index 6cb3a97..f4717c0 100644 --- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingLevelPage.kt @@ -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, ) } } diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt index 1a9da3f..38c1283 100644 --- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/components/OnboardingPageIndicator.kt @@ -32,7 +32,7 @@ fun OnboardingPageIndicator( if (index == currentPage) { MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.surfaceVariant + MaterialTheme.colorScheme.onBackground }, ), ) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index 19eaa4d..acd481f 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -241,7 +241,6 @@ private fun PlayerTopBar( Text( text = translation, style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt index f7e8f84..c5967a8 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt @@ -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, ) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt index 68219dc..4c16cfb 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt @@ -51,7 +51,6 @@ fun PreferenceToggle( Text( text = subtitle, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt index 987b07a..03414ab 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt @@ -60,7 +60,6 @@ fun RecordView( Text( text = translation, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt index 50cbbfb..1ba5797 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt @@ -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) diff --git a/shared/src/jvmTest/kotlin/bzh/ajaury/chombev/theme/ColorContrastTest.kt b/shared/src/jvmTest/kotlin/bzh/ajaury/chombev/theme/ColorContrastTest.kt new file mode 100644 index 0000000..9cc4034 --- /dev/null +++ b/shared/src/jvmTest/kotlin/bzh/ajaury/chombev/theme/ColorContrastTest.kt @@ -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 = 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.text( + foregroundName: String, + foreground: Color, + backgroundName: String, + background: Color, + ) { + add(ContrastPair("$foregroundName on $backgroundName", foreground, background, TEXT_MIN_RATIO)) + } + + private fun MutableList.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')}" + } + } +}