mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 13:16:09 +00:00
Aligns the shared OpenClawMascotView with the website hero styling: per-part bounding-box gradients (SVG objectBoundingBox semantics; the claws were nearly flat-colored before), antenna stroke width 2, eye glow r=2, float depth -8% of size, and a light-scheme palette from the site theme variables. The float now offsets the whole canvas like the site floats the hero container, so the antennae no longer clip at the float peak. macOS adopts the hero glow treatment: the About pane mascot gets the coral silhouette drop-shadow with the teal hover glow and 1.1 hover scale, and the onboarding hero replaces its hand-rolled breathing glow circle with the same silhouette glow.
259 lines
12 KiB
Swift
259 lines
12 KiB
Swift
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.
|
|
/// Styling (palette, glow colors, float depth) follows the openclaw.ai hero
|
|
/// (`src/pages/index.astro` + `Layout.astro` theme variables).
|
|
public struct OpenClawMascotView: View {
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
let palette = OpenClawMascotPalette.forScheme(self.colorScheme)
|
|
if self.reduceMotion {
|
|
OpenClawMascotCanvas(pose: .still, palette: palette)
|
|
} else {
|
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
|
let pose = OpenClawMascotPose.at(time: timeline.date.timeIntervalSinceReferenceDate)
|
|
// Float translates the whole canvas like the site floats the hero
|
|
// container; drawing the offset inside the canvas would clip the
|
|
// antennae (art starts at y~5 of 120) at the -9.6 float peak.
|
|
GeometryReader { proxy in
|
|
OpenClawMascotCanvas(pose: pose, palette: palette)
|
|
.offset(y: pose.floatOffset * min(proxy.size.width, proxy.size.height) / 120)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// openclaw.ai hero drop-shadow color (`--logo-glow` / `--logo-glow-hover`).
|
|
/// Pair with a shadow radius of ~10% of the mascot size (15% while hovering)
|
|
/// to match the site's `drop-shadow(0 0 20px)` on a 100px mark.
|
|
public static func heroGlowColor(for colorScheme: ColorScheme, hovering: Bool = false) -> Color {
|
|
switch (colorScheme, hovering) {
|
|
case (.light, false): Color(red: 239 / 255, green: 75 / 255, blue: 88 / 255).opacity(0.2)
|
|
case (.light, true): Color(red: 0, green: 143 / 255, blue: 135 / 255).opacity(0.35)
|
|
case (_, false): Color(red: 1, green: 77 / 255, blue: 77 / 255).opacity(0.4)
|
|
case (_, true): Color(red: 0, green: 229 / 255, blue: 204 / 255).opacity(0.6)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Body/antenna colors from the openclaw.ai theme variables: `:root` (dark)
|
|
/// and `html[data-theme='light']` in `Layout.astro`. Eye colors are fixed in
|
|
/// the site markup and shared by both themes.
|
|
struct OpenClawMascotPalette: Equatable {
|
|
let gradientTop: Color
|
|
let gradientBottom: Color
|
|
let antenna: Color
|
|
|
|
static let dark = OpenClawMascotPalette(
|
|
gradientTop: Color(red: 1, green: 77 / 255, blue: 77 / 255),
|
|
gradientBottom: Color(red: 153 / 255, green: 27 / 255, blue: 27 / 255),
|
|
antenna: Color(red: 1, green: 77 / 255, blue: 77 / 255))
|
|
|
|
static let light = OpenClawMascotPalette(
|
|
gradientTop: Color(red: 255 / 255, green: 112 / 255, blue: 121 / 255),
|
|
gradientBottom: Color(red: 234 / 255, green: 76 / 255, blue: 89 / 255),
|
|
antenna: Color(red: 239 / 255, green: 75 / 255, blue: 88 / 255))
|
|
|
|
static func forScheme(_ colorScheme: ColorScheme) -> OpenClawMascotPalette {
|
|
colorScheme == .light ? .light : .dark
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// Float depth matches the hero: -8px on a 100px mark = 8% of the 120 box.
|
|
OpenClawMascotPose(
|
|
floatOffset: -4.8 * (1 - cos(2 * .pi * self.cyclePhase(time, period: 4))),
|
|
antennaDegrees: -3 * sin(2 * .pi * self.cyclePhase(time, period: 2)),
|
|
leftClawDegrees: self.clawSnapDegrees(phase: self.cyclePhase(time, period: 4)),
|
|
rightClawDegrees: self.clawSnapDegrees(phase: self.cyclePhase(time - 0.2, period: 4)),
|
|
eyeGlowOpacity: self.blinkOpacity(phase: self.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 * self.easeInOut((phase - 0.85) / 0.05)
|
|
}
|
|
return -8 * (1 - self.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 ? self.easeInOut((phase - 0.9) / 0.05) : 1 - self.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
|
|
let palette: OpenClawMascotPalette
|
|
|
|
var body: some View {
|
|
Canvas { context, size in
|
|
Self.draw(context: &context, size: size, pose: self.pose, palette: self.palette)
|
|
}
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
// Geometry below is the favicon.svg path data in its native 120x120 space.
|
|
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,
|
|
palette: OpenClawMascotPalette)
|
|
{
|
|
let scale = min(size.width, size.height) / 120
|
|
context.scaleBy(x: scale, y: scale)
|
|
|
|
// Site antennae: stroke-width 2, `--coral-bright`.
|
|
let antennaStroke = StrokeStyle(lineWidth: 2, lineCap: .round)
|
|
|
|
// Same paint order as favicon.svg: body, claws, antennae, eyes.
|
|
context.fill(self.bodyPath, with: self.gradient(for: self.bodyPath, palette: palette))
|
|
self.drawRotated(context: context, degrees: pose.leftClawDegrees, pivot: self.leftClawPivot) {
|
|
$0.fill(self.leftClawPath, with: self.gradient(for: self.leftClawPath, palette: palette))
|
|
}
|
|
self.drawRotated(context: context, degrees: pose.rightClawDegrees, pivot: self.rightClawPivot) {
|
|
$0.fill(self.rightClawPath, with: self.gradient(for: self.rightClawPath, palette: palette))
|
|
}
|
|
self.drawRotated(context: context, degrees: pose.antennaDegrees, pivot: self.leftAntennaPivot) {
|
|
$0.stroke(self.leftAntennaPath, with: .color(palette.antenna), style: antennaStroke)
|
|
}
|
|
self.drawRotated(context: context, degrees: pose.antennaDegrees, pivot: self.rightAntennaPivot) {
|
|
$0.stroke(self.rightAntennaPath, with: .color(palette.antenna), style: antennaStroke)
|
|
}
|
|
|
|
context.fill(Path(ellipseIn: CGRect(x: 39, y: 29, width: 12, height: 12)), with: .color(self.eyeColor))
|
|
context.fill(Path(ellipseIn: CGRect(x: 69, y: 29, width: 12, height: 12)), with: .color(self.eyeColor))
|
|
var glowContext = context
|
|
glowContext.opacity = pose.eyeGlowOpacity
|
|
glowContext.fill(
|
|
Path(ellipseIn: CGRect(x: 44, y: 32, width: 4, height: 4)),
|
|
with: .color(self.eyeGlowColor))
|
|
glowContext.fill(
|
|
Path(ellipseIn: CGRect(x: 74, y: 32, width: 4, height: 4)),
|
|
with: .color(self.eyeGlowColor))
|
|
}
|
|
|
|
/// SVG gradients default to objectBoundingBox units, so the body and each
|
|
/// claw span the full top-left -> bottom-right ramp across their own bounds;
|
|
/// one canvas-wide gradient would leave the claws nearly flat-colored.
|
|
private static func gradient(
|
|
for path: Path,
|
|
palette: OpenClawMascotPalette) -> GraphicsContext.Shading
|
|
{
|
|
let box = path.boundingRect
|
|
return .linearGradient(
|
|
Gradient(colors: [palette.gradientTop, palette.gradientBottom]),
|
|
startPoint: box.origin,
|
|
endPoint: CGPoint(x: box.maxX, y: box.maxY))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|