diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift index 0ed70a28fcb3..b2f62e066ccc 100644 --- a/apps/macos/Sources/OpenClaw/AboutSettings.swift +++ b/apps/macos/Sources/OpenClaw/AboutSettings.swift @@ -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") diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index 47cbdd49b5d3..c8f08d45386f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -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) diff --git a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift index 2c9b9fb0d31a..95a1fe97b069 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift @@ -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) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift index ef3651cc4268..62d4da993a50 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift @@ -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,