ui: mute theme-aware peer colors for contrast

Extract PeerColorStyle so each palette owns saturation/value, keeping
hues stable while dark mode stays bright-but-muted and light mode avoids
neon labels. New themes only need to supply their own style.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
callebtc 2026-07-27 22:06:46 +02:00
parent c39bb8124c
commit 719b3d1895
3 changed files with 51 additions and 17 deletions

View File

@ -41,10 +41,11 @@ data class BitchatPalette(
val accentPurple: Color,
// MARK: - Deterministic peer colors
/** Chroma applied after deriving a peer's stable hue. */
val peerColorSaturation: Float,
/** Brightness applied after deriving a peer's stable hue. */
val peerColorValue: Float,
/**
* Saturation/value applied after deriving a peer's stable hue. Swap this when adding a
* new theme see [PeerColorStyle] for contrast guidelines.
*/
val peerColors: PeerColorStyle,
)
val DarkBitchatPalette = BitchatPalette(
@ -56,8 +57,7 @@ val DarkBitchatPalette = BitchatPalette(
textTertiary = Color(0xFF6B776B),
accentOrange = Color(0xFFFF9F0A),
accentPurple = Color(0xFFBF5AF2),
peerColorSaturation = 1f,
peerColorValue = 1f,
peerColors = PeerColorStyle.Dark,
)
val LightBitchatPalette = BitchatPalette(
@ -69,8 +69,7 @@ val LightBitchatPalette = BitchatPalette(
textTertiary = Color(0xFF757F75),
accentOrange = Color(0xFFFF9500),
accentPurple = Color(0xFFAF52DE),
peerColorSaturation = 0.85f,
peerColorValue = 0.45f,
peerColors = PeerColorStyle.Light,
)
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }

View File

@ -1,9 +1,36 @@
package com.bitchat.android.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.bitchat.android.ui.PeerIdentity
import kotlin.math.abs
/**
* Theme-specific chroma applied after a peer's stable hue is derived.
*
* Hue stays identity-stable across themes (and byte-identical to iOS). Only saturation
* and value change so peer labels remain readable on each background.
*
* Guidelines when adding a future theme:
* - Dim / dark backgrounds: keep [value] high so colors are not lost against the surface;
* prefer muted [saturation] over neon.
* - Light backgrounds: keep [value] moderate-low so colors are not blinding; avoid
* near-full saturation.
*/
@Immutable
data class PeerColorStyle(
val saturation: Float,
val value: Float,
) {
companion object {
/** Soft pastels that stay bright enough on near-black chat surfaces. */
val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f)
/** Deeper, less saturated tones that stay readable on near-white surfaces. */
val Light = PeerColorStyle(saturation = 0.70f, value = 0.42f)
}
}
/**
* The single identity-to-color boundary used by chat, people sheets, and mentions.
*
@ -22,9 +49,10 @@ fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
hue = (hue + 0.12) % 1.0
}
val style = palette.peerColors
return Color.hsv(
hue = (hue * 360).toFloat(),
saturation = palette.peerColorSaturation,
value = palette.peerColorValue
saturation = style.saturation,
value = style.value
)
}

View File

@ -14,6 +14,7 @@ import com.bitchat.android.ui.theme.LightBitchatColorScheme
import com.bitchat.android.ui.theme.LightBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.PeerColorStyle
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.Date
@ -444,8 +445,8 @@ class ChatUIUtilsTest {
@Test
fun `peer color hue is stable across light and dark, only chroma differs`() {
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for
// the redesigned neutral message body.
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per
// theme so dark mode stays muted-but-bright and light mode stays deep-but-readable.
val identity = PeerIdentity.mesh("abc")
val dark = colorForPeer(identity, DarkBitchatPalette)
val light = colorForPeer(identity, LightBitchatPalette)
@ -456,10 +457,16 @@ class ChatUIUtilsTest {
rgbToHsv(light.red, light.green, light.blue, lightHsv)
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
assertEquals(1.0, darkHsv[1].toDouble(), 0.01)
assertEquals(1.0, darkHsv[2].toDouble(), 0.01)
assertEquals(0.85, lightHsv[1].toDouble(), 0.01)
assertEquals(0.45, lightHsv[2].toDouble(), 0.01)
assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01)
assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01)
assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01)
assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01)
// Dark theme: muted chroma, never dark (readable on near-black).
assertTrue(darkHsv[1] < 0.75f)
assertTrue(darkHsv[2] >= 0.75f)
// Light theme: avoid neon / near-white peer labels.
assertTrue(lightHsv[1] < 0.85f)
assertTrue(lightHsv[2] <= 0.55f)
}
@Test
@ -485,7 +492,7 @@ class ChatUIUtilsTest {
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
assertTrue(
LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue
LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
)
}