refactor: remove redundant color attributes and improve color contrast handling
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
-1
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
+4
-4
@@ -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,
|
||||
)
|
||||
|
||||
-1
@@ -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')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user