This commit was merged in pull request #69.
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user