feat(android): mascot mood engine and onboarding moods (#109311)

Ports the deterministic mascot animator to Android: full pose channels
(face, gaze, droop, hard hat, effects), seeded mood loops including the
hard-hat working hammer cycle, entrance gestures, and static poses when
system animations are off. Onboarding heroes now feel the flow — working
while the gateway connects, celebrating once paired, thinking during
node approval, curious on permissions, sad on visible errors. Tinted
silhouette marks keep their ambient idle loop and stay silent for
TalkBack outside the welcome hero.
This commit is contained in:
Peter Steinberger
2026-07-16 14:04:40 -07:00
committed by GitHub
parent 5a0fcc2ff9
commit 7496f4d988
7 changed files with 1460 additions and 362 deletions
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@ import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.design.MascotMood
import ai.openclaw.app.ui.design.OpenClawMascot
import android.Manifest
import android.content.ClipData
@@ -284,6 +285,34 @@ internal fun OnboardingErrorCode.nativeTextOrNull(): NativeText? {
}
}
/** Visible errors outrank step defaults; connected recovery is the only pre-handoff success surface. */
internal fun onboardingMascotMood(
step: OnboardingStep,
recoveryState: GatewayRecoveryUiState? = null,
setupErrorCode: OnboardingErrorCode = OnboardingErrorCode.None,
setupScanErrorCode: OnboardingErrorCode = OnboardingErrorCode.None,
): MascotMood {
if (
setupErrorCode != OnboardingErrorCode.None ||
setupScanErrorCode != OnboardingErrorCode.None ||
recoveryState == GatewayRecoveryUiState.Failed
) {
return MascotMood.Sad
}
return when (step) {
OnboardingStep.Recovery ->
if (recoveryState == GatewayRecoveryUiState.Connected) MascotMood.Celebrating else MascotMood.Working
OnboardingStep.NodeApproval -> MascotMood.Thinking
OnboardingStep.Permissions -> MascotMood.Curious
OnboardingStep.Welcome,
OnboardingStep.Gateway,
OnboardingStep.SetupCode,
OnboardingStep.EnterSetupCode,
OnboardingStep.Manual,
-> MascotMood.Idle
}
}
private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L
private const val GATEWAY_CONNECT_TIMEOUT_MS = 20_000L
private const val NODE_APPROVAL_REFRESH_OBSERVE_TIMEOUT_MS = 750L
@@ -762,6 +791,11 @@ fun OnboardingFlow(
setupScanErrorCode.nativeTextOrNull()?.let { message ->
SetupScanErrorDialog(
message = message.resolveNativeTextResource(),
mascotMood =
onboardingMascotMood(
step = step,
setupScanErrorCode = setupScanErrorCode,
),
onDismiss = { setupScanErrorCode = OnboardingErrorCode.None },
onChooseAnotherImage = {
setupScanErrorCode = OnboardingErrorCode.None
@@ -816,6 +850,7 @@ fun OnboardingFlow(
OnboardingStep.Welcome ->
WelcomeScreen(
modifier = modifier,
mascotMood = onboardingMascotMood(step = step),
onConnect = { step = OnboardingStep.Gateway },
)
OnboardingStep.Gateway ->
@@ -879,6 +914,7 @@ fun OnboardingFlow(
modifier = modifier,
setupCode = setupCode,
error = setupErrorCode.nativeTextOrNull()?.resolveNativeTextResource(),
mascotMood = onboardingMascotMood(step = step, setupErrorCode = setupErrorCode),
onBack = ::goBack,
onSetupCodeChange = {
setupCode = it
@@ -895,6 +931,7 @@ fun OnboardingFlow(
token = token,
password = password,
error = setupErrorCode.nativeTextOrNull()?.resolveNativeTextResource(),
mascotMood = onboardingMascotMood(step = step, setupErrorCode = setupErrorCode),
onBack = ::goBack,
onManualHostChange = {
manualHost = it
@@ -990,6 +1027,7 @@ fun OnboardingFlow(
@Composable
private fun WelcomeScreen(
mascotMood: MascotMood,
onConnect: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -999,7 +1037,7 @@ private fun WelcomeScreen(
OnboardingIntroHero(
title = nativeString("Welcome to OpenClaw"),
subtitle = nativeString("Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools."),
mark = { WelcomeLogo() },
mark = { WelcomeLogo(mood = mascotMood, announceLogo = true) },
)
Spacer(modifier = Modifier.height(24.dp))
WelcomeChecklist()
@@ -1014,7 +1052,12 @@ private fun WelcomeScreen(
}
@Composable
private fun WelcomeLogo() {
private fun WelcomeLogo(
mood: MascotMood,
// Only the welcome hero announces the logo; status/error reuses are
// decorative and must stay silent for TalkBack.
announceLogo: Boolean = false,
) {
Surface(
modifier = Modifier.size(OnboardingHeroMarkSize),
shape = CircleShape,
@@ -1023,7 +1066,11 @@ private fun WelcomeLogo() {
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
OpenClawMascot(contentDescription = nativeString("OpenClaw logo"), modifier = Modifier.fillMaxSize())
OpenClawMascot(
contentDescription = if (announceLogo) nativeString("OpenClaw logo") else null,
modifier = Modifier.fillMaxSize(),
mood = mood,
)
}
}
}
@@ -1307,6 +1354,7 @@ private fun SetupCodeInstructionsScreen(
@Composable
private fun SetupScanErrorDialog(
message: String,
mascotMood: MascotMood,
onDismiss: () -> Unit,
onChooseAnotherImage: () -> Unit,
onEnterSetupCode: () -> Unit,
@@ -1349,6 +1397,10 @@ private fun SetupScanErrorDialog(
)
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
WelcomeLogo(mood = mascotMood)
}
Text(
text = message,
style = ClawTheme.type.body,
@@ -1644,6 +1696,7 @@ private fun analyzeSetupQrFrame(
private fun SetupCodeEntryScreen(
setupCode: String,
error: String?,
mascotMood: MascotMood,
onBack: () -> Unit,
onSetupCodeChange: (String) -> Unit,
onUseSetupCode: () -> Unit,
@@ -1653,6 +1706,11 @@ private fun SetupCodeEntryScreen(
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
OnboardingHeader(title = nativeText("Enter setup code"), onBack = onBack)
if (error != null) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
WelcomeLogo(mood = mascotMood)
}
}
LabeledField(label = nativeString("Setup code")) {
ClawTextField(
value = setupCode,
@@ -1679,6 +1737,7 @@ private fun ManualGatewaySetupScreen(
token: String,
password: String,
error: String?,
mascotMood: MascotMood,
onBack: () -> Unit,
onManualHostChange: (String) -> Unit,
onManualPortChange: (String) -> Unit,
@@ -1789,6 +1848,11 @@ private fun ManualGatewaySetupScreen(
}
}
error?.let { message ->
item {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
WelcomeLogo(mood = mascotMood)
}
}
item {
InlineError(title = nativeString("Could not test connection"), body = message)
}
@@ -1956,7 +2020,13 @@ private fun GatewayRecoveryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GatewayRecoveryIcon(state = recoveryState)
WelcomeLogo(
mood =
onboardingMascotMood(
step = OnboardingStep.Recovery,
recoveryState = recoveryState,
),
)
Spacer(modifier = Modifier.height(13.dp))
Text(text = recoveryTitle.resolveNativeTextResource(), style = ClawTheme.type.display, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp))
@@ -2052,46 +2122,6 @@ private fun copyGatewayDiagnostic(
Toast.makeText(context, nativeString("Details copied"), Toast.LENGTH_SHORT).show()
}
@Composable
private fun GatewayRecoveryIcon(state: GatewayRecoveryUiState) {
val icon =
when (state) {
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.Security
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.TakingLonger -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline
}
val tint =
when (state) {
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warning
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning
}
Surface(
modifier = Modifier.size(62.dp),
shape = CircleShape,
color =
when (state) {
GatewayRecoveryUiState.Connected -> ClawTheme.colors.successSoft
GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warningSoft
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warningSoft
else -> ClawTheme.colors.surfaceRaised
},
contentColor = tint,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(36.dp), tint = tint)
}
}
}
@Composable
private fun NodeApprovalScreen(
approval: GatewayNodeCapabilityApproval,
@@ -2131,7 +2161,7 @@ private fun NodeApprovalScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GatewayRecoveryIcon(state = GatewayRecoveryUiState.NodeCapabilityApprovalPending)
WelcomeLogo(mood = onboardingMascotMood(step = OnboardingStep.NodeApproval))
Spacer(modifier = Modifier.height(13.dp))
Text(
text = nativeString("Approve node access"),
@@ -2366,6 +2396,11 @@ private fun PermissionSetupScreen(
item {
PermissionTopBar(onBack = onBack)
}
item {
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), contentAlignment = Alignment.Center) {
WelcomeLogo(mood = onboardingMascotMood(step = OnboardingStep.Permissions))
}
}
item {
Text(
text = nativeString("Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings."),
@@ -0,0 +1,515 @@
package ai.openclaw.app.ui.design
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
private const val NONZERO_SEED: ULong = 0x9E37_79B9_7F4A_7C15uL
private const val XORSHIFT_MULTIPLIER: ULong = 2_685_821_657_736_338_717uL
private const val TAU = PI * 2.0
private const val BLINK_DURATION = 0.16
private const val CATCH_DURATION = 0.8
private enum class Gesture {
Wave,
Hop,
Celebrate,
Sigh,
Yawn,
ClawSnap,
DonHardHat,
WipeBrow,
}
private fun clamp(
value: Double,
min: Double = 0.0,
max: Double = 1.0,
): Double = value.coerceIn(min, max)
private fun cyclePhase(
time: Double,
period: Double,
): Double {
val normalized = (time / period) % 1.0
return if (normalized < 0.0) normalized + 1.0 else normalized
}
private fun easeInOut(value: Double): Double {
val t = clamp(value)
return t * t * (3.0 - 2.0 * t)
}
private fun bell(value: Double): Double {
val t = clamp(value)
return easeInOut(if (t < 0.5) t * 2.0 else (1.0 - t) * 2.0)
}
private fun plateau(
value: Double,
attack: Double,
release: Double,
): Double {
val t = clamp(value)
if (t < attack) return easeInOut(t / attack)
if (t > release) return easeInOut((1.0 - t) / (1.0 - release))
return 1.0
}
private fun gestureDuration(gesture: Gesture): Double =
when (gesture) {
Gesture.Wave -> 1.5
Gesture.Hop -> 0.7
Gesture.Celebrate -> 2.4
Gesture.Sigh -> 1.8
Gesture.Yawn,
Gesture.WipeBrow,
-> 2.0
Gesture.ClawSnap -> 0.6
Gesture.DonHardHat -> 1.0
}
private class SeededGenerator(
seed: ULong,
) {
private var state = if (seed == 0uL) NONZERO_SEED else seed
fun next(): ULong {
state = state xor (state shr 12)
state = state xor (state shl 25)
state = state xor (state shr 27)
return state * XORSHIFT_MULTIPLIER
}
fun unit(): Double = (next() shr 11).toDouble() / 9_007_199_254_740_992.0
}
/** Pure deterministic mood loops plus randomized blink, gaze, claw-snap, and mood-beat schedules. */
class MascotAnimator(
seed: ULong = System.nanoTime().toULong(),
) {
private val rng = SeededGenerator(seed)
private var currentMood = MascotMood.Idle
private var startTime: Double? = null
private var lastPoseTime = 0.0
private var activeGesture: Gesture? = null
private var activeGestureStart = 0.0
private var pendingGesture: Gesture? = null
private var pendingGestureAt = 0.0
private var nextBlinkAt = 0.0
private var pendingDoubleBlink = false
private val blinkStarts = mutableListOf<Double>()
private var nextGlanceAt = 0.0
private var gazeHoldUntil = 0.0
private var gazeTarget = MascotGaze()
private var currentGaze = MascotGaze()
private var nextClawSnapAt = 0.0
private var nextMoodBeatAt = 0.0
private var teaseActive = false
private var teaseChangedAt = 0.0
private var catchStartedAt: Double? = null
fun setMood(
mood: MascotMood,
timeSeconds: Double,
) {
if (mood == currentMood) return
currentMood = mood
// Do not let a queued hello or old mood gesture leak into the new body language.
pendingGesture = null
activeGesture = null
rescheduleMoodBeat(timeSeconds)
entranceGesture(mood)?.let { startGesture(it, timeSeconds) }
}
fun setTease(
active: Boolean,
timeSeconds: Double,
) {
teaseActive = active
teaseChangedAt = timeSeconds
}
fun playCatch(timeSeconds: Double) {
catchStartedAt = timeSeconds
}
fun poseAt(timeSeconds: Double): MascotPose {
if (startTime == null) begin(timeSeconds)
val dt = clamp(timeSeconds - lastPoseTime, 0.0, 0.1)
lastPoseTime = timeSeconds
advanceSchedules(timeSeconds)
// TS and Swift phase ambient loops from their host clocks. startTime only
// gates first-run schedules; raw time here preserves cross-platform parity.
val pose = basePose(currentMood, timeSeconds)
applyGaze(pose, currentMood, timeSeconds, dt)
applyBlinks(pose, timeSeconds)
activeGesture?.let { gesture ->
val progress = (timeSeconds - activeGestureStart) / gestureDuration(gesture)
if (progress >= 1.0) {
activeGesture = null
} else {
applyGesture(gesture, pose, progress)
}
}
if (teaseActive && timeSeconds >= teaseChangedAt) {
pose.mouthRound = max(pose.mouthRound, 0.5)
pose.gaze = MascotGaze(x = 0.0, y = 0.6)
}
catchStartedAt?.let { startedAt ->
val progress = (timeSeconds - startedAt) / CATCH_DURATION
if (progress >= 1.0) {
catchStartedAt = null
} else if (progress >= 0.0) {
val flash = bell(progress)
applyGesture(Gesture.ClawSnap, pose, clamp(progress / 0.75))
pose.happyEyes = max(pose.happyEyes, 0.9 * flash)
pose.mouthCurve = max(pose.mouthCurve, 0.7 * flash)
pose.blush = max(pose.blush, 0.65 * flash)
}
}
return pose.clamp()
}
private fun begin(timeSeconds: Double) {
startTime = timeSeconds
lastPoseTime = timeSeconds
nextBlinkAt = timeSeconds + random(0.8, 2.4)
nextGlanceAt = timeSeconds + random(1.5, 4.0)
nextClawSnapAt = timeSeconds + random(2.0, 5.0)
rescheduleMoodBeat(timeSeconds)
if (currentMood == MascotMood.Idle || currentMood == MascotMood.Curious || currentMood == MascotMood.Happy) {
pendingGesture = Gesture.Wave
pendingGestureAt = timeSeconds + 0.9
}
}
private fun advanceSchedules(timeSeconds: Double) {
if (timeSeconds >= nextBlinkAt) {
blinkStarts.add(timeSeconds)
if (pendingDoubleBlink) {
pendingDoubleBlink = false
nextBlinkAt = timeSeconds + blinkInterval()
} else if (random(0.0, 1.0) < 0.14) {
pendingDoubleBlink = true
nextBlinkAt = timeSeconds + 0.34
} else {
nextBlinkAt = timeSeconds + blinkInterval()
}
}
blinkStarts.removeAll { start -> timeSeconds - start > BLINK_DURATION }
if (timeSeconds >= nextGlanceAt) {
gazeTarget = randomGlanceTarget()
gazeHoldUntil = timeSeconds + random(0.7, 1.9)
nextGlanceAt = gazeHoldUntil + glanceInterval()
} else if (timeSeconds >= gazeHoldUntil) {
gazeTarget = MascotGaze()
}
if (timeSeconds >= nextClawSnapAt) {
if (activeGesture == null && currentMood != MascotMood.Sad && currentMood != MascotMood.Working) {
startGesture(Gesture.ClawSnap, timeSeconds)
}
nextClawSnapAt = timeSeconds + random(4.0, 9.0)
}
if (timeSeconds >= nextMoodBeatAt) {
if (activeGesture == null) {
when (currentMood) {
MascotMood.Sad -> startGesture(Gesture.Sigh, timeSeconds)
MascotMood.Sleepy -> startGesture(Gesture.Yawn, timeSeconds)
MascotMood.Working -> startGesture(Gesture.WipeBrow, timeSeconds)
else -> Unit
}
}
rescheduleMoodBeat(timeSeconds)
}
val pending = pendingGesture
if (pending != null && timeSeconds >= pendingGestureAt && activeGesture == null) {
pendingGesture = null
startGesture(pending, timeSeconds)
}
}
private fun basePose(
mood: MascotMood,
timeSeconds: Double,
): MascotPose {
val pose = MascotPose()
when (mood) {
MascotMood.Idle -> {
pose.floatOffset = -4.8 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 4.0)))
pose.antennaDegrees = -3.0 * sin(TAU * cyclePhase(timeSeconds, 2.0))
}
MascotMood.Curious -> {
pose.floatOffset = -4.2 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.4)))
pose.antennaDegrees = -4.0 * sin(TAU * cyclePhase(timeSeconds, 1.7))
pose.bodyTilt = 1.6 * sin(TAU * cyclePhase(timeSeconds, 5.2))
}
MascotMood.Thinking -> {
pose.floatOffset = -3.2 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 5.0)))
pose.antennaDegrees = -5.0 * sin(TAU * cyclePhase(timeSeconds, 1.3))
pose.bodyTilt = 2.0 * sin(TAU * cyclePhase(timeSeconds, 6.0))
pose.eyeGlowAlpha = 0.9 + 0.1 * sin(TAU * cyclePhase(timeSeconds, 0.8))
}
MascotMood.Working -> {
val phase = cyclePhase(timeSeconds, 0.95)
pose.rightClawDegrees =
when {
phase < 0.05 -> -6.0
phase < 0.6 -> -6.0 - 28.0 * easeInOut((phase - 0.05) / 0.55)
phase < 0.72 -> {
val strike = clamp((phase - 0.6) / 0.12)
-34.0 + 46.0 * strike * strike
}
else -> 12.0 - 18.0 * easeInOut((phase - 0.72) / 0.28)
}
pose.leftClawDegrees = 4.0 + 2.0 * sin(TAU * phase)
val impact = bell(clamp((phase - 0.72) / 0.14))
pose.floatOffset = -2.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.8))) + 0.8 * impact
pose.bodyStretch = 1.0 - 0.03 * impact
pose.bodyTilt = 2.2 + 0.6 * sin(TAU * cyclePhase(timeSeconds, 5.0))
if (phase >= 0.72) {
val recoil = clamp((phase - 0.72) / 0.28)
pose.antennaDegrees = 6.0 * (1.0 - recoil) * sin(recoil * 3.0 * PI)
}
pose.leftEyeOpenness = 0.85
pose.rightEyeOpenness = 0.85
pose.mouthCurve = 0.18
pose.hardHat = 1.0
pose.effect = MascotEffect.Sparks
val strikePhase = (phase - 0.72) % 1.0
pose.effectPhase = if (strikePhase < 0.0) strikePhase + 1.0 else strikePhase
}
MascotMood.Happy -> {
pose.floatOffset = -6.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.0)))
pose.antennaDegrees = -4.5 * sin(TAU * cyclePhase(timeSeconds, 1.6))
pose.mouthCurve = 0.55 + 0.1 * sin(TAU * cyclePhase(timeSeconds, 3.0))
pose.happyEyes = 0.35
}
MascotMood.Celebrating -> {
val hop = abs(sin(TAU * cyclePhase(timeSeconds, 1.6)))
pose.floatOffset = -9.0 * hop
pose.bodyStretch = 1.0 + 0.03 * hop
pose.antennaDegrees = -6.0 * sin(TAU * cyclePhase(timeSeconds, 0.8))
val clawWave = sin(TAU * cyclePhase(timeSeconds, 0.9))
pose.leftClawDegrees = 20.0 + 8.0 * clawWave
pose.rightClawDegrees = -20.0 + 8.0 * clawWave
pose.mouthCurve = 0.9
pose.mouthOpen = 0.35
pose.happyEyes = 0.7
pose.glowScale = 1.1
pose.effect = MascotEffect.Sparkles
pose.effectPhase = cyclePhase(timeSeconds, 2.2)
}
MascotMood.Sad -> {
pose.floatOffset = -2.4 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 5.5)))
pose.antennaDegrees = -1.5 * sin(TAU * cyclePhase(timeSeconds, 3.0))
pose.antennaDroop = 0.75
pose.mouthCurve = -0.55
pose.eyeGlowAlpha = 0.6
}
MascotMood.Sleepy -> {
pose.floatOffset = -2.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 6.0)))
pose.antennaDroop = 0.35
pose.leftEyeOpenness = 0.22 + 0.08 * sin(TAU * cyclePhase(timeSeconds, 3.0))
pose.rightEyeOpenness = pose.leftEyeOpenness
pose.eyeGlowAlpha = 0.5
pose.mouthRound = 0.15
pose.bodyTilt = 2.5 * sin(TAU * cyclePhase(timeSeconds, 6.0))
pose.effect = MascotEffect.Zzz
pose.effectPhase = cyclePhase(timeSeconds, 3.0)
}
MascotMood.Attentive -> {
pose.floatOffset = -3.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 4.0)))
pose.antennaDegrees = -2.5 * sin(TAU * cyclePhase(timeSeconds, 2.0))
pose.mouthCurve = 0.25
}
}
return pose
}
private fun applyGaze(
pose: MascotPose,
mood: MascotMood,
timeSeconds: Double,
dt: Double,
) {
val target =
when (mood) {
MascotMood.Thinking -> MascotGaze(x = 0.4 * sin(TAU * cyclePhase(timeSeconds, 3.8)), y = -0.55)
MascotMood.Working ->
MascotGaze(
x = 0.55 + 0.04 * sin(TAU * cyclePhase(timeSeconds, 4.6)),
y = 0.45 + 0.02 * cos(TAU * cyclePhase(timeSeconds, 3.9)),
)
MascotMood.Attentive -> MascotGaze(x = gazeTarget.x * 0.5, y = 0.35)
MascotMood.Sad -> MascotGaze(x = gazeTarget.x * 0.3, y = 0.5)
MascotMood.Sleepy -> MascotGaze(x = 0.0, y = 0.4)
MascotMood.Idle,
MascotMood.Curious,
MascotMood.Happy,
MascotMood.Celebrating,
-> gazeTarget
}
val blend = 1.0 - exp(-dt * 9.0)
currentGaze =
MascotGaze(
x = currentGaze.x + (target.x - currentGaze.x) * blend,
y = currentGaze.y + (target.y - currentGaze.y) * blend,
)
pose.gaze = currentGaze
}
private fun applyBlinks(
pose: MascotPose,
timeSeconds: Double,
) {
if (pose.happyEyes >= 0.6) return
for (start in blinkStarts) {
val progress = (timeSeconds - start) / BLINK_DURATION
if (progress < 0.0 || progress > 1.0) continue
val closure = bell(progress)
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1.0 - closure)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1.0 - closure)
pose.eyeGlowAlpha *= max(0.3, 1.0 - closure)
}
}
private fun applyGesture(
gesture: Gesture,
pose: MascotPose,
progress: Double,
) {
val p = clamp(progress)
when (gesture) {
Gesture.Wave -> {
val raised = plateau(p, 0.18, 0.82)
pose.rightClawDegrees += raised * (-28.0 + 9.0 * sin(p * 6.0 * PI))
pose.bodyTilt += -2.0 * raised
pose.mouthCurve = max(pose.mouthCurve, 0.5 * raised)
}
Gesture.Hop -> {
val air = bell(clamp((p - 0.2) / 0.6))
pose.floatOffset += -9.0 * air
pose.bodyStretch +=
0.045 * air -
0.1 * bell(clamp(p / 0.2)) -
0.06 * bell(clamp((p - 0.82) / 0.18))
pose.mouthCurve = max(pose.mouthCurve, 0.4 * air)
}
Gesture.Celebrate -> {
val envelope = plateau(p, 0.12, 0.88)
val hops = abs(sin(p * 4.0 * PI))
pose.floatOffset += -11.0 * hops * envelope
pose.bodyStretch += 0.035 * hops * envelope
pose.leftClawDegrees += 38.0 * envelope
pose.rightClawDegrees += -38.0 * envelope
pose.happyEyes = max(pose.happyEyes, envelope)
pose.mouthCurve = max(pose.mouthCurve, envelope)
pose.mouthOpen = max(pose.mouthOpen, 0.6 * bell(p))
pose.antennaDroop = 0.0
pose.glowScale = max(pose.glowScale, 1.0 + 0.2 * envelope)
pose.effect = MascotEffect.Sparkles
pose.effectPhase = p
}
Gesture.Sigh -> {
val rise = easeInOut(clamp(p / 0.3))
val fall = easeInOut(clamp((p - 0.3) / 0.45))
pose.bodyStretch += 0.025 * rise - 0.08 * fall * (1.0 - clamp((p - 0.85) / 0.15))
pose.gaze = MascotGaze(x = pose.gaze.x, y = 0.5 * fall)
pose.antennaDroop = min(1.0, pose.antennaDroop + 0.15 * fall)
}
Gesture.Yawn -> {
val openness = plateau(p, 0.3, 0.75)
pose.mouthRound = max(pose.mouthRound, 0.9 * openness)
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1.0 - 0.9 * openness)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1.0 - 0.9 * openness)
pose.bodyStretch += 0.03 * openness
pose.bodyTilt += -2.0 * openness
}
Gesture.ClawSnap -> {
pose.leftClawDegrees += -8.0 * bell(clamp(p / 0.7))
pose.rightClawDegrees += -8.0 * bell(clamp((p - 0.25) / 0.7))
}
Gesture.DonHardHat -> {
val drop = easeInOut(clamp(p / 0.55))
pose.hardHat = min(pose.hardHat, drop)
if (p < 0.55) pose.gaze = MascotGaze(x = 0.0, y = -0.9 * (1.0 - p))
pose.bodyStretch -= 0.04 * bell(clamp((p - 0.5) / 0.2))
val ready = bell(clamp((p - 0.7) / 0.3))
pose.leftClawDegrees += -8.0 * ready
pose.rightClawDegrees += 8.0 * ready
}
Gesture.WipeBrow -> {
val envelope = plateau(p, 0.2, 0.8)
pose.leftClawDegrees *= 1.0 - envelope
pose.rightClawDegrees *= 1.0 - envelope
pose.leftClawDegrees += 38.0 * envelope * (0.9 + 0.1 * sin(p * 5.0 * PI))
pose.bodyTilt *= 1.0 - envelope
pose.bodyStretch += 0.02 * envelope
pose.happyEyes = max(pose.happyEyes, 0.7 * envelope)
pose.mouthCurve = max(pose.mouthCurve, 0.5 * envelope)
pose.gaze = MascotGaze(x = pose.gaze.x * (1.0 - envelope), y = pose.gaze.y * (1.0 - envelope))
pose.effect = MascotEffect.Sweat
pose.effectPhase = p
}
}
}
private fun entranceGesture(mood: MascotMood): Gesture? =
when (mood) {
MascotMood.Happy -> Gesture.Hop
MascotMood.Celebrating -> Gesture.Celebrate
MascotMood.Sad -> Gesture.Sigh
MascotMood.Sleepy -> Gesture.Yawn
MascotMood.Working -> Gesture.DonHardHat
MascotMood.Idle,
MascotMood.Curious,
MascotMood.Thinking,
MascotMood.Attentive,
-> null
}
private fun startGesture(
gesture: Gesture,
timeSeconds: Double,
) {
activeGesture = gesture
activeGestureStart = timeSeconds
}
private fun random(
min: Double,
max: Double,
): Double = min + (max - min) * rng.unit()
private fun blinkInterval(): Double = if (currentMood == MascotMood.Attentive) random(1.8, 4.0) else random(2.2, 5.5)
private fun glanceInterval(): Double =
when (currentMood) {
MascotMood.Curious -> random(1.6, 4.0)
MascotMood.Thinking -> random(1.2, 3.0)
else -> random(3.0, 8.0)
}
private fun randomGlanceTarget(): MascotGaze {
val magnitude = random(0.5, 1.0)
val angle = random(0.0, TAU)
return MascotGaze(x = cos(angle) * magnitude, y = sin(angle) * magnitude * 0.6)
}
private fun rescheduleMoodBeat(timeSeconds: Double) {
nextMoodBeatAt = timeSeconds + random(6.0, 12.0)
}
}
@@ -0,0 +1,127 @@
package ai.openclaw.app.ui.design
enum class MascotMood {
Idle,
Curious,
Thinking,
Working,
Happy,
Celebrating,
Sad,
Sleepy,
Attentive,
}
enum class MascotEffect {
None,
Sparkles,
Zzz,
Sparks,
Sweat,
}
data class MascotGaze(
val x: Double = 0.0,
val y: Double = 0.0,
)
data class MascotPose(
var floatOffset: Double = 0.0,
var antennaDegrees: Double = 0.0,
var antennaDroop: Double = 0.0,
var leftClawDegrees: Double = 0.0,
var rightClawDegrees: Double = 0.0,
var eyeGlowAlpha: Double = 1.0,
var glowScale: Double = 1.0,
var leftEyeOpenness: Double = 1.0,
var rightEyeOpenness: Double = 1.0,
var happyEyes: Double = 0.0,
var gaze: MascotGaze = MascotGaze(),
var mouthCurve: Double = 0.0,
var mouthOpen: Double = 0.0,
var mouthRound: Double = 0.0,
var blush: Double = 0.0,
var hardHat: Double = 0.0,
var bodyTilt: Double = 0.0,
var bodyStretch: Double = 1.0,
var effect: MascotEffect = MascotEffect.None,
var effectPhase: Double = 0.0,
) {
/** Keeps every channel inside the drawable 120x120 art-space bounds. */
fun clamp(): MascotPose {
floatOffset = floatOffset.coerceIn(-12.0, 2.0)
antennaDegrees = antennaDegrees.coerceIn(-14.0, 14.0)
antennaDroop = antennaDroop.coerceIn(0.0, 1.0)
leftClawDegrees = leftClawDegrees.coerceIn(-45.0, 45.0)
rightClawDegrees = rightClawDegrees.coerceIn(-45.0, 45.0)
eyeGlowAlpha = eyeGlowAlpha.coerceIn(0.0, 1.0)
glowScale = glowScale.coerceIn(0.5, 1.6)
leftEyeOpenness = leftEyeOpenness.coerceIn(0.0, 1.0)
rightEyeOpenness = rightEyeOpenness.coerceIn(0.0, 1.0)
happyEyes = happyEyes.coerceIn(0.0, 1.0)
gaze = MascotGaze(gaze.x.coerceIn(-1.2, 1.2), gaze.y.coerceIn(-1.2, 1.2))
mouthCurve = mouthCurve.coerceIn(-1.0, 1.0)
mouthOpen = mouthOpen.coerceIn(0.0, 1.0)
mouthRound = mouthRound.coerceIn(0.0, 1.0)
blush = blush.coerceIn(0.0, 1.0)
hardHat = hardHat.coerceIn(0.0, 1.0)
bodyTilt = bodyTilt.coerceIn(-8.0, 8.0)
bodyStretch = bodyStretch.coerceIn(0.86, 1.05)
return this
}
companion object {
/** Motionless mood expression used when Android animations are disabled. */
fun staticPose(mood: MascotMood): MascotPose =
MascotPose().apply {
when (mood) {
MascotMood.Idle,
MascotMood.Curious,
MascotMood.Attentive,
-> Unit
MascotMood.Thinking -> gaze = MascotGaze(x = 0.3, y = -0.5)
MascotMood.Working -> {
hardHat = 1.0
rightClawDegrees = -28.0
gaze = MascotGaze(x = 0.4, y = 0.35)
mouthCurve = 0.15
bodyTilt = 2.0
}
MascotMood.Happy -> {
mouthCurve = 0.6
happyEyes = 0.4
}
MascotMood.Celebrating -> {
mouthCurve = 0.9
mouthOpen = 0.4
happyEyes = 0.8
leftClawDegrees = 30.0
rightClawDegrees = -30.0
}
MascotMood.Sad -> {
antennaDroop = 0.75
mouthCurve = -0.55
eyeGlowAlpha = 0.6
gaze = MascotGaze(x = 0.0, y = 0.5)
}
MascotMood.Sleepy -> {
leftEyeOpenness = 0.25
rightEyeOpenness = 0.25
eyeGlowAlpha = 0.5
antennaDroop = 0.35
}
}
}
}
}
fun staticPose(mood: MascotMood): MascotPose = MascotPose.staticPose(mood)
/**
* Tinted silhouettes stay on the ambient idle loop: mood faces cannot read in
* monochrome, and tiny tinted toolbar marks must not hammer or celebrate.
*/
internal fun effectiveMascotMood(
mood: MascotMood,
tinted: Boolean,
): MascotMood = if (tinted) MascotMood.Idle else mood
@@ -1,25 +1,24 @@
package ai.openclaw.app.ui.design
import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
@@ -29,6 +28,10 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
// Canonical 120x120 mascot geometry from ui/public/favicon.svg; parts stay
// separate paths so claws, antennae, and eyes can animate independently.
@@ -44,40 +47,56 @@ private val RightClawPath =
PathParser().parsePathString("M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z").toPath()
private val LeftAntennaPath = PathParser().parsePathString("M45 15 Q35 5 30 8").toPath()
private val RightAntennaPath = PathParser().parsePathString("M75 15 Q85 5 90 8").toPath()
private val HardHatDomePath =
PathParser().parsePathString("M45 15 C47 7 54 3 60 3 C66 3 73 7 75 15 L45 15 Z").toPath()
private val CoralBright = Color(0xFFFF4D4D)
private val CoralDark = Color(0xFF991B1B)
private val EyeDark = Color(0xFF050810)
private val EyeGlow = Color(0xFF00E5CC)
private val Blush = Color(0xFFFF9EAE)
private val HatAmber = Color(0xFFF2A833)
private val HatLight = Color(0xFFFFD659)
private val HatOutline = Color(0xB8B8731F)
private val SweatBlue = Color(0xFF80D4FF)
// Claws hinge on their body-facing edge, antennae rotate around their own center.
private val LeftClawPivot = Offset(26f, 53f)
private val RightClawPivot = Offset(94f, 53f)
private val LeftAntennaPivot = Offset(37.5f, 11f)
private val RightAntennaPivot = Offset(82.5f, 11f)
private val LeftEyeCenter = Offset(45f, 35f)
private val RightEyeCenter = Offset(75f, 35f)
private val EaseInOut = CubicBezierEasing(0.42f, 0f, 0.58f, 1f)
private class MascotPose(
val floatOffset: State<Float>,
val antennaDegrees: State<Float>,
val leftClawDegrees: State<Float>,
val rightClawDegrees: State<Float>,
val eyeGlowAlpha: State<Float>,
)
/**
* Animated OpenClaw mascot mirroring the openclaw.ai hero mark: body float,
* antenna wiggle, eye blink, and staggered claw snaps. With [tint] the mascot
* renders as a single-color silhouette (replacement for tinted [Icon] usage).
*/
/** Animated 120x120 OpenClaw mascot. [tint] keeps the single-color icon rendering path. */
@Composable
fun OpenClawMascot(
modifier: Modifier = Modifier,
tint: Color? = null,
contentDescription: String? = null,
mood: MascotMood = MascotMood.Idle,
) {
val pose = rememberMascotPose()
val context = LocalContext.current
val animationsEnabled =
remember(context) {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
}
val animator = remember { MascotAnimator() }
var pose by remember { mutableStateOf(staticPose(mood)) }
LaunchedEffect(animationsEnabled, mood, tint) {
if (!animationsEnabled) {
pose = staticPose(effectiveMascotMood(mood = mood, tinted = tint != null))
return@LaunchedEffect
}
while (true) {
withFrameNanos { frameTimeNanos ->
val timeSeconds = frameTimeNanos / 1_000_000_000.0
animator.setMood(effectiveMascotMood(mood = mood, tinted = tint != null), timeSeconds)
pose = animator.poseAt(timeSeconds)
}
}
}
val semantics =
if (contentDescription == null) {
Modifier
@@ -88,143 +107,313 @@ fun OpenClawMascot(
}
}
Canvas(modifier = modifier.then(semantics)) {
val scale = size.minDimension / 120f
val artScale = size.minDimension / 120f
withTransform({
scale(scale, scale, pivot = Offset.Zero)
translate(top = pose.floatOffset.value)
scale(artScale, artScale, pivot = Offset.Zero)
translate(top = pose.floatOffset.toFloat())
}) {
drawMascot(pose, tint)
}
}
}
@Composable
private fun rememberMascotPose(): MascotPose {
val context = LocalContext.current
// Compose infinite transitions ignore the system animator scale; honor the
// OS "remove animations" setting explicitly with a static pose.
val animationsEnabled =
remember(context) {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
}
if (!animationsEnabled) {
return remember {
val zero = mutableFloatStateOf(0f)
MascotPose(zero, zero, zero, zero, mutableFloatStateOf(1f))
}
}
val transition = rememberInfiniteTransition(label = "openclawMascot")
val floatOffset =
transition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec =
infiniteRepeatable(
keyframes {
durationMillis = 4000
0f at 0 using EaseInOut
-5f at 2000 using EaseInOut
},
),
label = "float",
)
val antennaDegrees =
transition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec =
infiniteRepeatable(
keyframes {
durationMillis = 2000
0f at 0 using EaseInOut
-3f at 500 using EaseInOut
3f at 1500 using EaseInOut
},
),
label = "antenna",
)
val leftClawDegrees =
transition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(clawSnapKeyframes()),
label = "clawLeft",
)
val rightClawDegrees =
transition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec =
InfiniteRepeatableSpec(
clawSnapKeyframes(),
RepeatMode.Restart,
initialStartOffset = StartOffset(200),
),
label = "clawRight",
)
val eyeGlowAlpha =
transition.animateFloat(
initialValue = 1f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
keyframes {
durationMillis = 3000
1f at 0 using EaseInOut
1f at 2700 using EaseInOut
0.3f at 2850 using EaseInOut
},
),
label = "blink",
)
return remember(transition) {
MascotPose(floatOffset, antennaDegrees, leftClawDegrees, rightClawDegrees, eyeGlowAlpha)
}
}
private fun clawSnapKeyframes() =
keyframes {
durationMillis = 4000
0f at 0 using EaseInOut
0f at 3400 using EaseInOut
-8f at 3600 using EaseInOut
0f at 3800 using EaseInOut
}
private fun DrawScope.drawMascot(
pose: MascotPose,
tint: Color?,
) {
val bodyBrush =
if (tint == null) {
Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset.Zero,
end = Offset(120f, 120f),
)
} else {
SolidColor(tint)
val stretchY = pose.bodyStretch.toFloat()
val stretchX = (1.0 + (1.0 - pose.bodyStretch) * 0.5).coerceIn(0.97, 1.03).toFloat()
withTransform({
if (stretchY != 1f) scale(stretchX, stretchY, pivot = Offset(60f, 110f))
if (pose.bodyTilt != 0.0) rotate(pose.bodyTilt.toFloat(), pivot = Offset(60f, 60f))
}) {
val bodyBrush =
tint?.let(::SolidColor)
?: Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset(15f, 10f),
end = Offset(105f, 110f),
)
val leftClawBrush =
tint?.let(::SolidColor)
?: Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset(3.125f, 43.67f),
end = Offset(26.197f, 65.451f),
)
val rightClawBrush =
tint?.let(::SolidColor)
?: Brush.linearGradient(
colors = listOf(CoralBright, CoralDark),
start = Offset(93.803f, 43.67f),
end = Offset(116.875f, 65.451f),
)
drawPath(BodyPath, bodyBrush)
withTransform({ rotate(pose.leftClawDegrees.toFloat(), pivot = LeftClawPivot) }) {
drawPath(LeftClawPath, leftClawBrush)
}
withTransform({ rotate(pose.rightClawDegrees.toFloat(), pivot = RightClawPivot) }) {
drawPath(RightClawPath, rightClawBrush)
}
val antennaColor = tint ?: CoralBright
val antennaStroke = Stroke(width = 2f, cap = StrokeCap.Round)
val wiggle = (pose.antennaDegrees * (1.0 - pose.antennaDroop)).toFloat()
withTransform({ rotate((-pose.antennaDroop * 40.0).toFloat(), pivot = Offset(45f, 15f)) }) {
withTransform({ rotate(wiggle, pivot = LeftAntennaPivot) }) {
drawPath(LeftAntennaPath, antennaColor, style = antennaStroke)
}
}
withTransform({ rotate((pose.antennaDroop * 40.0).toFloat(), pivot = Offset(75f, 15f)) }) {
withTransform({ rotate(wiggle, pivot = RightAntennaPivot) }) {
drawPath(RightAntennaPath, antennaColor, style = antennaStroke)
}
}
drawHardHat(pose.hardHat.toFloat(), tint)
if (tint == null) {
drawBlush(pose)
drawEye(LeftEyeCenter, pose.leftEyeOpenness, pose)
drawEye(RightEyeCenter, pose.rightEyeOpenness, pose)
drawMouth(pose)
drawEffect(pose)
}
// Same paint order as favicon.svg: body, claws, antennae, eyes.
drawPath(BodyPath, bodyBrush)
withTransform({ rotate(pose.leftClawDegrees.value, pivot = LeftClawPivot) }) {
drawPath(LeftClawPath, bodyBrush)
}
withTransform({ rotate(pose.rightClawDegrees.value, pivot = RightClawPivot) }) {
drawPath(RightClawPath, bodyBrush)
}
val antennaColor = tint ?: CoralBright
val antennaStroke = Stroke(width = 3f, cap = StrokeCap.Round)
withTransform({ rotate(pose.antennaDegrees.value, pivot = LeftAntennaPivot) }) {
drawPath(LeftAntennaPath, antennaColor, style = antennaStroke)
}
withTransform({ rotate(pose.antennaDegrees.value, pivot = RightAntennaPivot) }) {
drawPath(RightAntennaPath, antennaColor, style = antennaStroke)
}
drawCircle(tint ?: EyeDark, radius = 6f, center = Offset(45f, 35f))
drawCircle(tint ?: EyeDark, radius = 6f, center = Offset(75f, 35f))
val glowColor = tint ?: EyeGlow
drawCircle(glowColor, radius = 2.5f, center = Offset(46f, 34f), alpha = pose.eyeGlowAlpha.value)
drawCircle(glowColor, radius = 2.5f, center = Offset(76f, 34f), alpha = pose.eyeGlowAlpha.value)
}
private fun DrawScope.drawEye(
center: Offset,
openness: Double,
pose: MascotPose,
) {
val shifted =
Offset(
x = center.x + (pose.gaze.x * 2.0).toFloat(),
y = center.y + (pose.gaze.y * 1.5).toFloat(),
)
if (pose.happyEyes < 1.0) {
val height = max(1.2, 12.0 * openness * (1.0 - 0.6 * pose.happyEyes)).toFloat()
val eyeCenterY = shifted.y - 6f + (12f - height) * 0.65f + height / 2f
drawOval(
color = EyeDark,
topLeft = Offset(shifted.x - 6f, eyeCenterY - height / 2f),
size = Size(12f, height),
alpha = (1.0 - pose.happyEyes).toFloat(),
)
}
if (pose.happyEyes > 0.0) {
val arc =
Path().apply {
moveTo(shifted.x - 6f, shifted.y + 2f)
quadraticTo(shifted.x, shifted.y - 5.5f, shifted.x + 6f, shifted.y + 2f)
}
drawPath(
path = arc,
color = EyeDark,
alpha = pose.happyEyes.toFloat(),
style = Stroke(width = 2.6f, cap = StrokeCap.Round),
)
}
val glowVisibility = pose.eyeGlowAlpha * openness * (1.0 - pose.happyEyes)
if (glowVisibility <= 0.01) return
val glowRadius = (2.0 * pose.glowScale).toFloat()
val glowCenter =
Offset(
x = shifted.x + 1f + (pose.gaze.x * 1.2).toFloat(),
y = shifted.y - 1f + (pose.gaze.y * 0.9).toFloat(),
)
drawCircle(EyeGlow, radius = glowRadius, center = glowCenter, alpha = glowVisibility.toFloat())
}
private fun DrawScope.drawMouth(pose: MascotPose) {
when {
pose.mouthRound > 0.05 -> {
val radiusX = (1.0 + 3.2 * pose.mouthRound).toFloat()
val radiusY = (1.0 + 4.2 * pose.mouthRound).toFloat()
drawOval(
color = EyeDark,
topLeft = Offset(60f - radiusX, 51f - radiusY),
size = Size(radiusX * 2f, radiusY * 2f),
)
}
pose.mouthOpen > 0.05 -> {
val grin =
Path().apply {
moveTo(52.5f, 48.5f)
quadraticTo(60f, (48.5 + 14.0 * pose.mouthOpen).toFloat(), 67.5f, 48.5f)
close()
}
drawPath(grin, EyeDark)
}
kotlin.math.abs(pose.mouthCurve) > 0.05 -> {
val curve =
Path().apply {
moveTo(52.5f, 49f)
quadraticTo(60f, (49.0 + 8.0 * pose.mouthCurve).toFloat(), 67.5f, 49f)
}
drawPath(curve, EyeDark, style = Stroke(width = 2.2f, cap = StrokeCap.Round))
}
}
}
private fun DrawScope.drawBlush(pose: MascotPose) {
if (pose.blush <= 0.02) return
val alpha = (pose.blush * 0.55).toFloat()
drawOval(Blush, topLeft = Offset(32.5f, 42.5f), size = Size(9f, 5f), alpha = alpha)
drawOval(Blush, topLeft = Offset(78.5f, 42.5f), size = Size(9f, 5f), alpha = alpha)
}
private fun DrawScope.drawHardHat(
amount: Float,
tint: Color?,
) {
if (amount <= 0.01f) return
withTransform({
translate(top = -14f * (1f - amount))
rotate(-5f, pivot = Offset(60f, 15f))
}) {
val fill =
tint?.let(::SolidColor)
?: Brush.verticalGradient(colors = listOf(HatLight, HatAmber), startY = 3f, endY = 16f)
drawPath(HardHatDomePath, fill, alpha = amount)
if (tint == null) {
drawPath(HardHatDomePath, HatOutline, alpha = amount, style = Stroke(width = 0.8f))
}
drawRoundRect(
brush = tint?.let(::SolidColor) ?: SolidColor(HatAmber),
topLeft = Offset(41f, 14f),
size = Size(38f, 5f),
cornerRadius = CornerRadius(2f, 2f),
alpha = amount,
)
if (tint == null) {
drawRoundRect(
color = HatOutline,
topLeft = Offset(41f, 14f),
size = Size(38f, 5f),
cornerRadius = CornerRadius(2f, 2f),
alpha = amount,
style = Stroke(width = 0.8f),
)
}
}
}
private fun DrawScope.drawEffect(pose: MascotPose) {
when (pose.effect) {
MascotEffect.None -> Unit
MascotEffect.Sparkles -> {
repeat(6) { index ->
val phase = (pose.effectPhase + index * 0.37) % 1.0
val alpha = effectBell(phase)
if (alpha > 0.05) {
val angle = PI + PI * (index + 0.5) / 6.0
val center =
Offset(
x = (60.0 + cos(angle) * (50.0 + index % 3 * 4.0)).toFloat(),
y = (55.0 + sin(angle) * (40.0 + (index * 5) % 3 * 4.0)).toFloat(),
)
drawPath(
sparklePath(center, (2.5 + 2.0 * alpha).toFloat()),
if (index % 2 == 0) EyeGlow else CoralBright,
alpha = alpha.toFloat(),
)
}
}
}
MascotEffect.Zzz -> {
repeat(3) { index ->
val phase = (pose.effectPhase + index * 0.33) % 1.0
val alpha = if (phase < 0.2) phase / 0.2 else 1.0 - (phase - 0.2) / 0.8
if (alpha > 0.05) {
drawZ(
position =
Offset(
x = (86.0 + 14.0 * phase + 2.0 * sin(phase * 4.0 * PI)).toFloat(),
y = (24.0 - 20.0 * phase).toFloat(),
),
size = (6.0 + 4.0 * phase).toFloat(),
alpha = alpha.toFloat(),
)
}
}
}
MascotEffect.Sparks -> {
repeat(5) { index ->
val rawPhase = pose.effectPhase - index * 0.025
if (rawPhase >= 0.0 && rawPhase < 0.45) {
val alpha = if (rawPhase < 0.08) rawPhase / 0.08 else 1.0 - (rawPhase - 0.08) / 0.37
val angle = Math.toRadians(-160.0 + index * 35.0)
val radius = 5.0 + 12.0 * rawPhase / 0.45
val particleSize = (2.2 + index % 3 * 0.8).toFloat()
val center =
Offset(
x = (106.0 + cos(angle) * radius).coerceIn(particleSize.toDouble(), 120.0 - particleSize).toFloat(),
y = (66.0 + sin(angle) * radius).coerceIn(particleSize.toDouble(), 120.0 - particleSize).toFloat(),
)
drawPath(
sparklePath(center, particleSize),
if (index % 2 == 0) EyeGlow else HatAmber,
alpha = alpha.toFloat(),
)
}
}
}
MascotEffect.Sweat -> {
val alpha = effectBell(pose.effectPhase)
if (alpha > 0.02) {
val center = Offset(42f, (24.0 + 7.0 * pose.effectPhase).toFloat())
val drop =
Path().apply {
moveTo(center.x, center.y - 3f)
cubicTo(center.x - 4f, center.y + 1f, center.x - 2f, center.y + 3f, center.x, center.y + 3f)
cubicTo(center.x + 2f, center.y + 3f, center.x + 4f, center.y + 1f, center.x, center.y - 3f)
close()
}
drawPath(drop, SweatBlue, alpha = alpha.toFloat())
}
}
}
}
private fun sparklePath(
center: Offset,
size: Float,
): Path =
Path().apply {
moveTo(center.x, center.y - size)
listOf(1f to 0f, 0f to 1f, -1f to 0f, 0f to -1f).forEach { (dx, dy) ->
quadraticTo(center.x, center.y, center.x + size * dx, center.y + size * dy)
}
close()
}
private fun DrawScope.drawZ(
position: Offset,
size: Float,
alpha: Float,
) {
val width = size * 0.62f
val height = size * 0.78f
val path =
Path().apply {
moveTo(position.x - width / 2f, position.y - height / 2f)
lineTo(position.x + width / 2f, position.y - height / 2f)
lineTo(position.x - width / 2f, position.y + height / 2f)
lineTo(position.x + width / 2f, position.y + height / 2f)
}
drawPath(
path = path,
color = EyeGlow,
alpha = alpha * 0.9f,
style = Stroke(width = max(1.2f, size * 0.16f), cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
private fun effectBell(value: Double): Double {
val t = value.coerceIn(0.0, 1.0)
val edge = if (t < 0.5) t * 2.0 else (1.0 - t) * 2.0
return edge * edge * (3.0 - 2.0 * edge)
}
@@ -6,6 +6,7 @@ import ai.openclaw.app.LocationMode
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.ui.design.MascotMood
import android.Manifest
import androidx.compose.runtime.saveable.SaverScope
import kotlinx.coroutines.CompletableDeferred
@@ -19,6 +20,43 @@ import org.junit.Test
import java.util.Base64
class OnboardingFlowLogicTest {
@Test
fun mascotMoodTracksVisibleOnboardingState() {
assertEquals(MascotMood.Idle, onboardingMascotMood(OnboardingStep.Welcome))
assertEquals(MascotMood.Curious, onboardingMascotMood(OnboardingStep.Permissions))
assertEquals(MascotMood.Thinking, onboardingMascotMood(OnboardingStep.NodeApproval))
assertEquals(
MascotMood.Working,
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Finishing),
)
assertEquals(
MascotMood.Working,
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.TakingLonger),
)
assertEquals(
MascotMood.Celebrating,
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Connected),
)
assertEquals(
MascotMood.Sad,
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Failed),
)
assertEquals(
MascotMood.Sad,
onboardingMascotMood(
step = OnboardingStep.EnterSetupCode,
setupErrorCode = OnboardingErrorCode.SetupCodeRejected,
),
)
assertEquals(
MascotMood.Sad,
onboardingMascotMood(
step = OnboardingStep.SetupCode,
setupScanErrorCode = OnboardingErrorCode.InvalidSetupQr,
),
)
}
@Test
fun onboardingBackDestinationsMatchTheVisibleFlow() {
assertEquals(null, onboardingBackDestination(OnboardingStep.Welcome))
@@ -0,0 +1,194 @@
package ai.openclaw.app.ui.design
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MascotAnimatorTest {
@Test
fun allMoodChannelsStayInsideBoundsForThirtySeconds() {
MascotMood.entries.forEachIndexed { index, mood ->
val animator = MascotAnimator(seed = (index + 1).toULong())
animator.setMood(mood, 0.0)
repeat(3_001) { frame ->
val pose = animator.poseAt(frame / 100.0)
assertInBounds(pose, mood, frame)
}
}
}
@Test
fun sameSeedProducesIdenticalPoses() {
val first = MascotAnimator(seed = 0xCAFE_BABEuL)
val second = MascotAnimator(seed = 0xCAFE_BABEuL)
first.setMood(MascotMood.Curious, 0.0)
second.setMood(MascotMood.Curious, 0.0)
repeat(1_500) { frame ->
val time = frame * 0.023
if (frame == 400) {
first.setMood(MascotMood.Thinking, time)
second.setMood(MascotMood.Thinking, time)
}
if (frame == 900) {
first.setMood(MascotMood.Happy, time)
second.setMood(MascotMood.Happy, time)
}
assertEquals(first.poseAt(time), second.poseAt(time))
}
}
@Test
fun workingCycleSeatsHatSwingsClawAndShowsWorkEffects() {
val animator = MascotAnimator(seed = 7uL)
animator.setMood(MascotMood.Working, 0.0)
var minRightClaw = Double.POSITIVE_INFINITY
var maxRightClaw = Double.NEGATIVE_INFINITY
var seatedHat = false
var sawSparks = false
var sawSweat = false
repeat(2_001) { frame ->
val time = frame / 100.0
val pose = animator.poseAt(time)
minRightClaw = minOf(minRightClaw, pose.rightClawDegrees)
maxRightClaw = maxOf(maxRightClaw, pose.rightClawDegrees)
seatedHat = seatedHat || (time >= 1.0 && pose.hardHat >= 0.99)
sawSparks = sawSparks || pose.effect == MascotEffect.Sparks
sawSweat = sawSweat || pose.effect == MascotEffect.Sweat
}
assertTrue("hard hat never seated", seatedHat)
assertTrue("hammer swing was ${maxRightClaw - minRightClaw}°", maxRightClaw - minRightClaw > 25.0)
assertTrue("impact sparks never appeared", sawSparks)
assertTrue("wipe-brow sweat never appeared", sawSweat)
}
@Test
fun moodChangeCancelsQueuedAndActiveGestures() {
val animator = MascotAnimator(seed = 11uL)
animator.poseAt(0.0)
animator.poseAt(0.95)
animator.setMood(MascotMood.Thinking, 0.95)
val afterWaveCancellation = animator.poseAt(1.0)
assertEquals(0.0, afterWaveCancellation.rightClawDegrees, 0.000_001)
animator.setMood(MascotMood.Working, 1.1)
assertTrue(animator.poseAt(1.3).hardHat < 1.0)
animator.setMood(MascotMood.Sad, 1.3)
val afterHatCancellation = animator.poseAt(1.31)
assertEquals(0.0, afterHatCancellation.hardHat, 0.000_001)
}
@Test
fun staticPoseSignaturesMatchMoodContract() {
assertEquals(MascotPose(), staticPose(MascotMood.Idle))
assertEquals(MascotGaze(x = 0.3, y = -0.5), staticPose(MascotMood.Thinking).gaze)
val working = staticPose(MascotMood.Working)
assertEquals(1.0, working.hardHat, 0.0)
assertEquals(-28.0, working.rightClawDegrees, 0.0)
val celebrating = staticPose(MascotMood.Celebrating)
assertEquals(0.8, celebrating.happyEyes, 0.0)
assertEquals(30.0, celebrating.leftClawDegrees, 0.0)
assertEquals(-30.0, celebrating.rightClawDegrees, 0.0)
val sad = staticPose(MascotMood.Sad)
assertEquals(0.75, sad.antennaDroop, 0.0)
assertEquals(-0.55, sad.mouthCurve, 0.0)
val sleepy = staticPose(MascotMood.Sleepy)
assertEquals(0.25, sleepy.leftEyeOpenness, 0.0)
assertEquals(0.5, sleepy.eyeGlowAlpha, 0.0)
}
@Test
fun clampCoversEveryBoundedChannel() {
val pose =
MascotPose(
floatOffset = 100.0,
antennaDegrees = -100.0,
antennaDroop = 2.0,
leftClawDegrees = -100.0,
rightClawDegrees = 100.0,
eyeGlowAlpha = -1.0,
glowScale = 9.0,
leftEyeOpenness = -1.0,
rightEyeOpenness = 2.0,
happyEyes = 2.0,
gaze = MascotGaze(x = -9.0, y = 9.0),
mouthCurve = -9.0,
mouthOpen = 9.0,
mouthRound = -9.0,
blush = 9.0,
hardHat = -9.0,
bodyTilt = 90.0,
bodyStretch = 9.0,
effect = MascotEffect.Sweat,
effectPhase = 0.75,
).clamp()
assertEquals(2.0, pose.floatOffset, 0.0)
assertEquals(-14.0, pose.antennaDegrees, 0.0)
assertEquals(1.0, pose.antennaDroop, 0.0)
assertEquals(-45.0, pose.leftClawDegrees, 0.0)
assertEquals(45.0, pose.rightClawDegrees, 0.0)
assertEquals(0.0, pose.eyeGlowAlpha, 0.0)
assertEquals(1.6, pose.glowScale, 0.0)
assertEquals(0.0, pose.leftEyeOpenness, 0.0)
assertEquals(1.0, pose.rightEyeOpenness, 0.0)
assertEquals(1.0, pose.happyEyes, 0.0)
assertEquals(MascotGaze(x = -1.2, y = 1.2), pose.gaze)
assertEquals(-1.0, pose.mouthCurve, 0.0)
assertEquals(1.0, pose.mouthOpen, 0.0)
assertEquals(0.0, pose.mouthRound, 0.0)
assertEquals(1.0, pose.blush, 0.0)
assertEquals(0.0, pose.hardHat, 0.0)
assertEquals(8.0, pose.bodyTilt, 0.0)
assertEquals(1.05, pose.bodyStretch, 0.0)
assertEquals(MascotEffect.Sweat, pose.effect)
assertEquals(0.75, pose.effectPhase, 0.0)
}
private fun assertInBounds(
pose: MascotPose,
mood: MascotMood,
frame: Int,
) {
val location = "$mood frame $frame"
assertTrue(location, pose.floatOffset in -12.0..2.0)
assertTrue(location, pose.antennaDegrees in -14.0..14.0)
assertTrue(location, pose.antennaDroop in 0.0..1.0)
assertTrue(location, pose.leftClawDegrees in -45.0..45.0)
assertTrue(location, pose.rightClawDegrees in -45.0..45.0)
assertTrue(location, pose.eyeGlowAlpha in 0.0..1.0)
assertTrue(location, pose.glowScale in 0.5..1.6)
assertTrue(location, pose.leftEyeOpenness in 0.0..1.0)
assertTrue(location, pose.rightEyeOpenness in 0.0..1.0)
assertTrue(location, pose.happyEyes in 0.0..1.0)
assertTrue(location, pose.gaze.x in -1.2..1.2)
assertTrue(location, pose.gaze.y in -1.2..1.2)
assertTrue(location, pose.mouthCurve in -1.0..1.0)
assertTrue(location, pose.mouthOpen in 0.0..1.0)
assertTrue(location, pose.mouthRound in 0.0..1.0)
assertTrue(location, pose.blush in 0.0..1.0)
assertTrue(location, pose.hardHat in 0.0..1.0)
assertTrue(location, pose.bodyTilt in -8.0..8.0)
assertTrue(location, pose.bodyStretch in 0.86..1.05)
assertTrue(location, pose.effectPhase in 0.0..1.0)
}
}
class EffectiveMascotMoodTest {
@Test
fun `tinted mascots stay ambient regardless of requested mood`() {
for (mood in MascotMood.entries) {
assertEquals(MascotMood.Idle, effectiveMascotMood(mood = mood, tinted = true))
assertEquals(mood, effectiveMascotMood(mood = mood, tinted = false))
}
}
}