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:
Bruno Deanoz 2026-06-24 13:39:42 +02:00
parent 3b32b208b6
commit 1b4cc475f9
15 changed files with 245 additions and 69 deletions

View file

@ -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)

View file

@ -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)))

View file

@ -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 ---

View file

@ -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(

View file

@ -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)

View file

@ -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,

View file

@ -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,
)
)
)

View file

@ -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)))

View file

@ -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()

View file

@ -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) {

View file

@ -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()

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View 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
}