design polish: bug fixes, token migration, a11y touch targets
- GlassSurface: derive shadow geometry from shape (pill/circle now curve correctly)
new shape-aware kaizenShadow(stack, shape) overload in Shadow.kt
drop cornerRadius parameter from GlassSurface and all 8 call sites
- EmptyHero: replace remember { LocalDateTime.now() } with produceState that
re-emits on minute boundaries; clock no longer freezes on first composition
- ChatScreen + MeshBackground: skip blob layer + dither while LiveCallOverlay
is up (renderBlobs flag); avoids two animated MeshBackgrounds compositing
in parallel
- Color tokens: add KaizenSemanticColors.warning/warningDeep,
KaizenNeutralWashes (idle/hover/selected), KaizenGlassTint
(darkTop/darkBottom/lightTop/lightBottom)
- Migrate inline Color(0xFF...) literals to tokens in LiveCallOverlay,
ChatScreen (version-skew banner), Sidebar (4x neutral washes),
LoginScreen / PasswordUnlockDialog / SecuritySection (glass tints),
GlassSurface (use KaizenGlassTint)
- New ui/theme/Spacing.kt: KaizenSpacing scale (xxs-xxl),
KaizenTouch (minTouch/comfortable/hero), KaizenIconSize (xs-xl)
- Accessibility touch targets:
Sidebar Settings/Logout 30 -> 44dp
Sidebar New-Chat 34 -> 44dp
Sidebar history kebab 28 -> 44dp
ChatInput Plus/Tune/Mic/Send/Call/Stop 40 -> 48dp
- Remove no-op pointerInput(Unit) {} on KaizenOrb
- Expose SquircleShape.radius (val instead of private) so shadow can read it
This commit is contained in:
parent
3b32b208b6
commit
1b4cc475f9
15 changed files with 245 additions and 69 deletions
|
|
@ -50,6 +50,7 @@ import dev.kaizen.app.net.LoginResult
|
|||
import dev.kaizen.app.net.SessionViewModel
|
||||
import dev.kaizen.app.ui.effect.KaizenShadows
|
||||
import dev.kaizen.app.ui.effect.kaizenShadow
|
||||
import dev.kaizen.app.ui.theme.KaizenGlassTint
|
||||
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -169,9 +170,9 @@ private fun GlassField(
|
|||
val accent = LocalKaizenAccent.current
|
||||
|
||||
val glassBg = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.72f), Color(0xFF0F172A).copy(alpha = 0.85f)))
|
||||
Brush.verticalGradient(listOf(KaizenGlassTint.darkTop.copy(alpha = 0.72f), KaizenGlassTint.darkBottom.copy(alpha = 0.85f)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.90f), Color(0xFFEEF2F6).copy(alpha = 0.80f)))
|
||||
Brush.verticalGradient(listOf(KaizenGlassTint.lightTop.copy(alpha = 0.90f), KaizenGlassTint.lightBottom.copy(alpha = 0.80f)))
|
||||
}
|
||||
val glassBorder = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.04f)))
|
||||
|
|
@ -183,7 +184,7 @@ private fun GlassField(
|
|||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 420.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, shape = KaizenShapes.lg)
|
||||
.clip(KaizenShapes.lg)
|
||||
.background(glassBg)
|
||||
.border(1.2.dp, glassBorder, KaizenShapes.lg)
|
||||
|
|
@ -222,7 +223,7 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit
|
|||
.fillMaxWidth()
|
||||
.widthIn(max = 420.dp)
|
||||
.heightIn(min = 54.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level2, cornerRadius = 20.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level2, shape = KaizenShapes.lg)
|
||||
.clip(KaizenShapes.lg)
|
||||
.then(fill)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import dev.kaizen.app.R
|
|||
import dev.kaizen.app.ui.effect.GlassSurface
|
||||
import dev.kaizen.app.ui.effect.GlassTiers
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.KaizenGlassTint
|
||||
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
|
||||
|
|
@ -130,9 +131,9 @@ private fun PasswordField(
|
|||
val accent = LocalKaizenAccent.current
|
||||
|
||||
val glassBg = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.72f), Color(0xFF0F172A).copy(alpha = 0.85f)))
|
||||
Brush.verticalGradient(listOf(KaizenGlassTint.darkTop.copy(alpha = 0.72f), KaizenGlassTint.darkBottom.copy(alpha = 0.85f)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.90f), Color(0xFFEEF2F6).copy(alpha = 0.80f)))
|
||||
Brush.verticalGradient(listOf(KaizenGlassTint.lightTop.copy(alpha = 0.90f), KaizenGlassTint.lightBottom.copy(alpha = 0.80f)))
|
||||
}
|
||||
val glassBorder = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.04f)))
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import androidx.compose.ui.graphics.BlendMode
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.kaizen.app.ui.motion.Durations
|
||||
|
|
@ -39,15 +38,25 @@ import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
|||
import dev.kaizen.app.ui.theme.DITHER_ALPHA
|
||||
import dev.kaizen.app.ui.theme.rememberDitherBrush
|
||||
|
||||
/** Mesh/blob background: large, heavily blurred color circles that drift slowly. */
|
||||
/** Mesh/blob background: large, heavily blurred color circles that drift slowly.
|
||||
*
|
||||
* @param animateBlobs whether the blobs should drift; set false to freeze them.
|
||||
* @param renderBlobs whether to actually paint the blob layer. Set false when a
|
||||
* fullscreen sibling (e.g. [dev.kaizen.app.live.LiveCallOverlay]) already paints
|
||||
* its own MeshBackground — avoids two animated blob stacks compositing at once.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshBackground(animateBlobs: Boolean = true, content: @Composable BoxScope.() -> Unit) {
|
||||
fun MeshBackground(
|
||||
animateBlobs: Boolean = true,
|
||||
renderBlobs: Boolean = true,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val accent = LocalKaizenAccent.current
|
||||
val base = MaterialTheme.colorScheme.background
|
||||
val factor = if (dark) 1f else 0.78f
|
||||
|
||||
val drift = if (animateBlobs) {
|
||||
val drift = if (animateBlobs && renderBlobs) {
|
||||
val transition = rememberInfiniteTransition(label = "blobs")
|
||||
val d by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
|
|
@ -70,6 +79,7 @@ fun MeshBackground(animateBlobs: Boolean = true, content: @Composable BoxScope.(
|
|||
drawRect(brush = ditherBrush, alpha = DITHER_ALPHA)
|
||||
},
|
||||
) {
|
||||
if (renderBlobs) {
|
||||
Blob(accent.blob1, 440.dp, (-80f + drift * 40f).dp, (-60f + drift * 30f).dp, 0.48f * factor)
|
||||
Blob(accent.blob2, 460.dp, (140f - drift * 50f).dp, (200f + drift * 50f).dp, 0.40f * factor)
|
||||
Blob(accent.blob3, 400.dp, (-30f + drift * 60f).dp, (450f - drift * 40f).dp, 0.36f * factor)
|
||||
|
|
@ -81,6 +91,7 @@ fun MeshBackground(animateBlobs: Boolean = true, content: @Composable BoxScope.(
|
|||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
@ -138,8 +149,7 @@ fun KaizenOrb(
|
|||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.scale(scale * reactiveScale)
|
||||
.pointerInput(Unit) {},
|
||||
.scale(scale * reactiveScale),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// --- LAYER 1: Ambient Outer Halo ---
|
||||
|
|
|
|||
|
|
@ -79,13 +79,14 @@ import java.util.Locale
|
|||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dev.kaizen.app.haptics.rememberHaptics
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -104,7 +105,6 @@ fun KaizenHeader(modifier: Modifier = Modifier) {
|
|||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
shape = KaizenShapes.lg,
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 24.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
|
|
@ -121,8 +121,19 @@ fun KaizenHeader(modifier: Modifier = Modifier) {
|
|||
@Composable
|
||||
fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val now = remember { LocalDateTime.now() }
|
||||
val dateLine = remember {
|
||||
|
||||
// Live clock: re-emits LocalDateTime aligned to the next minute boundary,
|
||||
// so the greeting + "HH:mm" date line never go stale while the user
|
||||
// keeps the empty state open.
|
||||
val now by produceState(initialValue = LocalDateTime.now()) {
|
||||
while (true) {
|
||||
val current = LocalDateTime.now()
|
||||
value = current
|
||||
val msToNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L)
|
||||
delay(msToNextMinute)
|
||||
}
|
||||
}
|
||||
val dateLine = remember(now.minute, now.hour, now.dayOfYear) {
|
||||
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.getDefault()))
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +435,6 @@ fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
|
|||
shape = KaizenShapes.pill,
|
||||
tintAlpha = GlassTiers.pill(isDark),
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 16.dp,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
|
|
@ -543,7 +553,7 @@ fun MessageRow(
|
|||
Box(
|
||||
Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 20.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, shape = shape)
|
||||
.clip(shape)
|
||||
.background(bgBrush)
|
||||
.background(innerHighlight)
|
||||
|
|
@ -814,7 +824,6 @@ fun ChatInput(
|
|||
shape = KaizenShapes.pill,
|
||||
tintAlpha = if (isDark) 0.92f else 0.97f,
|
||||
shadowStack = KaizenShadows.level4,
|
||||
cornerRadius = 34.dp,
|
||||
) {
|
||||
Column(
|
||||
Modifier.heightIn(min = 64.dp),
|
||||
|
|
@ -874,7 +883,7 @@ fun ChatInput(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick),
|
||||
Modifier.size(48.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
|
|
@ -900,7 +909,7 @@ fun ChatInput(
|
|||
|
||||
if (pillsContent != null) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(KaizenShapes.circle).clickable { showPills = !showPills },
|
||||
Modifier.size(48.dp).clip(KaizenShapes.circle).clickable { showPills = !showPills },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
|
|
@ -912,7 +921,7 @@ fun ChatInput(
|
|||
}
|
||||
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onMicClick),
|
||||
Modifier.size(48.dp).clip(KaizenShapes.circle).clickable(onClick = onMicClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(KaizenIcons.Mic, stringResource(R.string.chat_mic_permission), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
|
|
@ -996,7 +1005,7 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
|||
}
|
||||
|
||||
Box(
|
||||
Modifier.size(40.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill)
|
||||
Modifier.size(48.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill)
|
||||
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
|
@ -1015,7 +1024,7 @@ private fun CallButton(onClick: () -> Unit) {
|
|||
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.size(48.dp)
|
||||
.scale(pressScale)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(
|
||||
|
|
@ -1042,7 +1051,7 @@ private fun StopButton(onClick: () -> Unit) {
|
|||
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.size(48.dp)
|
||||
.scale(pressScale)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(KaizenSemanticColors.error.copy(alpha = if (isDark) 0.85f else 0.80f))
|
||||
|
|
@ -1181,7 +1190,6 @@ private fun AttachPill(icon: ImageVector, label: String, tint: Color, isDark: Bo
|
|||
shape = KaizenShapes.pill,
|
||||
tintAlpha = GlassTiers.pill(isDark),
|
||||
shadowStack = KaizenShadows.level2,
|
||||
cornerRadius = 24.dp,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -225,7 +225,13 @@ fun ChatScreen(
|
|||
},
|
||||
gesturesEnabled = true,
|
||||
) {
|
||||
MeshBackground(animateBlobs = vm.messages.isEmpty()) {
|
||||
// Hide the underlying MeshBackground while the LiveCallOverlay is up —
|
||||
// the overlay renders its own fullscreen MeshBackground, so painting two
|
||||
// stacks of animated blobs simultaneously is pure GPU/CPU waste.
|
||||
MeshBackground(
|
||||
animateBlobs = vm.messages.isEmpty() && !vm.liveCallActive,
|
||||
renderBlobs = !vm.liveCallActive,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (vm.messages.isEmpty()) {
|
||||
EmptyHero(
|
||||
|
|
@ -302,7 +308,6 @@ fun ChatScreen(
|
|||
shape = KaizenShapes.circle,
|
||||
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 18.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp)
|
||||
|
|
@ -353,7 +358,6 @@ fun ChatScreen(
|
|||
shape = KaizenShapes.pill,
|
||||
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
||||
shadowStack = KaizenShadows.level1,
|
||||
cornerRadius = 20.dp,
|
||||
) {
|
||||
Row(Modifier.heightIn(min = 38.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
|
|
@ -414,7 +418,7 @@ fun ChatScreen(
|
|||
// Version skew banner
|
||||
val skew = vm.session.versionSkew
|
||||
if (skew != null) {
|
||||
val warningColor = Color(0xFFD97706)
|
||||
val warningColor = KaizenSemanticColors.warning
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import dev.kaizen.app.ui.effect.kaizenShadow
|
|||
import dev.kaizen.app.ui.effect.innerTopHighlight
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.R
|
||||
import dev.kaizen.app.ui.theme.KaizenNeutralWashes
|
||||
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -126,7 +127,6 @@ fun KaizenSidebar(
|
|||
shape = KaizenShapes.lg,
|
||||
tintAlpha = if (isDark) 0.80f else 0.86f,
|
||||
shadowStack = KaizenShadows.level3,
|
||||
cornerRadius = 24.dp,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
|
@ -147,9 +147,9 @@ fun KaizenSidebar(
|
|||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.size(44.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
|
||||
.background(KaizenNeutralWashes.hover(isDark))
|
||||
.clickable { onNewChat() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
|
@ -172,7 +172,7 @@ fun KaizenSidebar(
|
|||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.pill)
|
||||
.border(0.8.dp, searchBorder, KaizenShapes.pill)
|
||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
|
||||
.background(KaizenNeutralWashes.hover(isDark))
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
@ -314,7 +314,7 @@ fun KaizenSidebar(
|
|||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.size(44.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.clickable { onOpenSettings() },
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -329,7 +329,7 @@ fun KaizenSidebar(
|
|||
Spacer(Modifier.width(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.size(44.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.clickable { onLogout() },
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -394,7 +394,7 @@ fun KaizenSidebar(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
|
||||
.background(KaizenNeutralWashes.hover(isDark))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
},
|
||||
|
|
@ -458,12 +458,12 @@ private fun HistoryItemRow(
|
|||
.then(
|
||||
if (selected) {
|
||||
Modifier
|
||||
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 16.dp)
|
||||
.kaizenShadow(stack = KaizenShadows.level1, shape = KaizenShapes.md)
|
||||
.background(accent.primary.copy(alpha = if (isDark) 0.12f else 0.08f))
|
||||
.border(0.8.dp, activeBorder, KaizenShapes.md)
|
||||
} else {
|
||||
Modifier
|
||||
.background(if (isDark) Color(0x08FFFFFF) else Color(0x05000000))
|
||||
.background(KaizenNeutralWashes.idle(isDark))
|
||||
.border(0.5.dp, inactiveBorder, KaizenShapes.md)
|
||||
}
|
||||
)
|
||||
|
|
@ -491,7 +491,7 @@ private fun HistoryItemRow(
|
|||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.size(44.dp) // accessibility tap target
|
||||
.clip(KaizenShapes.circle)
|
||||
.clickable { showMenu = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import dev.kaizen.app.ui.effect.GlassSurface
|
|||
import dev.kaizen.app.ui.effect.KaizenShadows
|
||||
import dev.kaizen.app.ui.icon.KaizenIcons
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
|
||||
@Composable
|
||||
|
|
@ -162,8 +163,8 @@ private fun EndCallButton(onClick: () -> Unit) {
|
|||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFDC2626),
|
||||
KaizenSemanticColors.error,
|
||||
KaizenSemanticColors.errorDeep,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import dev.kaizen.app.R
|
|||
import dev.kaizen.app.net.AppDevice
|
||||
import dev.kaizen.app.net.ServerConfig
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.KaizenGlassTint
|
||||
import dev.kaizen.app.ui.theme.KaizenSemanticColors
|
||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||
import dev.kaizen.app.ui.icon.KaizenIcons
|
||||
|
|
@ -319,9 +320,9 @@ private fun PasswordField(
|
|||
val accent = LocalKaizenAccent.current
|
||||
|
||||
val bg = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.60f), Color(0xFF0F172A).copy(alpha = 0.72f)))
|
||||
Brush.verticalGradient(listOf(KaizenGlassTint.darkTop.copy(alpha = 0.60f), KaizenGlassTint.darkBottom.copy(alpha = 0.72f)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFEEF2F6).copy(alpha = 0.75f)))
|
||||
Brush.verticalGradient(listOf(KaizenGlassTint.lightTop.copy(alpha = 0.85f), KaizenGlassTint.lightBottom.copy(alpha = 0.75f)))
|
||||
}
|
||||
val border = if (isDark) {
|
||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.03f)))
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ fun SettingsCard(
|
|||
shape = KaizenShapes.lg,
|
||||
tintAlpha = GlassTiers.card(isDark),
|
||||
shadowStack = KaizenShadows.level2,
|
||||
cornerRadius = 24.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
content()
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ fun SettingsScreen(
|
|||
shape = KaizenShapes.circle,
|
||||
tintAlpha = GlassTiers.pill(isDark),
|
||||
shadowStack = KaizenShadows.level2,
|
||||
cornerRadius = 22.dp,
|
||||
modifier = Modifier.size(44.dp).clickable { onBack() },
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||
import dev.kaizen.app.ui.theme.KaizenGlassTint
|
||||
|
||||
/**
|
||||
* A frosted-glass surface — the core visual primitive of the Kaizen design system.
|
||||
|
|
@ -32,6 +32,9 @@ import dev.kaizen.app.ui.shape.KaizenShapes
|
|||
* The blob layer behind (MeshBackground) is what "shows through" the
|
||||
* transparency, creating the frosted-glass illusion.
|
||||
*
|
||||
* The shadow geometry is derived from [shape], so pill/circle surfaces get
|
||||
* correctly curved shadows instead of a rectangular fallback.
|
||||
*
|
||||
* See DESIGN.md §4 for the full anatomy and opacity tiers.
|
||||
*/
|
||||
@Composable
|
||||
|
|
@ -40,7 +43,6 @@ fun GlassSurface(
|
|||
shape: Shape = KaizenShapes.md,
|
||||
tintAlpha: Float = 0.72f,
|
||||
shadowStack: ShadowStack = KaizenShadows.level2,
|
||||
cornerRadius: Dp = 16.dp,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
|
@ -50,15 +52,15 @@ fun GlassSurface(
|
|||
if (isDark) {
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF1E293B).copy(alpha = tintAlpha),
|
||||
Color(0xFF0F172A).copy(alpha = (tintAlpha + 0.10f).coerceAtMost(1f)),
|
||||
KaizenGlassTint.darkTop.copy(alpha = tintAlpha),
|
||||
KaizenGlassTint.darkBottom.copy(alpha = (tintAlpha + 0.10f).coerceAtMost(1f)),
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.White.copy(alpha = tintAlpha),
|
||||
Color(0xFFF1F5F9).copy(alpha = (tintAlpha - 0.05f).coerceAtLeast(0f)),
|
||||
KaizenGlassTint.lightTop.copy(alpha = tintAlpha),
|
||||
KaizenGlassTint.lightBottom.copy(alpha = (tintAlpha - 0.05f).coerceAtLeast(0f)),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -78,7 +80,7 @@ fun GlassSurface(
|
|||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.kaizenShadow(stack = shadowStack, cornerRadius = cornerRadius)
|
||||
.kaizenShadow(stack = shadowStack, shape = shape)
|
||||
.clip(shape)
|
||||
.background(glassBg)
|
||||
.innerTopHighlight()
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
package dev.kaizen.app.ui.effect
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.kaizen.app.ui.shape.SquircleShape
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* A single layer in a multi-layer shadow stack.
|
||||
|
|
@ -80,6 +84,10 @@ object KaizenShadows {
|
|||
/**
|
||||
* Draws a multi-layer shadow stack behind the content.
|
||||
*
|
||||
* Prefer the [Shape]-based overload below — it derives the shadow's corner
|
||||
* radius from the surface shape automatically, preventing the geometric
|
||||
* mismatch where a pill/circle surface gets a rectangular shadow.
|
||||
*
|
||||
* [darkMultiplier] scales shadow alpha in dark mode (shadows need to be
|
||||
* stronger on dark surfaces to read). Default 1.8× matches the DESIGN.md spec.
|
||||
*/
|
||||
|
|
@ -97,12 +105,63 @@ fun Modifier.kaizenShadow(
|
|||
}
|
||||
}
|
||||
return this.drawBehind {
|
||||
drawShadowLayers(layers, cornerRadius)
|
||||
drawShadowLayers(layers) { _ -> cornerRadius.toPx() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawShadowLayers(layers: List<ShadowLayer>, cornerRadius: Dp) {
|
||||
val cr = cornerRadius.toPx()
|
||||
/**
|
||||
* Shape-aware shadow stack — derives the corner radius from [shape] so the
|
||||
* shadow geometry always matches the surface (especially important for
|
||||
* [SquircleShape] pill/circle variants where the radius is `min(w,h)/2`).
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.kaizenShadow(
|
||||
stack: ShadowStack,
|
||||
shape: Shape,
|
||||
darkMultiplier: Float = 1.8f,
|
||||
): Modifier {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val effectiveMultiplier = if (isDark) darkMultiplier else 1f
|
||||
val layers = remember(stack, effectiveMultiplier) {
|
||||
stack.layers.map { layer ->
|
||||
layer.copy(alpha = (layer.alpha * effectiveMultiplier).coerceAtMost(1f))
|
||||
}
|
||||
}
|
||||
return this.drawBehind {
|
||||
drawShadowLayers(layers) { sz -> resolveCornerRadiusPx(shape, sz) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective corner radius (in pixels) for a known [Shape] at a
|
||||
* given [size]. Falls back to 16 dp for unknown shapes.
|
||||
*/
|
||||
private fun DrawScope.resolveCornerRadiusPx(shape: Shape, size: Size): Float {
|
||||
return when (shape) {
|
||||
is SquircleShape -> {
|
||||
// Squircle clamps radius to min(w,h)/2 — mirror that here so pill/circle
|
||||
// shadows curve correctly instead of drawing a rounded rect with r=999.
|
||||
val r = shape.radius.toPx()
|
||||
r.coerceAtMost(min(size.width, size.height) / 2f)
|
||||
}
|
||||
CircleShape -> min(size.width, size.height) / 2f
|
||||
is RoundedCornerShape -> {
|
||||
val outline = shape.createOutline(size, androidx.compose.ui.unit.LayoutDirection.Ltr, this)
|
||||
when (outline) {
|
||||
is androidx.compose.ui.graphics.Outline.Rounded ->
|
||||
outline.roundRect.topLeftCornerRadius.x
|
||||
else -> 16.dp.toPx()
|
||||
}
|
||||
}
|
||||
else -> 16.dp.toPx()
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawShadowLayers(
|
||||
layers: List<ShadowLayer>,
|
||||
radiusForSize: (Size) -> Float,
|
||||
) {
|
||||
val cr = radiusForSize(size)
|
||||
drawIntoCanvas { canvas ->
|
||||
layers.forEach { layer ->
|
||||
val paint = Paint().apply {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ import kotlin.math.sin
|
|||
* screen density; reduce to 16 for small shapes (< 32 dp).
|
||||
*/
|
||||
class SquircleShape(
|
||||
private val radius: Dp,
|
||||
private val smoothness: Float = 4.5f,
|
||||
val radius: Dp,
|
||||
val smoothness: Float = 4.5f,
|
||||
private val samplesPerCorner: Int = 32,
|
||||
) : androidx.compose.ui.graphics.Shape {
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ val BlobTeal = p3(0.461f, 0.885f, 0.908f) // oklch(0.85 0.10 200) teal accent
|
|||
object KaizenSemanticColors {
|
||||
val error = Color(0xFFEF4444) // destructive actions, stop, delete
|
||||
val errorDeep = Color(0xFFDC2626) // error banners, stronger emphasis
|
||||
val warning = Color(0xFFD97706) // outdated client / non-fatal warnings
|
||||
val warningDeep = Color(0xFFB45309) // warning banners, stronger emphasis
|
||||
val success = Color(0xFF10B981) // success confirmation, check marks
|
||||
val successBright = Color(0xFF22C55E) // copy-confirmed flash
|
||||
val indigo = Color(0xFF6366F1) // Vertex AI badge, gallery accent
|
||||
|
|
@ -61,3 +63,33 @@ object KaizenSemanticColors {
|
|||
val dropdownDark = Color(0xFF1A2030) // dropdown/menu bg (dark)
|
||||
val dropdownLight = Color(0xFFF8F9FB) // dropdown/menu bg (light)
|
||||
}
|
||||
|
||||
// ── Neutral surface washes ────────────────────────────────────────────────
|
||||
// Tinted-white / tinted-black overlays used for hover/idle/pressed states
|
||||
// on neutral surfaces (sidebar rows, menu items, sub-buttons). Encapsulates
|
||||
// the alpha values that were previously littered inline as `Color(0x1AFFFFFF)`.
|
||||
object KaizenNeutralWashes {
|
||||
/** Lightest idle / resting tint. Dark vs light themed. */
|
||||
fun idle(isDark: Boolean): Color =
|
||||
if (isDark) Color(0x08FFFFFF) else Color(0x05000000)
|
||||
|
||||
/** Subtle hover/pressed tint, brighter than idle. */
|
||||
fun hover(isDark: Boolean): Color =
|
||||
if (isDark) Color(0x12FFFFFF) else Color(0x0A000000)
|
||||
|
||||
/** Most prominent neutral wash — selected rows, focus rings. */
|
||||
fun selected(isDark: Boolean): Color =
|
||||
if (isDark) Color(0x1AFFFFFF) else Color(0x14000000)
|
||||
}
|
||||
|
||||
// ── Glass-surface gradient tints ──────────────────────────────────────────
|
||||
// Two-stop vertical gradient stops for the frosted-glass body, owned here
|
||||
// so the GlassSurface implementation does not embed brand hex literals.
|
||||
object KaizenGlassTint {
|
||||
/** Dark-mode glass body: deep slate top → near-black bottom. */
|
||||
val darkTop = Color(0xFF1E293B)
|
||||
val darkBottom = Color(0xFF0F172A)
|
||||
/** Light-mode glass body: pure white top → cool grey bottom. */
|
||||
val lightTop = Color(0xFFFFFFFF)
|
||||
val lightBottom = Color(0xFFF1F5F9)
|
||||
}
|
||||
|
|
|
|||
59
app/src/main/java/dev/kaizen/app/ui/theme/Spacing.kt
Normal file
59
app/src/main/java/dev/kaizen/app/ui/theme/Spacing.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package dev.kaizen.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Kaizen spacing scale — 4-dp baseline grid.
|
||||
*
|
||||
* Every padding, gap, and inset in the app should reference one of these
|
||||
* tokens. If a value does not fit the scale, that is a signal to either
|
||||
* round to the nearest step or add a new named token (e.g. `topBarHeight`).
|
||||
*
|
||||
* Scale rationale:
|
||||
* xxs 2 dp hairline gaps, icon-to-label tightening
|
||||
* xs 4 dp intra-component spacing
|
||||
* sm 8 dp default gap between siblings
|
||||
* md 12 dp card inner padding, pill padding
|
||||
* lg 16 dp screen edge gutters, section gaps
|
||||
* xl 24 dp hero spacing, dialog padding
|
||||
* xxl 32 dp large hero / fullscreen padding
|
||||
*/
|
||||
object KaizenSpacing {
|
||||
val xxs = 2.dp
|
||||
val xs = 4.dp
|
||||
val sm = 8.dp
|
||||
val md = 12.dp
|
||||
val lg = 16.dp
|
||||
val xl = 24.dp
|
||||
val xxl = 32.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch-target size tokens.
|
||||
*
|
||||
* Material Design and WCAG both require a minimum 48 dp touch target.
|
||||
* Use [minTouch] as the floor for any `Modifier.size(...)` that is also
|
||||
* `.clickable`. The visual icon inside may be smaller — wrap it in a Box
|
||||
* of `minTouch` size centered.
|
||||
*/
|
||||
object KaizenTouch {
|
||||
/** Minimum tappable surface (Material / WCAG). */
|
||||
val minTouch = 48.dp
|
||||
/** Comfortable tap target (slightly above minimum). */
|
||||
val comfortable = 52.dp
|
||||
/** Hero-level button — bottom dock, primary CTA, end-call. */
|
||||
val hero = 64.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon size scale — every Icon size should be one of these tokens.
|
||||
*
|
||||
* Aligns visually with the Lucide stroke-width 1.0 px design language.
|
||||
*/
|
||||
object KaizenIconSize {
|
||||
val xs = 12.dp // inline indicator, badge dot
|
||||
val sm = 14.dp // chip / pill icon
|
||||
val md = 16.dp // standard control icon
|
||||
val lg = 20.dp // primary action icon (send, mic)
|
||||
val xl = 24.dp // header icon, hero icon
|
||||
}
|
||||
Loading…
Reference in a new issue