From e3a40b20a61db4e005aeaf88bdf2a7ff54c0adb2 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 14:07:46 +0100 Subject: [PATCH] feat: replace theme-based text color with WCAG luminance contrast (Closes #7) Add contrastTextColor() utility that calculates relative luminance per WCAG 2.1 and picks black or white text to guarantee >= 4.5:1 contrast. Attribute and Talent cards now derive text color from the background color itself instead of checking dark/light theme, fixing mid-luminance colors that were previously unreadable. Co-Authored-By: Claude Opus 4.6 --- .../attributespage/Attribute.kt | 8 +- .../charactermodel/attributespage/Talent.kt | 8 +- .../org/shahondin1624/theme/ContrastUtils.kt | 54 +++++++++++ .../org/shahondin1624/ContrastUtilsTest.kt | 90 +++++++++++++++++++ 4 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ContrastUtils.kt create mode 100644 sharedUI/src/commonTest/kotlin/org/shahondin1624/ContrastUtilsTest.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt index c28acc9..e4a26c1 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Attribute.kt @@ -10,12 +10,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag 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.functions.DiceRoll 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.dice @@ -38,8 +35,7 @@ fun Attribute( }, onEdit: (() -> Unit)? = null, ) { - var isInDarkMode by LocalThemeIsDark.current - val textColor = if (isInDarkMode) Color.Black else Color.White + val textColor = contrastTextColor(attribute.type.color) Card( modifier = Modifier .testTag(TestTags.attributeCard(attribute.type.name)) diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt index 7a9cc1e..512d961 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/attributespage/Talent.kt @@ -15,12 +15,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag 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.model.attributes.Attributes 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.dice @@ -45,8 +42,7 @@ fun Talent( }, onEdit: (() -> Unit)? = null, ) { - var isInDarkMode by LocalThemeIsDark.current - val textColor = if (isInDarkMode) Color.Black else Color.White + val textColor = contrastTextColor(talent.attribute.color) Card( modifier = Modifier .testTag(TestTags.talentCard(talent.name)) diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ContrastUtils.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ContrastUtils.kt new file mode 100644 index 0000000..5f1c590 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ContrastUtils.kt @@ -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 WCAG 2.1 Relative Luminance + */ +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 WCAG 2.1 Contrast Ratio + */ +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 +} diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/ContrastUtilsTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/ContrastUtilsTest.kt new file mode 100644 index 0000000..213407f --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/ContrastUtilsTest.kt @@ -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)) + } +}