diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt index 4d3dc35..c5829cd 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt @@ -1,5 +1,6 @@ package com.kairo.reader.ui.rsvp +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -9,7 +10,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performSemanticsAction @@ -21,6 +25,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.kairo.reader.TestActivity import com.kairo.reader.core.model.ReaderTheme import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpFontFamily import com.kairo.reader.core.model.RsvpProfile import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType @@ -30,8 +35,10 @@ import com.kairo.reader.core.rsvp.RsvpGenerationOptions import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import com.kairo.reader.ui.theme.KairoTheme import com.kairo.reader.ui.theme.colorContrast +import com.kairo.reader.ui.theme.composeFontFamily import com.kairo.reader.ui.theme.materialColorScheme import com.kairo.reader.ui.theme.rsvpAccentColors +import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -45,16 +52,19 @@ class RsvpDifficultyHighlightDeviceTest { val composeRule = createAndroidComposeRule() @Test - fun darkThemesRenderAContrastingPivotLetterWithoutAHighlightBlock() { + fun lightAndDarkThemesRenderAContrastingPivotLetterWithoutAHighlightBlock() { var theme by mutableStateOf(ReaderTheme.DARK) composeRule.setContent { KairoTheme(readerTheme = theme) { val scheme = MaterialTheme.colorScheme val accents = scheme.rsvpAccentColors() - Box(Modifier.size(FOCUS_STAGE_WIDTH_DP.dp, FOCUS_STAGE_HEIGHT_DP.dp).background(scheme.background)) { + Box( + Modifier.size(FOCUS_STAGE_WIDTH_DP.dp, FOCUS_STAGE_HEIGHT_DP.dp) + .background(scheme.background).testTag("rsvp-accent-stage"), + ) { OrpAlignedText( tokens = listOf(Token("reading", TokenType.WORD)), - typography = OrpTypography(FOCUS_FONT_SIZE_SP, FontFamily.Monospace, FontWeight.Normal), + typography = OrpTypography(FOCUS_FONT_SIZE_SP, RsvpFontFamily.MERRIWEATHER.composeFontFamily(), FontWeight.Normal), colors = OrpColors(scheme.onBackground, accents.pivot, scheme.onBackground, accents.wordPart), layout = OrpTextLayout( horizontalBias = FOCUS_HORIZONTAL_BIAS, @@ -71,7 +81,7 @@ class RsvpDifficultyHighlightDeviceTest { } } - for (currentTheme in listOf(ReaderTheme.DARK, ReaderTheme.INK)) { + for (currentTheme in ReaderTheme.entries.filter { it != ReaderTheme.CUSTOM }) { composeRule.runOnIdle { theme = currentTheme } val scheme = currentTheme.materialColorScheme() val accents = scheme.rsvpAccentColors() @@ -82,7 +92,8 @@ class RsvpDifficultyHighlightDeviceTest { assertEquals(Color.Unspecified, pivot.item.background) assertNull(pivot.item.fontWeight) assertNull(pivot.item.textDecoration) - assertTrue(colorContrast(pivot.item.color, scheme.background) >= WCAG_AA_TEXT_CONTRAST) + assertTrue(colorContrast(pivot.item.color, scheme.background) >= PIVOT_TEXT_CONTRAST) + captureAccent(currentTheme) } } @@ -211,6 +222,14 @@ class RsvpDifficultyHighlightDeviceTest { return layouts.single() } + private fun captureAccent(theme: ReaderTheme) { + val bitmap = composeRule.onNodeWithTag("rsvp-accent-stage").captureToImage().asAndroidBitmap() + val directory = requireNotNull(composeRule.activity.getExternalFilesDir("rsvp-accent-review")) + File(directory, "${theme.name.lowercase()}.png").outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + private fun highlightedText(layout: TextLayoutResult): String { val text = layout.layoutInput.text return text.spanStyles.filter { it.item.color == Color.Blue }.joinToString("") { text.text.substring(it.start, it.end) } @@ -223,6 +242,6 @@ class RsvpDifficultyHighlightDeviceTest { const val FOCUS_HORIZONTAL_BIAS = 0.5f const val FOCUS_GUIDE_THICKNESS = 1f const val PIVOT_CHARACTER_COUNT = 1 - const val WCAG_AA_TEXT_CONTRAST = 4.5f + const val PIVOT_TEXT_CONTRAST = 6f } } diff --git a/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt index 8fe4881..4073174 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt @@ -8,23 +8,22 @@ import kotlin.math.min internal data class RsvpAccentColors(val pivot: Color, val wordPart: Color) -/** Tertiary is usually the palette's hue-separated accent; reserve primary for word parts. */ +/** Keep the palette's tertiary hue, but give the single-letter focus a richer tone than UI accents. */ internal fun ColorScheme.rsvpAccentColors(): RsvpAccentColors { - val themedPivot = contrastingColor(tertiary, listOf(background)) - val pivot = if (colorChroma(background) >= MIN_BACKGROUND_CHROMA && - hueDistance(background, themedPivot) < MIN_PIVOT_HUE_DISTANCE + val pivotHue = if (colorChroma(background) >= MIN_BACKGROUND_CHROMA && + hueDistance(background, tertiary) < MIN_PIVOT_HUE_DISTANCE ) { - contrastingColor( - Color.hsl( - (colorHue(background) + HUE_HALF_TURN) % FULL_CIRCLE_DEGREES, - PIVOT_SATURATION, - accentLightness(background), - ), - listOf(background), - ) + (colorHue(background) + HUE_HALF_TURN) % FULL_CIRCLE_DEGREES } else { - themedPivot + colorHue(tertiary) } + // Pale dark-theme accents can meet background contrast yet disappear among near-white letters. + // A controlled saturation and tone separate the pivot from both the page and the reading text. + val pivot = contrastingColor( + Color.hsl(pivotHue, PIVOT_SATURATION, min(accentLightness(background), MAX_PIVOT_LIGHTNESS)), + listOf(background), + minimum = PIVOT_TEXT_CONTRAST, + ) val themedWordPart = contrastingColor(primary, listOf(background)) val wordPart = if (channelDistance(pivot, themedWordPart) >= MIN_ACCENT_DISTANCE) { themedWordPart @@ -83,8 +82,10 @@ private const val MIN_ACCENT_DISTANCE = 0.16f private const val MIN_BACKGROUND_CHROMA = 0.04f private const val MIN_PIVOT_HUE_DISTANCE = 45f private const val MIN_TEXT_CONTRAST = 4.5f +private const val PIVOT_TEXT_CONTRAST = 6f private const val WORD_PART_SATURATION = 0.9f -private const val PIVOT_SATURATION = 0.8f +private const val PIVOT_SATURATION = 0.78f +private const val MAX_PIVOT_LIGHTNESS = 0.68f private const val LIGHT_BACKGROUND_ACCENT_LIGHTNESS = 0.38f private const val DARK_BACKGROUND_ACCENT_LIGHTNESS = 0.72f private const val NEUTRAL_CHROMA_THRESHOLD = 0.02f diff --git a/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt index a72ad90..c80a704 100644 --- a/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt @@ -6,6 +6,7 @@ import com.kairo.reader.core.model.CustomTheme import com.kairo.reader.core.model.ReaderTheme import kotlin.math.abs import kotlin.math.max +import kotlin.math.min import kotlin.random.Random import org.junit.Assert.assertTrue import org.junit.Test @@ -14,7 +15,17 @@ class RsvpAccentColorsTest { @Test fun everyBuiltInThemeHasLegibleDistinctRsvpAccents() { ReaderTheme.entries.filter { it != ReaderTheme.CUSTOM }.forEach { theme -> - assertAccents(theme.materialColorScheme()) + val scheme = theme.materialColorScheme() + assertAccents(scheme) + val pivot = scheme.rsvpAccentColors().pivot + val chroma = max(pivot.red, max(pivot.green, pivot.blue)) - min(pivot.red, min(pivot.green, pivot.blue)) + val textDistance = max( + abs(pivot.red - scheme.onBackground.red), + max(abs(pivot.green - scheme.onBackground.green), abs(pivot.blue - scheme.onBackground.blue)), + ) + assertTrue("$theme pivot should remain vivid among reading text: $pivot", chroma >= MIN_PIVOT_CHROMA) + assertTrue("$theme pivot blends into reading text: $pivot", textDistance >= MIN_PIVOT_TEXT_DISTANCE) + assertTrue("$theme pivot needs strong page contrast: $pivot", colorContrast(pivot, scheme.background) >= MIN_PIVOT_CONTRAST) } } @@ -61,5 +72,8 @@ class RsvpAccentColorsTest { const val WCAG_AA_TEXT_CONTRAST = 4.5f const val CONTRAST_TOLERANCE = 0.0001f const val MIN_VISIBLE_CHANNEL_DELTA = 0.05f + const val MIN_PIVOT_CHROMA = 0.35f + const val MIN_PIVOT_TEXT_DISTANCE = 0.3f + const val MIN_PIVOT_CONTRAST = 6f } }