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
|
- improve accessibility globally in the app
|
||||||
- add scrollable support for subtitle preferences bottom sheet
|
- add scrollable support for subtitle preferences bottom sheet
|
||||||
|
- improve color contrast handling
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
-1
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
|
|||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -51,7 +51,6 @@ fun OnboardingLevelPage(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.onboarding_level_description),
|
text = stringResource(Res.string.onboarding_level_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
LevelOption(
|
LevelOption(
|
||||||
@@ -110,7 +109,6 @@ private fun LevelOption(
|
|||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ fun OnboardingPageIndicator(
|
|||||||
if (index == currentPage) {
|
if (index == currentPage) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.onBackground
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ private fun PlayerTopBar(
|
|||||||
Text(
|
Text(
|
||||||
text = translation,
|
text = translation,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -10,6 +10,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
@@ -69,13 +70,12 @@ fun LabeledSlider(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
|
modifier = Modifier.weight(1f).alpha(contentAlpha),
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
modifier = Modifier.alpha(contentAlpha),
|
||||||
text = valueLabel,
|
text = valueLabel,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
)
|
)
|
||||||
|
|||||||
-1
@@ -51,7 +51,6 @@ fun PreferenceToggle(
|
|||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ fun RecordView(
|
|||||||
Text(
|
Text(
|
||||||
text = translation,
|
text = translation,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
|
|
||||||
// region Light
|
// region Light
|
||||||
internal val md_light_primary = Color(0xFFFF9201)
|
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_primaryContainer = Color(0xFFFFEBD1)
|
||||||
internal val md_light_onPrimaryContainer = Color(0xFF1C1613)
|
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_background = Color(0xFFF5FAFB)
|
||||||
internal val md_light_onBackground = Color(0xFF171D1E)
|
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_onSurface = Color(0xFF171D1E)
|
||||||
internal val md_light_surfaceVariant = Color(0xFF91949C)
|
internal val md_light_surfaceVariant = Color(0xFFFAFEFF)
|
||||||
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
|
internal val md_light_onSurfaceVariant = Color(0xFF000000)
|
||||||
|
|
||||||
internal val md_light_outline = Color(0xFF6F797B)
|
internal val md_light_outline = Color(0xFF6F797B)
|
||||||
internal val md_light_outlineVariant = Color(0xFFBFC8CA)
|
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_surface = Color(0xFF0E1415)
|
||||||
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
||||||
internal val md_dark_surfaceVariant = Color(0xFF3F484A)
|
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_outline = Color(0xFF899294)
|
||||||
internal val md_dark_outlineVariant = Color(0xFF3F484A)
|
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