@@ -3,20 +3,66 @@ import SwiftUI
33/// Animated OpenClaw mascot. Redraws the canonical 120x120 vector from
44/// `ui/public/favicon.svg` so individual parts (claws, antennae, eyes) can
55/// animate like the openclaw.ai hero mark; the bundled PNG asset cannot.
6+ /// Styling (palette, glow colors, float depth) follows the openclaw.ai hero
7+ /// (`src/pages/index.astro` + `Layout.astro` theme variables).
68public struct OpenClawMascotView : View {
79 @Environment ( \. accessibilityReduceMotion) private var reduceMotion
10+ @Environment ( \. colorScheme) private var colorScheme
811
912 public init ( ) { }
1013
1114 public var body : some View {
15+ let palette = OpenClawMascotPalette . forScheme ( self . colorScheme)
1216 if self . reduceMotion {
13- OpenClawMascotCanvas ( pose: . still)
17+ OpenClawMascotCanvas ( pose: . still, palette : palette )
1418 } else {
1519 TimelineView ( . animation( minimumInterval: 1.0 / 30.0 ) ) { timeline in
16- OpenClawMascotCanvas ( pose: . at( time: timeline. date. timeIntervalSinceReferenceDate) )
20+ let pose = OpenClawMascotPose . at ( time: timeline. date. timeIntervalSinceReferenceDate)
21+ // Float translates the whole canvas like the site floats the hero
22+ // container; drawing the offset inside the canvas would clip the
23+ // antennae (art starts at y~5 of 120) at the -9.6 float peak.
24+ GeometryReader { proxy in
25+ OpenClawMascotCanvas ( pose: pose, palette: palette)
26+ . offset ( y: pose. floatOffset * min( proxy. size. width, proxy. size. height) / 120 )
27+ }
1728 }
1829 }
1930 }
31+
32+ /// openclaw.ai hero drop-shadow color (`--logo-glow` / `--logo-glow-hover`).
33+ /// Pair with a shadow radius of ~10% of the mascot size (15% while hovering)
34+ /// to match the site's `drop-shadow(0 0 20px)` on a 100px mark.
35+ public static func heroGlowColor( for colorScheme: ColorScheme , hovering: Bool = false ) -> Color {
36+ switch ( colorScheme, hovering) {
37+ case ( . light, false ) : Color ( red: 239 / 255 , green: 75 / 255 , blue: 88 / 255 ) . opacity ( 0.2 )
38+ case ( . light, true ) : Color ( red: 0 , green: 143 / 255 , blue: 135 / 255 ) . opacity ( 0.35 )
39+ case ( _, false ) : Color ( red: 1 , green: 77 / 255 , blue: 77 / 255 ) . opacity ( 0.4 )
40+ case ( _, true ) : Color ( red: 0 , green: 229 / 255 , blue: 204 / 255 ) . opacity ( 0.6 )
41+ }
42+ }
43+ }
44+
45+ /// Body/antenna colors from the openclaw.ai theme variables: `:root` (dark)
46+ /// and `html[data-theme='light']` in `Layout.astro`. Eye colors are fixed in
47+ /// the site markup and shared by both themes.
48+ struct OpenClawMascotPalette : Equatable {
49+ let gradientTop : Color
50+ let gradientBottom : Color
51+ let antenna : Color
52+
53+ static let dark = OpenClawMascotPalette (
54+ gradientTop: Color ( red: 1 , green: 77 / 255 , blue: 77 / 255 ) ,
55+ gradientBottom: Color ( red: 153 / 255 , green: 27 / 255 , blue: 27 / 255 ) ,
56+ antenna: Color ( red: 1 , green: 77 / 255 , blue: 77 / 255 ) )
57+
58+ static let light = OpenClawMascotPalette (
59+ gradientTop: Color ( red: 255 / 255 , green: 112 / 255 , blue: 121 / 255 ) ,
60+ gradientBottom: Color ( red: 234 / 255 , green: 76 / 255 , blue: 89 / 255 ) ,
61+ antenna: Color ( red: 239 / 255 , green: 75 / 255 , blue: 88 / 255 ) )
62+
63+ static func forScheme( _ colorScheme: ColorScheme ) -> OpenClawMascotPalette {
64+ colorScheme == . light ? . light : . dark
65+ }
2066}
2167
2268/// Part transforms for one animation frame. Mirrors the openclaw.ai CSS
@@ -32,8 +78,9 @@ struct OpenClawMascotPose: Equatable {
3278 static let still = OpenClawMascotPose ( )
3379
3480 static func at( time: TimeInterval ) -> OpenClawMascotPose {
81+ // Float depth matches the hero: -8px on a 100px mark = 8% of the 120 box.
3582 OpenClawMascotPose (
36- floatOffset: - 2.5 * ( 1 - cos( 2 * . pi * self . cyclePhase ( time, period: 4 ) ) ) ,
83+ floatOffset: - 4.8 * ( 1 - cos( 2 * . pi * self . cyclePhase ( time, period: 4 ) ) ) ,
3784 antennaDegrees: - 3 * sin( 2 * . pi * self . cyclePhase ( time, period: 2 ) ) ,
3885 leftClawDegrees: self . clawSnapDegrees ( phase: self . cyclePhase ( time, period: 4 ) ) ,
3986 rightClawDegrees: self . clawSnapDegrees ( phase: self . cyclePhase ( time - 0.2 , period: 4 ) ) ,
@@ -73,17 +120,16 @@ struct OpenClawMascotPose: Equatable {
73120
74121private struct OpenClawMascotCanvas : View {
75122 let pose : OpenClawMascotPose
123+ let palette : OpenClawMascotPalette
76124
77125 var body : some View {
78126 Canvas { context, size in
79- Self . draw ( context: & context, size: size, pose: self . pose)
127+ Self . draw ( context: & context, size: size, pose: self . pose, palette : self . palette )
80128 }
81129 . accessibilityHidden ( true )
82130 }
83131
84132 // Geometry below is the favicon.svg path data in its native 120x120 space.
85- private static let bodyColorTop = Color ( red: 255 / 255 , green: 77 / 255 , blue: 77 / 255 )
86- private static let bodyColorBottom = Color ( red: 153 / 255 , green: 27 / 255 , blue: 27 / 255 )
87133 private static let eyeColor = Color ( red: 5 / 255 , green: 8 / 255 , blue: 16 / 255 )
88134 private static let eyeGlowColor = Color ( red: 0 , green: 229 / 255 , blue: 204 / 255 )
89135 // Rotation pivots: claws hinge on their body-facing edge, antennae on their own center.
@@ -144,44 +190,59 @@ private struct OpenClawMascotCanvas: View {
144190 return path
145191 } ( )
146192
147- private static func draw( context: inout GraphicsContext , size: CGSize , pose: OpenClawMascotPose ) {
193+ private static func draw(
194+ context: inout GraphicsContext ,
195+ size: CGSize ,
196+ pose: OpenClawMascotPose ,
197+ palette: OpenClawMascotPalette )
198+ {
148199 let scale = min ( size. width, size. height) / 120
149200 context. scaleBy ( x: scale, y: scale)
150- context. translateBy ( x: 0 , y: pose. floatOffset)
151201
152- let bodyShading = GraphicsContext . Shading. linearGradient (
153- Gradient ( colors: [ self . bodyColorTop, self . bodyColorBottom] ) ,
154- startPoint: . zero,
155- endPoint: CGPoint ( x: 120 , y: 120 ) )
156- let antennaStroke = StrokeStyle ( lineWidth: 3 , lineCap: . round)
202+ // Site antennae: stroke-width 2, `--coral-bright`.
203+ let antennaStroke = StrokeStyle ( lineWidth: 2 , lineCap: . round)
157204
158205 // Same paint order as favicon.svg: body, claws, antennae, eyes.
159- context. fill ( self . bodyPath, with: bodyShading )
206+ context. fill ( self . bodyPath, with: self . gradient ( for : self . bodyPath , palette : palette ) )
160207 self . drawRotated ( context: context, degrees: pose. leftClawDegrees, pivot: self . leftClawPivot) {
161- $0. fill ( self . leftClawPath, with: bodyShading )
208+ $0. fill ( self . leftClawPath, with: self . gradient ( for : self . leftClawPath , palette : palette ) )
162209 }
163210 self . drawRotated ( context: context, degrees: pose. rightClawDegrees, pivot: self . rightClawPivot) {
164- $0. fill ( self . rightClawPath, with: bodyShading )
211+ $0. fill ( self . rightClawPath, with: self . gradient ( for : self . rightClawPath , palette : palette ) )
165212 }
166213 self . drawRotated ( context: context, degrees: pose. antennaDegrees, pivot: self . leftAntennaPivot) {
167- $0. stroke ( self . leftAntennaPath, with: . color( self . bodyColorTop ) , style: antennaStroke)
214+ $0. stroke ( self . leftAntennaPath, with: . color( palette . antenna ) , style: antennaStroke)
168215 }
169216 self . drawRotated ( context: context, degrees: pose. antennaDegrees, pivot: self . rightAntennaPivot) {
170- $0. stroke ( self . rightAntennaPath, with: . color( self . bodyColorTop ) , style: antennaStroke)
217+ $0. stroke ( self . rightAntennaPath, with: . color( palette . antenna ) , style: antennaStroke)
171218 }
172219
173220 context. fill ( Path ( ellipseIn: CGRect ( x: 39 , y: 29 , width: 12 , height: 12 ) ) , with: . color( self . eyeColor) )
174221 context. fill ( Path ( ellipseIn: CGRect ( x: 69 , y: 29 , width: 12 , height: 12 ) ) , with: . color( self . eyeColor) )
175222 var glowContext = context
176223 glowContext. opacity = pose. eyeGlowOpacity
177224 glowContext. fill (
178- Path ( ellipseIn: CGRect ( x: 43.5 , y: 31.5 , width: 5 , height: 5 ) ) ,
225+ Path ( ellipseIn: CGRect ( x: 44 , y: 32 , width: 4 , height: 4 ) ) ,
179226 with: . color( self . eyeGlowColor) )
180227 glowContext. fill (
181- Path ( ellipseIn: CGRect ( x: 73.5 , y: 31.5 , width: 5 , height: 5 ) ) ,
228+ Path ( ellipseIn: CGRect ( x: 74 , y: 32 , width: 4 , height: 4 ) ) ,
182229 with: . color( self . eyeGlowColor) )
183230 }
184231
232+ /// SVG gradients default to objectBoundingBox units, so the body and each
233+ /// claw span the full top-left -> bottom-right ramp across their own bounds;
234+ /// one canvas-wide gradient would leave the claws nearly flat-colored.
235+ private static func gradient(
236+ for path: Path ,
237+ palette: OpenClawMascotPalette ) -> GraphicsContext . Shading
238+ {
239+ let box = path. boundingRect
240+ return . linearGradient(
241+ Gradient ( colors: [ palette. gradientTop, palette. gradientBottom] ) ,
242+ startPoint: box. origin,
243+ endPoint: CGPoint ( x: box. maxX, y: box. maxY) )
244+ }
245+
185246 private static func drawRotated(
186247 context: GraphicsContext ,
187248 degrees: CGFloat ,
0 commit comments