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.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))

View File

@@ -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))

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
}