feat: replace theme-based text color with WCAG luminance contrast (Closes #7) (#69)

This commit was merged in pull request #69.
This commit is contained in:
2026-03-13 14:08:05 +01:00
parent b819217ff4
commit a9cf04de0e
4 changed files with 148 additions and 12 deletions

View File

@@ -10,12 +10,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -26,7 +23,7 @@ import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants.SMALL_PADDING import org.shahondin1624.lib.components.UiConstants.SMALL_PADDING
import org.shahondin1624.lib.functions.DiceRoll import org.shahondin1624.lib.functions.DiceRoll
import org.shahondin1624.model.attributes.Attribute import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.theme.LocalThemeIsDark import org.shahondin1624.theme.contrastTextColor
import shadowruncharsheet.sharedui.generated.resources.Res import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.dice import shadowruncharsheet.sharedui.generated.resources.dice
@@ -38,8 +35,7 @@ fun Attribute(
}, },
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
) { ) {
var isInDarkMode by LocalThemeIsDark.current val textColor = contrastTextColor(attribute.type.color)
val textColor = if (isInDarkMode) Color.Black else Color.White
Card( Card(
modifier = Modifier modifier = Modifier
.testTag(TestTags.attributeCard(attribute.type.name)) .testTag(TestTags.attributeCard(attribute.type.name))

View File

@@ -15,12 +15,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -32,7 +29,7 @@ import org.shahondin1624.lib.components.UiConstants.SMALL_PADDING
import org.shahondin1624.lib.functions.DiceRoll import org.shahondin1624.lib.functions.DiceRoll
import org.shahondin1624.model.attributes.Attributes import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.talents.TalentDefinition import org.shahondin1624.model.talents.TalentDefinition
import org.shahondin1624.theme.LocalThemeIsDark import org.shahondin1624.theme.contrastTextColor
import shadowruncharsheet.sharedui.generated.resources.Res import shadowruncharsheet.sharedui.generated.resources.Res
import shadowruncharsheet.sharedui.generated.resources.dice import shadowruncharsheet.sharedui.generated.resources.dice
@@ -45,8 +42,7 @@ fun Talent(
}, },
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
) { ) {
var isInDarkMode by LocalThemeIsDark.current val textColor = contrastTextColor(talent.attribute.color)
val textColor = if (isInDarkMode) Color.Black else Color.White
Card( Card(
modifier = Modifier modifier = Modifier
.testTag(TestTags.talentCard(talent.name)) .testTag(TestTags.talentCard(talent.name))

View File

@@ -0,0 +1,54 @@
package org.shahondin1624.theme
import androidx.compose.ui.graphics.Color
import kotlin.math.pow
/**
* Calculates the WCAG 2.1 relative luminance of a color.
* Uses sRGB linearization before applying the standard luminance formula.
*
* @see <a href="https://www.w3.org/TR/WCAG21/#dfn-relative-luminance">WCAG 2.1 Relative Luminance</a>
*/
fun relativeLuminance(color: Color): Double {
fun linearize(channel: Float): Double {
val c = channel.toDouble()
return if (c <= 0.03928) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
}
val r = linearize(color.red)
val g = linearize(color.green)
val b = linearize(color.blue)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Calculates the WCAG 2.1 contrast ratio between two colors.
* Returns a value between 1:1 and 21:1.
*
* @see <a href="https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio">WCAG 2.1 Contrast Ratio</a>
*/
fun contrastRatio(color1: Color, color2: Color): Double {
val l1 = relativeLuminance(color1)
val l2 = relativeLuminance(color2)
val lighter = maxOf(l1, l2)
val darker = minOf(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Returns either [Color.Black] or [Color.White], whichever provides
* the higher contrast ratio against [backgroundColor].
*
* Guarantees at least WCAG 4.5:1 contrast for normal text against
* any opaque background color (mathematical guarantee: at least one
* of black or white always exceeds 4.5:1 for any background).
*/
fun contrastTextColor(backgroundColor: Color): Color {
val luminance = relativeLuminance(backgroundColor)
// Contrast ratio with white: (1.0 + 0.05) / (luminance + 0.05)
// Contrast ratio with black: (luminance + 0.05) / (0.0 + 0.05)
// White is better when the background is dark (low luminance)
// The crossover point is at luminance ~0.1791
val contrastWithWhite = (1.0 + 0.05) / (luminance + 0.05)
val contrastWithBlack = (luminance + 0.05) / (0.0 + 0.05)
return if (contrastWithWhite >= contrastWithBlack) Color.White else Color.Black
}

View File

@@ -0,0 +1,90 @@
package org.shahondin1624
import androidx.compose.ui.graphics.Color
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.theme.contrastRatio
import org.shahondin1624.theme.contrastTextColor
import org.shahondin1624.theme.relativeLuminance
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ContrastUtilsTest {
@Test
fun whiteLuminanceIsOne() {
val luminance = relativeLuminance(Color.White)
assertTrue(luminance > 0.99, "White luminance should be ~1.0, was $luminance")
}
@Test
fun blackLuminanceIsZero() {
val luminance = relativeLuminance(Color.Black)
assertTrue(luminance < 0.01, "Black luminance should be ~0.0, was $luminance")
}
@Test
fun contrastRatioBlackOnWhiteIs21() {
val ratio = contrastRatio(Color.White, Color.Black)
assertTrue(ratio > 20.9, "Contrast ratio of white/black should be ~21:1, was $ratio")
}
@Test
fun contrastRatioIsSymmetric() {
val ratio1 = contrastRatio(Color.Red, Color.Blue)
val ratio2 = contrastRatio(Color.Blue, Color.Red)
assertTrue(
abs(ratio1 - ratio2) < 0.001,
"Contrast ratio should be symmetric: $ratio1 vs $ratio2"
)
}
@Test
fun contrastTextColorForWhiteIsBlack() {
assertEquals(Color.Black, contrastTextColor(Color.White))
}
@Test
fun contrastTextColorForBlackIsWhite() {
assertEquals(Color.White, contrastTextColor(Color.Black))
}
@Test
fun allAttributeColorsMeetWcagContrast() {
for (type in AttributeType.entries) {
val textColor = contrastTextColor(type.color)
val ratio = contrastRatio(type.color, textColor)
assertTrue(
ratio >= 4.5,
"Attribute ${type.name} (color=${type.color}) with text $textColor " +
"has contrast ratio $ratio, which is below WCAG 4.5:1"
)
}
}
@Test
fun midGrayGetsAppropriateContrast() {
// Mid-gray (0.5, 0.5, 0.5) should get either black or white with sufficient contrast
val midGray = Color(0.5f, 0.5f, 0.5f)
val textColor = contrastTextColor(midGray)
val ratio = contrastRatio(midGray, textColor)
assertTrue(
ratio >= 4.5,
"Mid-gray with text $textColor has contrast ratio $ratio, below WCAG 4.5:1"
)
}
@Test
fun darkBlueGetsWhiteText() {
// Dark blue like Willpower (Color.Blue) should get white text
assertEquals(Color.White, contrastTextColor(Color.Blue))
}
@Test
fun brightYellowGetsBlackText() {
// Bright yellow like Agility Color(239, 229, 138) should get black text
val agility = Color(239 / 255f, 229 / 255f, 138 / 255f)
assertEquals(Color.Black, contrastTextColor(agility))
}
}