FaviconInitials.kt

2.9 KB · raw

1package com.peachpasswords.ui.components23import androidx.compose.foundation.layout.Box4import androidx.compose.foundation.layout.size5import androidx.compose.foundation.shape.RoundedCornerShape6import androidx.compose.material3.Text7import androidx.compose.runtime.Composable8import androidx.compose.ui.Alignment9import androidx.compose.ui.Modifier10import androidx.compose.ui.draw.clip11import androidx.compose.ui.graphics.Color12import androidx.compose.ui.draw.drawBehind13import androidx.compose.ui.text.font.FontWeight14import androidx.compose.ui.unit.Dp15import androidx.compose.ui.unit.dp16import androidx.compose.ui.unit.em17import androidx.compose.ui.unit.sp18import com.peachpasswords.ui.theme.peachColors19import com.peachpasswords.ui.theme.PeachType2021/**22 * Initials chip used in place of a network favicon (offline-first). Ports the23 * browser titleInitials + titleBackground (deterministic HSL 32%/26%). The24 * corner radius tracks the chip size (≈6dp at 26dp, ≈3dp at 16dp) per spec §4.6.25 */26@Composable27fun FaviconInitials(28    source: String,29    modifier: Modifier = Modifier,30    size: Dp = 26.dp,31    cornerRadius: Dp = (size.value * 0.23f).dp,32) {33    val bg = titleBackground(source)34    Box(35        modifier = modifier36            .size(size)37            .clip(RoundedCornerShape(cornerRadius))38            .drawBehind { drawRect(bg) },39        contentAlignment = Alignment.Center,40    ) {41        Text(42            text = titleInitials(source),43            color = peachColors.T0.copy(alpha = 0.92f),44            fontFamily = PeachType.Mono,45            fontWeight = FontWeight.SemiBold,46            letterSpacing = 0.04.em,47            fontSize = (size.value * 0.34f).sp,48        )49    }50}5152fun titleInitials(source: String): String {53    val cleaned = source.replace(Regex("^https?://"), "").removePrefix("www.").trim()54    if (cleaned.isEmpty()) return "·"55    val words = cleaned.split(Regex("[\\s.\\-_/]+")).filter { it.isNotEmpty() }56    if (words.size >= 2 && words[0].isNotEmpty() && words[1].isNotEmpty()) {57        return (words[0][0].toString() + words[1][0].toString()).uppercase()58    }59    val single = words.firstOrNull() ?: cleaned60    return single.take(2).uppercase()61}6263/** Deterministic muted HSL(hue, 32%, 26%) background, matching the browser. */64fun titleBackground(source: String): Color {65    var hash = 066    for (ch in source) hash = ((hash shl 5) - hash + ch.code)67    val hue = (((hash % 360) + 360) % 360).toFloat()68    return hsl(hue, 0.32f, 0.26f)69}7071private fun hsl(h: Float, s: Float, l: Float): Color {72    val c = (1f - kotlin.math.abs(2 * l - 1f)) * s73    val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f))74    val m = l - c / 2f75    val (r, g, b) = when {76        h < 60 -> Triple(c, x, 0f)77        h < 120 -> Triple(x, c, 0f)78        h < 180 -> Triple(0f, c, x)79        h < 240 -> Triple(0f, x, c)80        h < 300 -> Triple(x, 0f, c)81        else -> Triple(c, 0f, x)82    }83    return Color(r + m, g + m, b + m)84}