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))
+ }
+}