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:
Peter Steinberger
2026-07-04 20:14:34 -07:00
parent fcd7eb6130
commit 851156af3b
13 changed files with 478 additions and 122 deletions
+2
View File
@@ -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>
+4
View File
@@ -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
View File
@@ -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