mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat: animate the OpenClaw mascot across web, iOS, and Android
Ports the openclaw.ai hero mascot animation (body float 4s, eye blink 3s, antenna wiggle 2s, staggered claw snaps 4s) to every product surface that shows the logo: - web: ui/public/favicon.svg now carries SMIL animations, so every <img> usage (login gate, sidebar brand, agent avatar fallback) animates with no markup changes; CSS animations do not run in <img>-loaded SVGs. - iOS: new OpenClawMascotView renders the canonical 120x120 vector via Canvas + TimelineView (30fps, honors Reduce Motion); OpenClawProMark uses it in onboarding, sidebar, and command center. Static PNG imageset deleted (watch app keeps its own copy). - Android: new OpenClawMascot composable (PathParser + infinite transitions, honors the OS animator scale) replaces the static vector drawable in onboarding and the chat/shell/voice headers, with tint support for the monochrome header silhouettes. Drawable deleted.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
The OpenClaw mascot now comes alive across onboarding and the app headers with the same float, blink, antenna-wiggle, and claw-snap animation as openclaw.ai.
|
||||
|
||||
## 2026.6.11 - 2026-07-01
|
||||
|
||||
Improves Android gateway setup with localized onboarding, QR pairing fixes, and support for local mDNS gateway hosts.
|
||||
|
||||
@@ -17,6 +17,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.OpenClawMascot
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
@@ -46,7 +47,6 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -118,7 +118,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -891,7 +890,7 @@ private fun WelcomeLogo() {
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
|
||||
Image(painter = painterResource(id = R.drawable.openclaw_logo), contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize())
|
||||
OpenClawMascot(contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
@@ -98,7 +99,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -506,12 +506,7 @@ private fun OverviewHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
OpenClawMascot(modifier = Modifier.size(25.dp), tint = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
|
||||
@@ -2,7 +2,6 @@ package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayTalkSetupReadiness
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.gatewayTalkSetupDescription
|
||||
import ai.openclaw.app.isReady
|
||||
@@ -14,6 +13,7 @@ import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import ai.openclaw.app.voice.VoiceConversationRole
|
||||
import android.Manifest
|
||||
@@ -74,7 +74,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -568,12 +567,7 @@ private fun VoiceHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
OpenClawMascot(modifier = Modifier.size(25.dp), tint = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.chat.ChatCommandEntry
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
@@ -17,6 +16,7 @@ import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint
|
||||
import ai.openclaw.app.ui.gatewayStatusForDisplay
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -73,7 +73,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -399,12 +398,7 @@ private fun ChatHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
OpenClawMascot(modifier = Modifier.size(25.dp), tint = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.withTransform
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
|
||||
// Canonical 120x120 mascot geometry from ui/public/favicon.svg; parts stay
|
||||
// separate paths so claws, antennae, and eyes can animate independently.
|
||||
private val BodyPath =
|
||||
PathParser()
|
||||
.parsePathString(
|
||||
"M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 " +
|
||||
"C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z",
|
||||
)
|
||||
.toPath()
|
||||
private val LeftClawPath =
|
||||
PathParser().parsePathString("M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z").toPath()
|
||||
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 CoralBright = Color(0xFFFF4D4D)
|
||||
private val CoralDark = Color(0xFF991B1B)
|
||||
private val EyeDark = Color(0xFF050810)
|
||||
private val EyeGlow = Color(0xFF00E5CC)
|
||||
// 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 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).
|
||||
*/
|
||||
@Composable
|
||||
fun OpenClawMascot(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color? = null,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
val pose = rememberMascotPose()
|
||||
val semantics =
|
||||
if (contentDescription == null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.semantics {
|
||||
this.contentDescription = contentDescription
|
||||
role = Role.Image
|
||||
}
|
||||
}
|
||||
Canvas(modifier = modifier.then(semantics)) {
|
||||
val scale = size.minDimension / 120f
|
||||
withTransform({
|
||||
scale(scale, scale, pivot = Offset.Zero)
|
||||
translate(top = pose.floatOffset.value)
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="120"
|
||||
android:viewportHeight="120">
|
||||
<path android:pathData="M60,10 C30,10 15,35 15,55 C15,75 30,95 45,100 L45,110 L55,110 L55,100 C55,100 60,102 65,100 L65,110 L75,110 L75,100 C90,95 105,75 105,55 C105,35 90,10 60,10Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M20,45 C5,40 0,50 5,60 C10,70 20,65 25,55 C28,48 25,45 20,45Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M100,45 C115,40 120,50 115,60 C110,70 100,65 95,55 C92,48 95,45 100,45Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M45,15 Q35,5 30,8"
|
||||
android:strokeColor="#ff4d4d"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3" />
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M75,15 Q85,5 90,8"
|
||||
android:strokeColor="#ff4d4d"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3" />
|
||||
<path
|
||||
android:fillColor="#050810"
|
||||
android:pathData="M45,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
|
||||
<path
|
||||
android:fillColor="#050810"
|
||||
android:pathData="M75,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
|
||||
<path
|
||||
android:fillColor="#00e5cc"
|
||||
android:pathData="M46,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
|
||||
<path
|
||||
android:fillColor="#00e5cc"
|
||||
android:pathData="M76,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
|
||||
</vector>
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Animated the OpenClaw mascot (float, blink, antenna wiggle, claw snaps) across onboarding, sidebar, and command center, matching openclaw.ai.
|
||||
|
||||
## 2026.6.11 - 2026-07-01
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "openclaw-icon.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Animated OpenClaw mascot. Redraws the canonical 120x120 vector from
|
||||
/// `ui/public/favicon.svg` so individual parts (claws, antennae, eyes) can
|
||||
/// animate like the openclaw.ai hero mark; the bundled PNG asset cannot.
|
||||
struct OpenClawMascotView: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
if self.reduceMotion {
|
||||
OpenClawMascotCanvas(pose: .still)
|
||||
} else {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||
OpenClawMascotCanvas(pose: .at(time: timeline.date.timeIntervalSinceReferenceDate))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Part transforms for one animation frame. Mirrors the openclaw.ai CSS
|
||||
/// keyframes: float 4s, antenna wiggle 2s, eye blink 3s, claw snap 4s with
|
||||
/// the right claw trailing by 0.2s.
|
||||
struct OpenClawMascotPose: Equatable {
|
||||
var floatOffset: CGFloat = 0
|
||||
var antennaDegrees: CGFloat = 0
|
||||
var leftClawDegrees: CGFloat = 0
|
||||
var rightClawDegrees: CGFloat = 0
|
||||
var eyeGlowOpacity: CGFloat = 1
|
||||
|
||||
static let still = OpenClawMascotPose()
|
||||
|
||||
static func at(time: TimeInterval) -> OpenClawMascotPose {
|
||||
OpenClawMascotPose(
|
||||
floatOffset: -2.5 * (1 - cos(2 * .pi * cyclePhase(time, period: 4))),
|
||||
antennaDegrees: -3 * sin(2 * .pi * cyclePhase(time, period: 2)),
|
||||
leftClawDegrees: clawSnapDegrees(phase: cyclePhase(time, period: 4)),
|
||||
rightClawDegrees: clawSnapDegrees(phase: cyclePhase(time - 0.2, period: 4)),
|
||||
eyeGlowOpacity: blinkOpacity(phase: cyclePhase(time, period: 3)))
|
||||
}
|
||||
|
||||
private static func cyclePhase(_ time: TimeInterval, period: TimeInterval) -> CGFloat {
|
||||
let normalized = (time / period).truncatingRemainder(dividingBy: 1)
|
||||
return CGFloat(normalized < 0 ? normalized + 1 : normalized)
|
||||
}
|
||||
|
||||
private static func clawSnapDegrees(phase: CGFloat) -> CGFloat {
|
||||
// 0deg until 85%, snap to -8deg at 90%, back to 0deg at 95%, hold.
|
||||
if phase < 0.85 || phase >= 0.95 {
|
||||
return 0
|
||||
}
|
||||
if phase < 0.9 {
|
||||
return -8 * easeInOut((phase - 0.85) / 0.05)
|
||||
}
|
||||
return -8 * (1 - easeInOut((phase - 0.9) / 0.05))
|
||||
}
|
||||
|
||||
private static func blinkOpacity(phase: CGFloat) -> CGFloat {
|
||||
// Full glow until 90%, dip to 0.3 at 95%, recover by 100%.
|
||||
if phase < 0.9 {
|
||||
return 1
|
||||
}
|
||||
let dip = phase < 0.95 ? easeInOut((phase - 0.9) / 0.05) : 1 - easeInOut((phase - 0.95) / 0.05)
|
||||
return 1 - 0.7 * dip
|
||||
}
|
||||
|
||||
private static func easeInOut(_ t: CGFloat) -> CGFloat {
|
||||
let clamped = min(max(t, 0), 1)
|
||||
return clamped * clamped * (3 - 2 * clamped)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenClawMascotCanvas: View {
|
||||
let pose: OpenClawMascotPose
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
Self.draw(context: &context, size: size, pose: self.pose)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
// Geometry below is the favicon.svg path data in its native 120x120 space.
|
||||
private static let bodyColorTop = Color(red: 255 / 255, green: 77 / 255, blue: 77 / 255)
|
||||
private static let bodyColorBottom = Color(red: 153 / 255, green: 27 / 255, blue: 27 / 255)
|
||||
private static let eyeColor = Color(red: 5 / 255, green: 8 / 255, blue: 16 / 255)
|
||||
private static let eyeGlowColor = Color(red: 0, green: 229 / 255, blue: 204 / 255)
|
||||
// Rotation pivots: claws hinge on their body-facing edge, antennae on their own center.
|
||||
private static let leftClawPivot = CGPoint(x: 26, y: 53)
|
||||
private static let rightClawPivot = CGPoint(x: 94, y: 53)
|
||||
private static let leftAntennaPivot = CGPoint(x: 37.5, y: 11)
|
||||
private static let rightAntennaPivot = CGPoint(x: 82.5, y: 11)
|
||||
|
||||
private static let bodyPath: Path = {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 60, y: 10))
|
||||
path.addCurve(to: CGPoint(x: 15, y: 55), control1: CGPoint(x: 30, y: 10), control2: CGPoint(x: 15, y: 35))
|
||||
path.addCurve(to: CGPoint(x: 45, y: 100), control1: CGPoint(x: 15, y: 75), control2: CGPoint(x: 30, y: 95))
|
||||
path.addLine(to: CGPoint(x: 45, y: 110))
|
||||
path.addLine(to: CGPoint(x: 55, y: 110))
|
||||
path.addLine(to: CGPoint(x: 55, y: 100))
|
||||
path.addCurve(to: CGPoint(x: 65, y: 100), control1: CGPoint(x: 55, y: 100), control2: CGPoint(x: 60, y: 102))
|
||||
path.addLine(to: CGPoint(x: 65, y: 110))
|
||||
path.addLine(to: CGPoint(x: 75, y: 110))
|
||||
path.addLine(to: CGPoint(x: 75, y: 100))
|
||||
path.addCurve(to: CGPoint(x: 105, y: 55), control1: CGPoint(x: 90, y: 95), control2: CGPoint(x: 105, y: 75))
|
||||
path.addCurve(to: CGPoint(x: 60, y: 10), control1: CGPoint(x: 105, y: 35), control2: CGPoint(x: 90, y: 10))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}()
|
||||
|
||||
private static let leftClawPath: Path = {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 20, y: 45))
|
||||
path.addCurve(to: CGPoint(x: 5, y: 60), control1: CGPoint(x: 5, y: 40), control2: CGPoint(x: 0, y: 50))
|
||||
path.addCurve(to: CGPoint(x: 25, y: 55), control1: CGPoint(x: 10, y: 70), control2: CGPoint(x: 20, y: 65))
|
||||
path.addCurve(to: CGPoint(x: 20, y: 45), control1: CGPoint(x: 28, y: 48), control2: CGPoint(x: 25, y: 45))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}()
|
||||
|
||||
private static let rightClawPath: Path = {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 100, y: 45))
|
||||
path.addCurve(to: CGPoint(x: 115, y: 60), control1: CGPoint(x: 115, y: 40), control2: CGPoint(x: 120, y: 50))
|
||||
path.addCurve(to: CGPoint(x: 95, y: 55), control1: CGPoint(x: 110, y: 70), control2: CGPoint(x: 100, y: 65))
|
||||
path.addCurve(to: CGPoint(x: 100, y: 45), control1: CGPoint(x: 92, y: 48), control2: CGPoint(x: 95, y: 45))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}()
|
||||
|
||||
private static let leftAntennaPath: Path = {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 45, y: 15))
|
||||
path.addQuadCurve(to: CGPoint(x: 30, y: 8), control: CGPoint(x: 35, y: 5))
|
||||
return path
|
||||
}()
|
||||
|
||||
private static let rightAntennaPath: Path = {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 75, y: 15))
|
||||
path.addQuadCurve(to: CGPoint(x: 90, y: 8), control: CGPoint(x: 85, y: 5))
|
||||
return path
|
||||
}()
|
||||
|
||||
private static func draw(context: inout GraphicsContext, size: CGSize, pose: OpenClawMascotPose) {
|
||||
let scale = min(size.width, size.height) / 120
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
context.translateBy(x: 0, y: pose.floatOffset)
|
||||
|
||||
let bodyShading = GraphicsContext.Shading.linearGradient(
|
||||
Gradient(colors: [bodyColorTop, bodyColorBottom]),
|
||||
startPoint: .zero,
|
||||
endPoint: CGPoint(x: 120, y: 120))
|
||||
let antennaStroke = StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||
|
||||
// Same paint order as favicon.svg: body, claws, antennae, eyes.
|
||||
context.fill(bodyPath, with: bodyShading)
|
||||
drawRotated(context: context, degrees: pose.leftClawDegrees, pivot: leftClawPivot) {
|
||||
$0.fill(leftClawPath, with: bodyShading)
|
||||
}
|
||||
drawRotated(context: context, degrees: pose.rightClawDegrees, pivot: rightClawPivot) {
|
||||
$0.fill(rightClawPath, with: bodyShading)
|
||||
}
|
||||
drawRotated(context: context, degrees: pose.antennaDegrees, pivot: leftAntennaPivot) {
|
||||
$0.stroke(leftAntennaPath, with: .color(bodyColorTop), style: antennaStroke)
|
||||
}
|
||||
drawRotated(context: context, degrees: pose.antennaDegrees, pivot: rightAntennaPivot) {
|
||||
$0.stroke(rightAntennaPath, with: .color(bodyColorTop), style: antennaStroke)
|
||||
}
|
||||
|
||||
context.fill(Path(ellipseIn: CGRect(x: 39, y: 29, width: 12, height: 12)), with: .color(eyeColor))
|
||||
context.fill(Path(ellipseIn: CGRect(x: 69, y: 29, width: 12, height: 12)), with: .color(eyeColor))
|
||||
var glowContext = context
|
||||
glowContext.opacity = pose.eyeGlowOpacity
|
||||
glowContext.fill(Path(ellipseIn: CGRect(x: 43.5, y: 31.5, width: 5, height: 5)), with: .color(eyeGlowColor))
|
||||
glowContext.fill(Path(ellipseIn: CGRect(x: 73.5, y: 31.5, width: 5, height: 5)), with: .color(eyeGlowColor))
|
||||
}
|
||||
|
||||
private static func drawRotated(
|
||||
context: GraphicsContext,
|
||||
degrees: CGFloat,
|
||||
pivot: CGPoint,
|
||||
draw: (inout GraphicsContext) -> Void
|
||||
) {
|
||||
var rotated = context
|
||||
rotated.translateBy(x: pivot.x, y: pivot.y)
|
||||
rotated.rotate(by: .degrees(degrees))
|
||||
rotated.translateBy(x: -pivot.x, y: -pivot.y)
|
||||
draw(&rotated)
|
||||
}
|
||||
}
|
||||
@@ -650,9 +650,7 @@ struct OpenClawProMark: View {
|
||||
var shadowRadius: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
Image("OpenClawIcon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
OpenClawMascotView()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
|
||||
+46
-14
@@ -5,18 +5,50 @@
|
||||
<stop offset="100%" stop-color="#991b1b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Body -->
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Left Claw -->
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Right Claw -->
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Antenna -->
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<!-- SMIL, not CSS: Chromium/Safari only run declarative SMIL inside <img>-loaded
|
||||
SVGs, and every app surface loads this file via <img src>. Mirrors the
|
||||
openclaw.ai hero mascot keyframes (float 4s, wiggle 2s, blink 3s, snap 4s). -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" additive="sum"
|
||||
values="0 0; 0 -5; 0 0" keyTimes="0; 0.5; 1" dur="4s" repeatCount="indefinite"
|
||||
calcMode="spline" keySplines="0.42 0 0.58 1; 0.42 0 0.58 1"/>
|
||||
<!-- Body -->
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Left Claw (hinges on its body-facing edge) -->
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)">
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
values="0 26 53; 0 26 53; -8 26 53; 0 26 53; 0 26 53" keyTimes="0; 0.85; 0.9; 0.95; 1"
|
||||
dur="4s" repeatCount="indefinite"
|
||||
calcMode="spline" keySplines="0.42 0 0.58 1; 0.42 0 0.58 1; 0.42 0 0.58 1; 0.42 0 0.58 1"/>
|
||||
</path>
|
||||
<!-- Right Claw (snaps 0.2s after the left) -->
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)">
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
values="0 94 53; 0 94 53; -8 94 53; 0 94 53; 0 94 53" keyTimes="0; 0.85; 0.9; 0.95; 1"
|
||||
dur="4s" begin="0.2s" repeatCount="indefinite"
|
||||
calcMode="spline" keySplines="0.42 0 0.58 1; 0.42 0 0.58 1; 0.42 0 0.58 1; 0.42 0 0.58 1"/>
|
||||
</path>
|
||||
<!-- Antenna -->
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
values="0 37.5 11; -3 37.5 11; 3 37.5 11; 0 37.5 11" keyTimes="0; 0.25; 0.75; 1"
|
||||
dur="2s" repeatCount="indefinite"
|
||||
calcMode="spline" keySplines="0.42 0 0.58 1; 0.42 0 0.58 1; 0.42 0 0.58 1"/>
|
||||
</path>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
values="0 82.5 11; -3 82.5 11; 3 82.5 11; 0 82.5 11" keyTimes="0; 0.25; 0.75; 1"
|
||||
dur="2s" repeatCount="indefinite"
|
||||
calcMode="spline" keySplines="0.42 0 0.58 1; 0.42 0 0.58 1; 0.42 0 0.58 1"/>
|
||||
</path>
|
||||
<!-- Eyes -->
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc">
|
||||
<animate attributeName="opacity" values="1; 1; 0.3; 1" keyTimes="0; 0.9; 0.95; 1" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc">
|
||||
<animate attributeName="opacity" values="1; 1; 0.3; 1" keyTimes="0; 0.9; 0.95; 1" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.2 KiB |
Reference in New Issue
Block a user