mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 22:16:13 +00:00
feat(apps): match the mascot design to the openclaw.ai hero
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.
This commit is contained in:
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
|
||||
struct AboutSettings: View {
|
||||
weak var updater: UpdaterProviding?
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var iconHover = false
|
||||
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
|
||||
@State private var didLoadUpdaterState = false
|
||||
@@ -14,10 +15,16 @@ struct AboutSettings: View {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
// Hero treatment from openclaw.ai: coral silhouette glow at 10% of
|
||||
// size, teal glow at 15% plus scale 1.1 on hover.
|
||||
OpenClawMascotView()
|
||||
.frame(width: 160, height: 160)
|
||||
.shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 10)
|
||||
.scaleEffect(self.iconHover ? 1.05 : 1.0)
|
||||
.shadow(
|
||||
color: OpenClawMascotView.heroGlowColor(
|
||||
for: self.colorScheme,
|
||||
hovering: self.iconHover),
|
||||
radius: self.iconHover ? 24 : 16)
|
||||
.scaleEffect(self.iconHover ? 1.1 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("OpenClaw on GitHub")
|
||||
|
||||
@@ -4,7 +4,7 @@ import SwiftUI
|
||||
extension OnboardingView {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
GlowingOpenClawIcon(size: 130, glowIntensity: 0.28)
|
||||
GlowingOpenClawIcon(size: 130)
|
||||
.offset(y: 10)
|
||||
.frame(height: 145)
|
||||
|
||||
|
||||
@@ -1,64 +1,22 @@
|
||||
import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
/// Onboarding hero mascot with the openclaw.ai hero treatment: the animated
|
||||
/// mascot plus its coral silhouette glow (drop-shadow at ~10% of size).
|
||||
struct GlowingOpenClawIcon: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let size: CGFloat
|
||||
let glowIntensity: Double
|
||||
let enableFloating: Bool
|
||||
|
||||
@State private var breathe = false
|
||||
|
||||
init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) {
|
||||
init(size: CGFloat = 148) {
|
||||
self.size = size
|
||||
self.glowIntensity = glowIntensity
|
||||
self.enableFloating = enableFloating
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let glowBlurRadius: CGFloat = 18
|
||||
let glowCanvasSize: CGFloat = self.size + 56
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(self.glowIntensity),
|
||||
Color.blue.opacity(self.glowIntensity * 0.6),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.frame(width: glowCanvasSize, height: glowCanvasSize)
|
||||
.padding(glowBlurRadius)
|
||||
.blur(radius: glowBlurRadius)
|
||||
.scaleEffect(self.breathe ? 1.08 : 0.96)
|
||||
.opacity(0.84)
|
||||
|
||||
// Mascot animates itself (and goes still under reduce motion), so no breathe scale here.
|
||||
OpenClawMascotView()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
|
||||
}
|
||||
.frame(
|
||||
width: glowCanvasSize + (glowBlurRadius * 2),
|
||||
height: glowCanvasSize + (glowBlurRadius * 2))
|
||||
.onAppear { self.updateBreatheAnimation() }
|
||||
.onDisappear { self.breathe = false }
|
||||
.onChange(of: self.scenePhase) { _, _ in
|
||||
self.updateBreatheAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBreatheAnimation() {
|
||||
guard self.enableFloating, !self.reduceMotion, self.scenePhase == .active else {
|
||||
self.breathe = false
|
||||
return
|
||||
}
|
||||
guard !self.breathe else { return }
|
||||
withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) {
|
||||
self.breathe = true
|
||||
}
|
||||
OpenClawMascotView()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(
|
||||
color: OpenClawMascotView.heroGlowColor(for: self.colorScheme),
|
||||
radius: self.size * 0.1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,66 @@ 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)
|
||||
OpenClawMascotCanvas(pose: .still, palette: palette)
|
||||
} else {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
|
||||
OpenClawMascotCanvas(pose: .at(time: timeline.date.timeIntervalSinceReferenceDate))
|
||||
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
|
||||
@@ -32,8 +78,9 @@ struct OpenClawMascotPose: Equatable {
|
||||
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: -2.5 * (1 - cos(2 * .pi * self.cyclePhase(time, period: 4))),
|
||||
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)),
|
||||
@@ -73,17 +120,16 @@ struct OpenClawMascotPose: Equatable {
|
||||
|
||||
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)
|
||||
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 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.
|
||||
@@ -144,30 +190,31 @@ private struct OpenClawMascotCanvas: View {
|
||||
return path
|
||||
}()
|
||||
|
||||
private static func draw(context: inout GraphicsContext, size: CGSize, pose: OpenClawMascotPose) {
|
||||
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)
|
||||
context.translateBy(x: 0, y: pose.floatOffset)
|
||||
|
||||
let bodyShading = GraphicsContext.Shading.linearGradient(
|
||||
Gradient(colors: [self.bodyColorTop, self.bodyColorBottom]),
|
||||
startPoint: .zero,
|
||||
endPoint: CGPoint(x: 120, y: 120))
|
||||
let antennaStroke = StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||
// 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: bodyShading)
|
||||
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: bodyShading)
|
||||
$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: bodyShading)
|
||||
$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(self.bodyColorTop), style: antennaStroke)
|
||||
$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(self.bodyColorTop), style: antennaStroke)
|
||||
$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))
|
||||
@@ -175,13 +222,27 @@ private struct OpenClawMascotCanvas: View {
|
||||
var glowContext = context
|
||||
glowContext.opacity = pose.eyeGlowOpacity
|
||||
glowContext.fill(
|
||||
Path(ellipseIn: CGRect(x: 43.5, y: 31.5, width: 5, height: 5)),
|
||||
Path(ellipseIn: CGRect(x: 44, y: 32, width: 4, height: 4)),
|
||||
with: .color(self.eyeGlowColor))
|
||||
glowContext.fill(
|
||||
Path(ellipseIn: CGRect(x: 73.5, y: 31.5, width: 5, height: 5)),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user