From 1b4cc475f98e0f82b8f6a150e843f609ec94e50c Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Wed, 24 Jun 2026 13:39:42 +0200 Subject: [PATCH] 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 --- .../java/dev/kaizen/app/auth/LoginScreen.kt | 9 +-- .../kaizen/app/auth/PasswordUnlockDialog.kt | 5 +- .../java/dev/kaizen/app/chat/Background.kt | 42 +++++++----- .../dev/kaizen/app/chat/ChatComponents.kt | 36 ++++++---- .../java/dev/kaizen/app/chat/ChatScreen.kt | 12 ++-- .../main/java/dev/kaizen/app/chat/Sidebar.kt | 20 +++--- .../dev/kaizen/app/live/LiveCallOverlay.kt | 5 +- .../kaizen/app/settings/SecuritySection.kt | 5 +- .../kaizen/app/settings/SettingsComponents.kt | 1 - .../dev/kaizen/app/settings/SettingsScreen.kt | 1 - .../dev/kaizen/app/ui/effect/GlassSurface.kt | 16 +++-- .../java/dev/kaizen/app/ui/effect/Shadow.kt | 67 +++++++++++++++++-- .../java/dev/kaizen/app/ui/shape/Squircle.kt | 4 +- .../java/dev/kaizen/app/ui/theme/Color.kt | 32 +++++++++ .../java/dev/kaizen/app/ui/theme/Spacing.kt | 59 ++++++++++++++++ 15 files changed, 245 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/ui/theme/Spacing.kt diff --git a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt index 7b1cb5f..30b0bd6 100644 --- a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt +++ b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt @@ -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) diff --git a/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt b/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt index 882bb8d..307b622 100644 --- a/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt +++ b/app/src/main/java/dev/kaizen/app/auth/PasswordUnlockDialog.kt @@ -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))) diff --git a/app/src/main/java/dev/kaizen/app/chat/Background.kt b/app/src/main/java/dev/kaizen/app/chat/Background.kt index ebaa2cb..2d10177 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Background.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Background.kt @@ -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,16 +79,18 @@ fun MeshBackground(animateBlobs: Boolean = true, content: @Composable BoxScope.( drawRect(brush = ditherBrush, alpha = DITHER_ALPHA) }, ) { - 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) - Blob(accent.blob4, 420.dp, (180f + drift * 40f).dp, (620f - drift * 44f).dp, 0.38f * factor) - if (dark) { - Box( - Modifier.fillMaxSize().background( - Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.35f))), - ), - ) + 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) + Blob(accent.blob4, 420.dp, (180f + drift * 40f).dp, (620f - drift * 44f).dp, 0.38f * factor) + if (dark) { + Box( + Modifier.fillMaxSize().background( + Brush.radialGradient(listOf(Color.Transparent, base.copy(alpha = 0.35f))), + ), + ) + } } 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 --- diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index e45acb8..45e8779 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -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( diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index afaf5e7..5f97add 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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) diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt index 0b7712e..61c6e1a 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt @@ -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, diff --git a/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt b/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt index 659aba6..7aea1d0 100644 --- a/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt +++ b/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt @@ -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, ) ) ) diff --git a/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt b/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt index 02272c7..4d56158 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt @@ -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))) diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt index 6c51788..674d322 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt @@ -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() diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt index 5726c2e..3ccff60 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt @@ -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) { diff --git a/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt b/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt index 8508ecb..32aed75 100644 --- a/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt +++ b/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt @@ -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() diff --git a/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt b/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt index d1db6fd..afc110a 100644 --- a/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt +++ b/app/src/main/java/dev/kaizen/app/ui/effect/Shadow.kt @@ -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, 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, + radiusForSize: (Size) -> Float, +) { + val cr = radiusForSize(size) drawIntoCanvas { canvas -> layers.forEach { layer -> val paint = Paint().apply { diff --git a/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt b/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt index 01e6e12..1e1ed7b 100644 --- a/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt +++ b/app/src/main/java/dev/kaizen/app/ui/shape/Squircle.kt @@ -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 { diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt index b38a036..9f0c70b 100644 --- a/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Color.kt @@ -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) +} diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Spacing.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Spacing.kt new file mode 100644 index 0000000..b648293 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Spacing.kt @@ -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 +}