feat(macos): onboarding mascot reacts with moods, randomized idle behavior, and Easter eggs (#104255)

* feat(macos): mascot moods, randomized idle behavior, and onboarding Easter eggs

* feat(apps): opt About window and iOS hero marks into mascot Easter eggs

* chore(i18n): sync native app i18n inventory
This commit is contained in:
Peter Steinberger
2026-07-11 01:01:26 -07:00
committed by GitHub
parent 8cd092620d
commit f9ebefba29
14 changed files with 1471 additions and 131 deletions

View File

@@ -9635,7 +9635,7 @@
},
{
"kind": "ui-modifier",
"line": 600,
"line": 602,
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
"source": "OpenClaw",
"surface": "apple",
@@ -9643,7 +9643,7 @@
},
{
"kind": "ui-modifier",
"line": 628,
"line": 630,
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
"source": "Gateway \\(self.title)",
"surface": "apple",
@@ -9651,7 +9651,7 @@
},
{
"kind": "conditional-branch",
"line": 633,
"line": 635,
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
"source": "Online",
"surface": "apple",
@@ -9659,7 +9659,7 @@
},
{
"kind": "conditional-branch",
"line": 635,
"line": 637,
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
"source": "Connecting",
"surface": "apple",
@@ -9667,7 +9667,7 @@
},
{
"kind": "conditional-branch",
"line": 637,
"line": 639,
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
"source": "Attention",
"surface": "apple",
@@ -9675,7 +9675,7 @@
},
{
"kind": "conditional-branch",
"line": 639,
"line": 641,
"path": "apps/ios/Sources/Design/OpenClawProComponents.swift",
"source": "Offline",
"surface": "apple",
@@ -14937,17 +14937,9 @@
"surface": "apple",
"id": "native.apple.096fb24013ac5455"
},
{
"kind": "ui-modifier",
"line": 32,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "OpenClaw on GitHub",
"surface": "apple",
"id": "native.apple.6fdc607c6178ba31"
},
{
"kind": "ui-call",
"line": 40,
"line": 32,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "OpenClaw",
"surface": "apple",
@@ -14955,7 +14947,7 @@
},
{
"kind": "ui-call",
"line": 44,
"line": 36,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Menu bar companion for notifications, screenshots, and privileged agent actions.",
"surface": "apple",
@@ -14963,7 +14955,7 @@
},
{
"kind": "ui-named-argument",
"line": 53,
"line": 45,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Website",
"surface": "apple",
@@ -14971,7 +14963,7 @@
},
{
"kind": "ui-named-argument",
"line": 54,
"line": 46,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Docs",
"surface": "apple",
@@ -14979,7 +14971,7 @@
},
{
"kind": "ui-named-argument",
"line": 57,
"line": 49,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "GitHub",
"surface": "apple",
@@ -14987,7 +14979,7 @@
},
{
"kind": "ui-named-argument",
"line": 61,
"line": 53,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Discord",
"surface": "apple",
@@ -14995,7 +14987,7 @@
},
{
"kind": "ui-call",
"line": 74,
"line": 66,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Check for updates automatically",
"surface": "apple",
@@ -15003,7 +14995,7 @@
},
{
"kind": "ui-call",
"line": 78,
"line": 70,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Check for Updates…",
"surface": "apple",
@@ -15011,7 +15003,7 @@
},
{
"kind": "ui-call",
"line": 81,
"line": 73,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Updates unavailable in this build.",
"surface": "apple",
@@ -15019,7 +15011,7 @@
},
{
"kind": "ui-call",
"line": 87,
"line": 79,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "© 2026 OpenClaw Foundation — MIT License.",
"surface": "apple",
@@ -15027,7 +15019,7 @@
},
{
"kind": "ui-call",
"line": 166,
"line": 158,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Copy full commit hash",
"surface": "apple",
@@ -15035,7 +15027,7 @@
},
{
"kind": "ui-call",
"line": 170,
"line": 162,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Copy build info",
"surface": "apple",
@@ -15043,7 +15035,7 @@
},
{
"kind": "ui-call",
"line": 176,
"line": 168,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Copy Commit",
"surface": "apple",
@@ -15051,7 +15043,7 @@
},
{
"kind": "ui-call",
"line": 180,
"line": 172,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Copy Build Info",
"surface": "apple",
@@ -15059,7 +15051,7 @@
},
{
"kind": "ui-call",
"line": 196,
"line": 188,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Unavailable",
"surface": "apple",
@@ -15067,7 +15059,7 @@
},
{
"kind": "ui-call",
"line": 212,
"line": 204,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Version \\(version), commit \\(commit), built \\(built), timestamp \\(timestamp)",
"surface": "apple",
@@ -15075,7 +15067,7 @@
},
{
"kind": "ui-call",
"line": 215,
"line": 207,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Version \\(version), commit \\(commit), build date unavailable",
"surface": "apple",
@@ -15083,7 +15075,7 @@
},
{
"kind": "ui-call",
"line": 218,
"line": 210,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Version \\(version), commit unavailable, built \\(built), timestamp \\(timestamp)",
"surface": "apple",
@@ -15091,7 +15083,7 @@
},
{
"kind": "ui-call",
"line": 220,
"line": 212,
"path": "apps/macos/Sources/OpenClaw/AboutSettings.swift",
"source": "Version \\(version), commit unavailable, build date unavailable",
"surface": "apple",
@@ -18947,7 +18939,7 @@
},
{
"kind": "conditional-branch",
"line": 236,
"line": 237,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Finish",
"surface": "apple",
@@ -18955,7 +18947,7 @@
},
{
"kind": "conditional-branch",
"line": 236,
"line": 237,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Next",
"surface": "apple",
@@ -23505,6 +23497,14 @@
"surface": "apple",
"id": "native.apple.8d0606ab1a2c0f44"
},
{
"kind": "ui-call",
"line": 547,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift",
"source": "z",
"surface": "apple",
"id": "native.apple.f74d150ce05205dd"
},
{
"kind": "ui-modifier",
"line": 39,

View File

@@ -210,9 +210,11 @@ enum OpenClawBrand {
struct OpenClawActivationGlyph: View {
let size: CGFloat
/// Opt-in tap Easter eggs; leave off when the glyph sits inside a control.
var interactive = false
var body: some View {
OpenClawMascotView(floats: false)
OpenClawMascotView(floats: false, interactive: self.interactive)
.frame(width: self.size, height: self.size)
.shadow(
color: OpenClawBrand.activationGlow.opacity(0.18),

View File

@@ -592,9 +592,11 @@ struct ProValuePill: View {
struct OpenClawProMark: View {
var size: CGFloat = 42
var shadowRadius: CGFloat = 10
/// Opt-in tap Easter eggs; leave off when the mark sits inside a control.
var interactive = false
var body: some View {
OpenClawMascotView()
OpenClawMascotView(interactive: self.interactive)
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
.accessibilityLabel("OpenClaw")

View File

@@ -694,7 +694,7 @@ extension SettingsProTab {
Group {
Section {
VStack(spacing: 12) {
OpenClawProMark(size: 96, shadowRadius: 18)
OpenClawProMark(size: 96, shadowRadius: 18, interactive: true)
.accessibilityHidden(true)
VStack(spacing: 2) {
Text("OpenClaw")

View File

@@ -211,7 +211,7 @@ private struct GatewayQuickSetupHeader: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ZStack(alignment: .bottomTrailing) {
OpenClawActivationGlyph(size: 70)
OpenClawActivationGlyph(size: 70, interactive: true)
.shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 10, x: 0, y: 5)
Image(systemName: "antenna.radiowaves.left.and.right")

View File

@@ -27,7 +27,7 @@ private struct OnboardingActivationCanvas<Content: View>: View {
private struct OnboardingHeroGlyph: View {
var body: some View {
OpenClawActivationGlyph(size: 78)
OpenClawActivationGlyph(size: 78, interactive: true)
}
}
@@ -361,7 +361,7 @@ struct OnboardingSuccessStep: View {
Spacer(minLength: 54)
ZStack(alignment: .bottomTrailing) {
OpenClawActivationGlyph(size: 86)
OpenClawActivationGlyph(size: 86, interactive: true)
.shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 12, x: 0, y: 6)
Image(systemName: "checkmark")

View File

@@ -12,29 +12,21 @@ struct AboutSettings: View {
var body: some View {
VStack(spacing: 8) {
Button {
if let url = URL(string: "https://github.com/openclaw/openclaw") {
NSWorkspace.shared.open(url)
// Hero treatment from openclaw.ai: coral silhouette glow at 10% of
// size, teal glow at 15% plus scale 1.1 on hover. Clicks go to the
// mascot's Easter eggs; the GitHub link lives in the row set below.
OpenClawMascotView(interactive: true)
.frame(width: 160, height: 160)
.shadow(
color: OpenClawMascotView.heroGlowColor(
for: self.colorScheme,
hovering: self.iconHover),
radius: self.iconHover ? 24 : 16)
.scaleEffect(self.iconHover ? 1.1 : 1.0)
.pointingHandCursor()
.onHover { hover in
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
}
} 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: 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")
.focusable(false)
.pointingHandCursor()
.onHover { hover in
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
}
VStack(spacing: 3) {
Text("OpenClaw")

View File

@@ -149,6 +149,7 @@ struct OnboardingView: View {
let cliPageIndex = 2
let aiPageIndex = 3
let onboardingChatPageIndex = 8
let readyPageIndex = 9
let permissionsPageIndex = 5

View File

@@ -7,7 +7,7 @@ extension OnboardingView {
let contentHeight = self.contentHeight(for: windowGeometry.size.height)
VStack(spacing: 0) {
// Chat-heavy pages shrink the mascot so the content gets the room.
GlowingOpenClawIcon(size: self.heroSize)
GlowingOpenClawIcon(size: self.heroSize, mood: self.mascotMood)
.offset(y: self.usesCompactHero ? 4 : 10)
.frame(height: self.heroFrameHeight)
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.usesCompactHero)

View File

@@ -1,22 +1,132 @@
import OpenClawChatUI
import OpenClawIPC
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).
/// Interactive: it reacts to clicks and its eyes follow the pointer.
struct GlowingOpenClawIcon: View {
@Environment(\.colorScheme) private var colorScheme
let size: CGFloat
let mood: OpenClawMascotMood
init(size: CGFloat = 148) {
init(size: CGFloat = 148, mood: OpenClawMascotMood = .idle) {
self.size = size
self.mood = mood
}
var body: some View {
OpenClawMascotView()
OpenClawMascotView(mood: self.mood, interactive: true)
.frame(width: self.size, height: self.size)
.shadow(
color: OpenClawMascotView.heroGlowColor(for: self.colorScheme),
radius: self.size * 0.1)
}
}
extension OnboardingView {
/// Onboarding page classes the mascot reacts to.
enum MascotPage {
case welcome
case connection
case cli
case ai
case permissions
case chat
case ready
}
/// Flow state the mascot mood is derived from.
struct MascotMoodSnapshot {
var page: MascotPage
var installingCLI = false
var cliInstalled = false
var cliStatusKnown = false
var aiPhase: OnboardingAISetupModel.Phase = .idle
var aiBusy = false
var aiFailed = false
var remoteProbeState: RemoteOnboardingProbeState = .idle
var allPermissionsGranted = false
}
/// The hero mascot mirrors what setup is doing: curious while choosing,
/// thinking while work is in flight, sad on failures, celebrating once
/// the AI answers and on the final page.
var mascotMood: OpenClawMascotMood {
Self.mascotMood(for: MascotMoodSnapshot(
page: self.mascotPage,
installingCLI: self.installingCLI,
cliInstalled: self.cliInstalled,
cliStatusKnown: self.cliStatusKnown,
aiPhase: self.aiSetup.phase,
aiBusy: self.aiSetup.isBusy,
aiFailed: Self.aiSetupLooksFailed(self.aiSetup),
remoteProbeState: self.remoteProbeState,
allPermissionsGranted: Capability.importanceOrdered
.allSatisfy { self.permissionMonitor.status[$0] ?? false }))
}
private var mascotPage: MascotPage {
switch self.activePageIndex {
case self.connectionPageIndex: .connection
case self.cliPageIndex: .cli
case self.aiPageIndex: .ai
case self.permissionsPageIndex: .permissions
case self.onboardingChatPageIndex: .chat
case self.readyPageIndex: .ready
default: .welcome
}
}
static func aiSetupLooksFailed(_ aiSetup: OnboardingAISetupModel) -> Bool {
guard !aiSetup.connected else { return false }
let candidateFailed = aiSetup.statuses.values.contains { status in
if case .failed = status { return true }
return false
}
return aiSetup.detectError != nil ||
aiSetup.exhaustedAutoCandidates ||
aiSetup.manualError != nil ||
candidateFailed
}
static func mascotMood(for snapshot: MascotMoodSnapshot) -> OpenClawMascotMood {
switch snapshot.page {
case .welcome:
.idle
case .connection:
switch snapshot.remoteProbeState {
case .checking: .thinking
case .failed: .sad
case .ok: .happy
case .idle: .curious
}
case .cli:
if snapshot.cliInstalled {
.happy
} else if snapshot.cliStatusKnown, !snapshot.installingCLI {
// Mirrors the page's install-failed card.
.sad
} else {
.thinking
}
case .ai:
if snapshot.aiPhase == .connected {
.celebrating
} else if snapshot.aiBusy {
.thinking
} else if snapshot.aiFailed {
.sad
} else {
.curious
}
case .permissions:
snapshot.allPermissionsGranted ? .happy : .curious
case .chat:
.attentive
case .ready:
.celebrating
}
}
}

View File

@@ -0,0 +1,54 @@
import OpenClawChatUI
import Testing
@testable import OpenClaw
@MainActor
struct OnboardingMascotMoodTests {
private func mood(_ snapshot: OnboardingView.MascotMoodSnapshot) -> OpenClawMascotMood {
OnboardingView.mascotMood(for: snapshot)
}
@Test func `welcome page idles`() {
#expect(self.mood(.init(page: .welcome)) == .idle)
}
@Test func `connection page follows probe`() {
#expect(self.mood(.init(page: .connection)) == .curious)
#expect(self.mood(.init(page: .connection, remoteProbeState: .checking)) == .thinking)
#expect(self.mood(.init(page: .connection, remoteProbeState: .failed("no route"))) == .sad)
#expect(self.mood(.init(
page: .connection,
remoteProbeState: .ok(RemoteGatewayProbeSuccess(authSource: nil)))) == .happy)
}
@Test func `cli page tracks install lifecycle`() {
#expect(self.mood(.init(page: .cli, installingCLI: true)) == .thinking)
#expect(self.mood(.init(page: .cli)) == .thinking, "status probe still deciding")
#expect(self.mood(.init(page: .cli, cliStatusKnown: true)) == .sad, "install failed")
#expect(self.mood(.init(page: .cli, cliInstalled: true, cliStatusKnown: true)) == .happy)
}
@Test func `ai page celebrates thinks and mourns`() {
#expect(self.mood(.init(page: .ai)) == .curious)
#expect(self.mood(.init(page: .ai, aiPhase: .testing, aiBusy: true)) == .thinking)
#expect(self.mood(.init(page: .ai, aiFailed: true)) == .sad)
#expect(self.mood(.init(page: .ai, aiPhase: .connected)) == .celebrating)
#expect(
self.mood(.init(page: .ai, aiPhase: .connected, aiFailed: true)) == .celebrating,
"a live connection outranks stale failures")
}
@Test func `permissions page warms up when everything is granted`() {
#expect(self.mood(.init(page: .permissions)) == .curious)
#expect(self.mood(.init(page: .permissions, allPermissionsGranted: true)) == .happy)
}
@Test func `chat and ready pages`() {
#expect(self.mood(.init(page: .chat)) == .attentive)
#expect(self.mood(.init(page: .ready)) == .celebrating)
}
@Test func `fresh AI setup model does not look failed`() {
#expect(!OnboardingView.aiSetupLooksFailed(OnboardingAISetupModel()))
}
}

View File

@@ -0,0 +1,653 @@
import Foundation
import SwiftUI
/// High-level emotional state for the animated mascot. Callers describe *how
/// the mascot should feel*; the animator owns how that feeling looks, including
/// the transition gesture played when a mood is entered.
public enum OpenClawMascotMood: String, CaseIterable, Equatable, Sendable {
/// Site-parity float/wiggle plus randomized blinks, glances, and rare quirks.
case idle
/// Looks around a lot searching (gateway discovery, permission list).
case curious
/// Eyes drift up and scan, antennae twitch asymmetrically work in flight.
case thinking
/// Perky antennae, soft smile, happy-squint eyes.
case happy
/// One big jump-and-claw-raise entrance, then sparkly happy loop.
case celebrating
/// Droopy antennae, dim glow, downward gaze, periodic sighs.
case sad
/// Heavy lids, slow float, drifting z's; periodic yawns.
case sleepy
/// Subdued idle that watches the content below (compact chat hero).
case attentive
}
/// Ambient particle overlay drawn around the mascot for one pose frame.
enum OpenClawMascotEffect: Equatable {
case none
case sparkles
case hearts
case zzz
}
/// Drives the mascot's behavior over time: per-mood base loops, randomized
/// micro-behaviors (blinks, glances, claw snaps, rare quirks), one-shot
/// gestures, and interaction Easter eggs (click reactions, dizzy spiral,
/// auto-sleep). Not thread-safe by design SwiftUI drives it from the view
/// body on the main thread only.
final class OpenClawMascotAnimator {
/// Deterministic xorshift64* so tests can seed the behavior schedule.
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
self.state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
}
mutating func next() -> UInt64 {
self.state ^= self.state >> 12
self.state ^= self.state << 25
self.state ^= self.state >> 27
return self.state &* 2_685_821_657_736_338_717
}
}
private(set) var mood: OpenClawMascotMood = .idle
private var rng: SeededGenerator
private var startTime: TimeInterval?
private var lastPoseTime: TimeInterval = 0
// One-shot gesture playback (single slot; quirks wait for a free slot).
private var activeGesture: OpenClawMascotGesture?
private var activeGestureStart: TimeInterval = 0
private var pendingGesture: OpenClawMascotGesture?
private var pendingGestureAt: TimeInterval = 0
// Randomized micro-behavior schedule.
private var nextBlinkAt: TimeInterval = 0
private var pendingDoubleBlink = false
private var blinkStarts: [TimeInterval] = []
private var nextGlanceAt: TimeInterval = 0
private var gazeHoldUntil: TimeInterval = 0
private var gazeTarget: CGSize = .zero
private var currentGaze: CGSize = .zero
private var nextClawSnapAt: TimeInterval = 0
private var nextQuirkAt: TimeInterval = 0
private var nextMoodBeatAt: TimeInterval = 0
private var lastClickReaction: OpenClawMascotGesture?
// Interaction state.
private var pointerTarget: CGSize?
private var recentTapTimes: [TimeInterval] = []
private var dizzyStart: TimeInterval = 0
private var dizzyUntil: TimeInterval = 0
private var dizzyRecoveryQueued = false
// Auto-sleep: idle mascots doze off and need a click to wake up. Night
// owls (23:00-05:00 local) nod off about twice as fast. Only mascots with
// a tap handler may sleep a non-interactive view has no wake path and
// would stay asleep forever.
private let allowsAutoSleep: Bool
private var lastInteractionAt: TimeInterval = 0
private var sleepAfter: TimeInterval = 60
private let nightOwl: Bool
init(seed: UInt64? = nil, hourOfDay: Int? = nil, allowsAutoSleep: Bool = false) {
self.rng = SeededGenerator(seed: seed ?? UInt64.random(in: 1...UInt64.max))
self.allowsAutoSleep = allowsAutoSleep
let hour = hourOfDay ?? Calendar.current.component(.hour, from: Date())
self.nightOwl = hour >= 23 || hour < 5
}
// MARK: - Inputs
func setMood(_ mood: OpenClawMascotMood, at time: TimeInterval) {
guard mood != self.mood else { return }
self.mood = mood
self.lastInteractionAt = time
self.rescheduleMoodBeat(at: time)
if let entrance = Self.entranceGesture(for: mood) {
self.startGesture(entrance, at: time)
}
}
/// Pointer direction from the view center, roughly unit-scaled; nil when
/// the pointer leaves. Eyes track it, and presence defers auto-sleep
/// but hovering never wakes a sleeping mascot; that takes a click.
func setPointerTarget(_ direction: CGSize?, at time: TimeInterval) {
self.pointerTarget = direction.map { dir in
CGSize(width: dir.width.clamped(to: -1...1), height: dir.height.clamped(to: -1...1))
}
if direction != nil, !self.isDozing(at: time) {
self.lastInteractionAt = time
}
}
/// Click Easter-egg ladder: single clicks pick a playful reaction, a
/// quick burst of affection earns blushing hearts, and relentless
/// clicking makes the poor thing dizzy.
func handleTap(at time: TimeInterval) {
let wasDozing = self.isDozing(at: time)
self.lastInteractionAt = time
self.sleepAfter = self.randomSleepDelay()
if wasDozing {
self.startGesture(.startle, at: time)
return
}
self.recentTapTimes.append(time)
self.recentTapTimes.removeAll { time - $0 > 3.0 }
let burst = self.recentTapTimes.count
if time < self.dizzyUntil || burst >= 6 {
if time >= self.dizzyUntil {
self.dizzyStart = time
}
self.dizzyUntil = max(self.dizzyUntil, time + 2.4)
self.dizzyRecoveryQueued = true
self.activeGesture = nil
return
}
if burst >= 3 {
self.startGesture(.heartBurst, at: time)
return
}
var reactions: [OpenClawMascotGesture] = [.hop, .wave, .wink]
if let last = self.lastClickReaction {
reactions.removeAll { $0 == last }
}
let reaction = reactions.randomElement(using: &self.rng) ?? .hop
self.lastClickReaction = reaction
self.startGesture(reaction, at: time)
}
// MARK: - Pose
func pose(at time: TimeInterval) -> OpenClawMascotPose {
if self.startTime == nil {
self.begin(at: time)
}
let dt = (time - self.lastPoseTime).clamped(to: 0...0.1)
self.lastPoseTime = time
self.advanceSchedules(at: time)
let dozing = self.isDozing(at: time)
let effectiveMood: OpenClawMascotMood = dozing ? .sleepy : self.mood
var pose = self.basePose(for: effectiveMood, at: time)
self.applyGaze(&pose, mood: effectiveMood, at: time, dt: dt)
self.applyBlinks(&pose, at: time)
self.applyDizzy(&pose, at: time)
if let gesture = self.activeGesture {
let progress = (time - self.activeGestureStart) / gesture.duration
if progress >= 1 {
self.activeGesture = nil
} else {
gesture.apply(to: &pose, progress: CGFloat(progress))
}
}
pose.clampChannels()
return pose
}
// MARK: - Lifecycle
private func begin(at time: TimeInterval) {
self.startTime = time
self.lastPoseTime = time
self.lastInteractionAt = time
self.sleepAfter = self.randomSleepDelay()
self.nextBlinkAt = time + self.random(in: 0.8...2.4)
self.nextGlanceAt = time + self.random(in: 1.5...4.0)
self.nextClawSnapAt = time + self.random(in: 2.0...5.0)
self.nextQuirkAt = time + self.random(in: 9...18)
self.rescheduleMoodBeat(at: time)
// Say hello: a little wave shortly after first appearing.
if self.mood == .idle || self.mood == .curious || self.mood == .happy {
self.pendingGesture = .wave
self.pendingGestureAt = time + 0.9
}
}
private func isDozing(at time: TimeInterval) -> Bool {
self.allowsAutoSleep && self.mood == .idle &&
time - self.lastInteractionAt > self.sleepAfter
}
private func randomSleepDelay() -> TimeInterval {
let base = self.random(in: 45...80)
return self.nightOwl ? base * 0.55 : base
}
private func random(in range: ClosedRange<Double>) -> Double {
Double.random(in: range, using: &self.rng)
}
// MARK: - Schedules
private func advanceSchedules(at time: TimeInterval) {
if time >= self.nextBlinkAt {
self.blinkStarts.append(time)
if self.pendingDoubleBlink {
self.pendingDoubleBlink = false
self.nextBlinkAt = time + self.blinkInterval()
} else if self.random(in: 0...1) < 0.14 {
self.pendingDoubleBlink = true
self.nextBlinkAt = time + 0.34
} else {
self.nextBlinkAt = time + self.blinkInterval()
}
}
self.blinkStarts.removeAll { time - $0 > OpenClawMascotGesture.blinkDuration }
if time >= self.nextGlanceAt {
self.gazeTarget = self.randomGlanceTarget()
self.gazeHoldUntil = time + self.random(in: 0.7...1.9)
self.nextGlanceAt = self.gazeHoldUntil + self.glanceInterval()
} else if time >= self.gazeHoldUntil {
self.gazeTarget = .zero
}
let dozing = self.isDozing(at: time)
if time >= self.nextClawSnapAt {
if self.activeGesture == nil, !dozing, self.mood != .sad, time >= self.dizzyUntil {
self.startGesture(.clawSnap, at: time)
}
self.nextClawSnapAt = time + self.random(in: 4...9)
}
if time >= self.nextQuirkAt {
if self.activeGesture == nil, !dozing, time >= self.dizzyUntil, self.quirkEligible {
self.startGesture(self.randomQuirk(), at: time)
}
self.nextQuirkAt = time + self.random(in: self.mood == .curious ? 7...14 : 9...18)
}
if time >= self.nextMoodBeatAt {
if self.activeGesture == nil, time >= self.dizzyUntil {
if self.mood == .sad {
self.startGesture(.sigh, at: time)
} else if dozing || self.mood == .sleepy {
self.startGesture(.yawn, at: time)
}
}
self.rescheduleMoodBeat(at: time)
}
// Fires once the gesture slot frees up rather than dropping the clip.
if let pending = self.pendingGesture, time >= self.pendingGestureAt,
self.activeGesture == nil, !dozing
{
self.pendingGesture = nil
self.startGesture(pending, at: time)
}
if self.dizzyRecoveryQueued, time >= self.dizzyUntil {
self.dizzyRecoveryQueued = false
self.startGesture(.shake, at: time)
}
}
private var quirkEligible: Bool {
switch self.mood {
case .idle, .curious, .happy, .attentive: true
case .thinking, .celebrating, .sad, .sleepy: false
}
}
private func randomQuirk() -> OpenClawMascotGesture {
// Weighted grab bag; the sneeze stays rare so it keeps surprising.
let roll = self.random(in: 0...11)
switch roll {
case ..<3: return .wink
case ..<6: return .peek
case ..<8: return .antennaZap
case ..<10: return .hop
default: return .sneeze
}
}
private func blinkInterval() -> TimeInterval {
self.mood == .attentive ? self.random(in: 1.8...4.0) : self.random(in: 2.2...5.5)
}
private func glanceInterval() -> TimeInterval {
switch self.mood {
case .curious: self.random(in: 1.6...4.0)
case .thinking: self.random(in: 1.2...3.0)
default: self.random(in: 3.0...8.0)
}
}
private func randomGlanceTarget() -> CGSize {
let magnitude = self.random(in: 0.5...1.0)
let angle = self.random(in: 0...(2 * .pi))
// Bias horizontal: sideways glances read better than straight up/down.
return CGSize(width: cos(angle) * magnitude, height: sin(angle) * magnitude * 0.6)
}
private func rescheduleMoodBeat(at time: TimeInterval) {
self.nextMoodBeatAt = time + self.random(in: 6...12)
}
private func startGesture(_ gesture: OpenClawMascotGesture, at time: TimeInterval) {
self.activeGesture = gesture
self.activeGestureStart = time
}
private static func entranceGesture(for mood: OpenClawMascotMood) -> OpenClawMascotGesture? {
switch mood {
case .happy: .hop
case .celebrating: .celebrate
case .sad: .sigh
case .sleepy: .yawn
case .idle, .curious, .thinking, .attentive: nil
}
}
// MARK: - Pose layers
/// Per-mood base loop. `.idle` keeps the exact site-parity waves (float 4s,
/// antenna ±3° at 2s); other moods reshape speed, depth, and expression.
private func basePose(for mood: OpenClawMascotMood, at time: TimeInterval) -> OpenClawMascotPose {
var pose = OpenClawMascotPose()
switch mood {
case .idle:
pose.floatOffset = -4.8 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 4)))
pose.antennaDegrees = -3 * sin(2 * .pi * Self.cyclePhase(time, period: 2))
case .curious:
pose.floatOffset = -4.2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 3.4)))
pose.antennaDegrees = -4 * sin(2 * .pi * Self.cyclePhase(time, period: 1.7))
pose.bodyTilt = 1.6 * sin(2 * .pi * Self.cyclePhase(time, period: 5.2))
case .thinking:
pose.floatOffset = -3.2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 5)))
// Antennae twitch out of phase visible "processing".
pose.antennaDegrees = -5 * sin(2 * .pi * Self.cyclePhase(time, period: 1.3))
pose.bodyTilt = 2 * sin(2 * .pi * Self.cyclePhase(time, period: 6))
pose.eyeGlowOpacity = 0.9 + 0.1 * sin(2 * .pi * Self.cyclePhase(time, period: 0.8))
case .happy:
pose.floatOffset = -6 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 3)))
pose.antennaDegrees = -4.5 * sin(2 * .pi * Self.cyclePhase(time, period: 1.6))
pose.mouthCurve = 0.55 + 0.1 * sin(2 * .pi * Self.cyclePhase(time, period: 3))
pose.happyEyes = 0.35
case .celebrating:
let hop = abs(sin(2 * .pi * Self.cyclePhase(time, period: 1.6)))
pose.floatOffset = -9 * hop
pose.bodyStretch = 1 + 0.03 * hop
pose.antennaDegrees = -6 * sin(2 * .pi * Self.cyclePhase(time, period: 0.8))
let clawWave = sin(2 * .pi * Self.cyclePhase(time, period: 0.9))
pose.leftClawDegrees = 20 + 8 * clawWave
pose.rightClawDegrees = -20 + 8 * clawWave
pose.mouthCurve = 0.9
pose.mouthOpen = 0.35
pose.happyEyes = 0.7
pose.glowScale = 1.1
pose.effect = .sparkles
pose.effectPhase = Self.cyclePhase(time, period: 2.2)
case .sad:
pose.floatOffset = -2.4 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 5.5)))
pose.antennaDegrees = -1.5 * sin(2 * .pi * Self.cyclePhase(time, period: 3))
pose.antennaDroop = 0.75
pose.mouthCurve = -0.55
pose.eyeGlowOpacity = 0.6
case .sleepy:
pose.floatOffset = -2 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 6)))
pose.antennaDroop = 0.35
pose.leftEyeOpenness = 0.22 + 0.08 * sin(2 * .pi * Self.cyclePhase(time, period: 3))
pose.rightEyeOpenness = pose.leftEyeOpenness
pose.eyeGlowOpacity = 0.5
pose.mouthRound = 0.15
// Slow head-bob as it nods off.
pose.bodyTilt = 2.5 * sin(2 * .pi * Self.cyclePhase(time, period: 6))
pose.effect = .zzz
pose.effectPhase = Self.cyclePhase(time, period: 3)
case .attentive:
pose.floatOffset = -3 * (1 - cos(2 * .pi * Self.cyclePhase(time, period: 4)))
pose.antennaDegrees = -2.5 * sin(2 * .pi * Self.cyclePhase(time, period: 2))
pose.mouthCurve = 0.25
}
return pose
}
private func applyGaze(
_ pose: inout OpenClawMascotPose,
mood: OpenClawMascotMood,
at time: TimeInterval,
dt: TimeInterval)
{
var target = self.pointerTarget ?? self.gazeTarget
switch mood {
case .thinking:
// Pondering: eyes drift up and slowly scan side to side.
if self.pointerTarget == nil {
target = CGSize(
width: 0.4 * sin(2 * .pi * Self.cyclePhase(time, period: 3.8)),
height: -0.55)
}
case .attentive:
if self.pointerTarget == nil {
target = CGSize(width: target.width * 0.5, height: 0.35)
}
case .sad:
if self.pointerTarget == nil {
target = CGSize(width: target.width * 0.3, height: 0.5)
}
case .sleepy:
target = CGSize(width: 0, height: 0.4)
case .idle, .curious, .happy, .celebrating:
break
}
let blend = 1 - exp(-dt * 9)
self.currentGaze.width += (target.width - self.currentGaze.width) * blend
self.currentGaze.height += (target.height - self.currentGaze.height) * blend
pose.gaze = self.currentGaze
}
private func applyBlinks(_ pose: inout OpenClawMascotPose, at time: TimeInterval) {
guard pose.happyEyes < 0.6 else { return }
for start in self.blinkStarts {
let progress = (time - start) / OpenClawMascotGesture.blinkDuration
guard progress >= 0, progress <= 1 else { continue }
let closure = OpenClawMascotGesture.bell(CGFloat(progress))
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1 - closure)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1 - closure)
pose.eyeGlowOpacity *= max(0.3, 1 - closure)
}
}
private func applyDizzy(_ pose: inout OpenClawMascotPose, at time: TimeInterval) {
guard time < self.dizzyUntil else { return }
let remaining = self.dizzyUntil - time
// Ramp in from the first dizzy tap, out toward the recovery shake;
// extra taps extend `dizzyUntil` without restarting the ramp-in.
let ramp = min((time - self.dizzyStart) / 0.25, remaining / 0.4).clamped(to: 0...1)
pose.dizzy = ramp
pose.dizzyPhase = Self.cyclePhase(time, period: 0.55)
pose.bodyTilt += 4.5 * ramp * sin(2 * .pi * Self.cyclePhase(time, period: 0.85))
pose.antennaDegrees += 6 * ramp * sin(2 * .pi * Self.cyclePhase(time, period: 0.45))
pose.mouthRound = max(pose.mouthRound, 0.3 * ramp)
pose.happyEyes = 0
pose.mouthCurve = min(pose.mouthCurve, 0)
pose.gaze = .zero
}
static func cyclePhase(_ time: TimeInterval, period: TimeInterval) -> CGFloat {
let normalized = (time / period).truncatingRemainder(dividingBy: 1)
return CGFloat(normalized < 0 ? normalized + 1 : normalized)
}
}
/// One-shot animation clips layered over the mood base pose.
enum OpenClawMascotGesture: Equatable {
case wave
case hop
case wink
case celebrate
case heartBurst
case peek
case sneeze
case antennaZap
case sigh
case yawn
case startle
case shake
case clawSnap
static let blinkDuration: TimeInterval = 0.16
var duration: TimeInterval {
switch self {
case .wave: 1.5
case .hop: 0.7
case .wink: 0.9
case .celebrate: 2.4
case .heartBurst: 2.0
case .peek: 1.9
case .sneeze: 1.5
case .antennaZap: 1.0
case .sigh: 1.8
case .yawn: 2.0
case .startle: 0.8
case .shake: 0.8
case .clawSnap: 0.6
}
}
func apply(to pose: inout OpenClawMascotPose, progress p: CGFloat) {
switch self {
case .wave:
let raised = Self.plateau(p, attack: 0.18, release: 0.82)
pose.rightClawDegrees += raised * (-28 + 9 * sin(p * 6 * .pi))
pose.bodyTilt += -2 * raised
pose.mouthCurve = max(pose.mouthCurve, 0.5 * raised)
case .hop:
let air = Self.bell(((p - 0.2) / 0.6).clamped(to: 0...1))
pose.floatOffset += -9 * air
pose.bodyStretch += 0.045 * air - 0.1 * Self.bell((p / 0.2).clamped(to: 0...1))
- 0.06 * Self.bell(((p - 0.82) / 0.18).clamped(to: 0...1))
pose.mouthCurve = max(pose.mouthCurve, 0.4 * air)
case .wink:
let closure = Self.plateau(p, attack: 0.3, release: 0.72)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1 - closure)
pose.mouthCurve = max(pose.mouthCurve, 0.5 * closure)
pose.bodyTilt += 1.5 * closure
case .celebrate:
let env = Self.plateau(p, attack: 0.12, release: 0.88)
let hops = abs(sin(p * 4 * .pi))
pose.floatOffset += -11 * hops * env
pose.bodyStretch += 0.035 * hops * env
pose.leftClawDegrees += 38 * env
pose.rightClawDegrees += -38 * env
pose.happyEyes = max(pose.happyEyes, env)
pose.mouthCurve = max(pose.mouthCurve, env)
pose.mouthOpen = max(pose.mouthOpen, 0.6 * Self.bell(p))
pose.antennaDroop = 0
pose.glowScale = max(pose.glowScale, 1 + 0.2 * env)
pose.effect = .sparkles
pose.effectPhase = p
case .heartBurst:
let env = Self.plateau(p, attack: 0.15, release: 0.85)
pose.blush = max(pose.blush, 0.9 * env)
pose.happyEyes = max(pose.happyEyes, 0.8 * env)
pose.mouthCurve = max(pose.mouthCurve, 0.8 * env)
pose.bodyTilt += 2 * env * sin(p * 4 * .pi)
pose.effect = .hearts
pose.effectPhase = p
case .peek:
// Lean left, then right, like checking who's offscreen.
let left = Self.plateau((p / 0.5).clamped(to: 0...1), attack: 0.3, release: 0.7)
let right = Self.plateau(((p - 0.5) / 0.5).clamped(to: 0...1), attack: 0.3, release: 0.7)
pose.bodyTilt += -6 * left + 6 * right
pose.gaze = CGSize(width: -1.1 * left + 1.1 * right, height: -0.1)
pose.glowScale = max(pose.glowScale, 1.1)
case .sneeze:
if p < 0.42 {
let inhale = OpenClawMascotGesture.easeInOut(p / 0.42)
pose.bodyStretch += 0.04 * inhale
pose.gaze = CGSize(width: 0, height: -0.8 * inhale)
pose.mouthRound = max(pose.mouthRound, 0.55 * inhale)
pose.antennaDegrees += 8 * inhale
} else if p < 0.58 {
let burst = Self.bell((p - 0.42) / 0.16)
pose.bodyStretch -= 0.13 * burst
pose.leftEyeOpenness = 0
pose.rightEyeOpenness = 0
pose.antennaDroop = max(pose.antennaDroop, 0.9 * burst)
pose.bodyTilt += 3 * burst
} else {
let recover = 1 - OpenClawMascotGesture.easeInOut((p - 0.58) / 0.42)
pose.antennaDroop = max(pose.antennaDroop, 0.5 * recover)
pose.eyeGlowOpacity *= 1 - 0.4 * recover
pose.mouthRound = max(pose.mouthRound, 0.2 * recover)
}
case .antennaZap:
let env = 1 - p
pose.antennaDegrees += 5 * env * sin(p * 12 * .pi)
pose.glowScale = max(pose.glowScale, 1 + 0.55 * Self.bell(p))
pose.eyeGlowOpacity = 1
case .sigh:
let rise = OpenClawMascotGesture.easeInOut((p / 0.3).clamped(to: 0...1))
let fall = OpenClawMascotGesture.easeInOut(((p - 0.3) / 0.45).clamped(to: 0...1))
pose.bodyStretch += 0.025 * rise - 0.08 * fall * (1 - ((p - 0.85) / 0.15).clamped(to: 0...1))
pose.gaze = CGSize(width: pose.gaze.width, height: 0.5 * fall)
pose.antennaDroop = min(1, pose.antennaDroop + 0.15 * fall)
case .yawn:
let openess = Self.plateau(p, attack: 0.3, release: 0.75)
pose.mouthRound = max(pose.mouthRound, 0.9 * openess)
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1 - 0.9 * openess)
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1 - 0.9 * openess)
pose.bodyStretch += 0.03 * openess
pose.bodyTilt += -2 * openess
case .startle:
let jolt = Self.bell((p / 0.4).clamped(to: 0...1))
pose.floatOffset += -5 * jolt
pose.bodyStretch += 0.05 * jolt
pose.glowScale = max(pose.glowScale, 1 + 0.4 * jolt)
pose.leftEyeOpenness = 1
pose.rightEyeOpenness = 1
pose.antennaDroop = 0
pose.antennaDegrees = 0
pose.gaze = .zero
case .shake:
pose.bodyTilt += 5 * (1 - p) * sin(p * 6 * .pi)
pose.gaze = .zero
case .clawSnap:
// The site's 4s-cycle snap, compressed into a schedulable clip:
// snap to -8° and back, right claw trailing slightly.
pose.leftClawDegrees += -8 * Self.bell((p / 0.7).clamped(to: 0...1))
pose.rightClawDegrees += -8 * Self.bell(((p - 0.25) / 0.7).clamped(to: 0...1))
}
}
// MARK: - Easing
static func easeInOut(_ t: CGFloat) -> CGFloat {
let clamped = t.clamped(to: 0...1)
return clamped * clamped * (3 - 2 * clamped)
}
/// Smooth 010 bump peaking at 0.5.
static func bell(_ t: CGFloat) -> CGFloat {
let clamped = t.clamped(to: 0...1)
return self.easeInOut(clamped < 0.5 ? clamped * 2 : (1 - clamped) * 2)
}
/// Ease in until `attack`, hold at 1, ease out after `release`.
static func plateau(_ t: CGFloat, attack: CGFloat, release: CGFloat) -> CGFloat {
let clamped = t.clamped(to: 0...1)
if clamped < attack {
return self.easeInOut(clamped / attack)
}
if clamped > release {
return self.easeInOut((1 - clamped) / (1 - release))
}
return 1
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}

View File

@@ -5,35 +5,84 @@ import SwiftUI
/// 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).
///
/// Beyond the site's loop, an `OpenClawMascotAnimator` layers on moods
/// (thinking, celebrating, sad, ), randomized micro-behaviors, and when
/// `interactive` click Easter eggs. Eyes follow the pointer on hover.
public struct OpenClawMascotView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
@State private var animator: OpenClawMascotAnimator
private let floats: Bool
private let mood: OpenClawMascotMood
private let interactive: Bool
public init(floats: Bool = true) {
/// - Parameters:
/// - floats: allow whole-body vertical travel (float loop, hops). Turn
/// off in tight layouts; bounces then show as squash-and-stretch only.
/// - mood: emotional state; transitions play an entrance gesture.
/// - interactive: enables click reactions and auto-sleep (waking takes
/// a click). Off by default so the mascot never swallows taps meant
/// for an enclosing control.
public init(
floats: Bool = true,
mood: OpenClawMascotMood = .idle,
interactive: Bool = false)
{
self.floats = floats
self.mood = mood
self.interactive = interactive
self._animator = State(initialValue: OpenClawMascotAnimator(allowsAutoSleep: interactive))
}
public var body: some View {
let palette = OpenClawMascotPalette.forScheme(self.colorScheme)
if self.reduceMotion {
OpenClawMascotCanvas(pose: .still, palette: palette)
OpenClawMascotCanvas(pose: .staticPose(for: self.mood), 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: self.floats
? pose.floatOffset * min(proxy.size.width, proxy.size.height) / 120
: 0)
}
self.animatedMascot(palette: palette)
}
}
@ViewBuilder
private func animatedMascot(palette: OpenClawMascotPalette) -> some View {
let core = TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
let pose = self.animator.pose(at: 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 float/hop peak.
GeometryReader { proxy in
OpenClawMascotCanvas(pose: pose, palette: palette)
.offset(
y: self.floats
? pose.floatOffset * min(proxy.size.width, proxy.size.height) / 120
: 0)
.pointerTracking(size: proxy.size) { direction in
self.animator.setPointerTarget(direction, at: self.now())
}
}
}
.onChange(of: self.mood) { _, newMood in
self.animator.setMood(newMood, at: self.now())
}
.onAppear {
self.animator.setMood(self.mood, at: self.now())
}
if self.interactive {
core
.contentShape(Rectangle())
.onTapGesture {
self.animator.handleTap(at: self.now())
}
} else {
core
}
}
private func now() -> TimeInterval {
Date().timeIntervalSinceReferenceDate
}
/// openclaw.ai hero drop-shadow color (`--logo-glow` / `--logo-glow-hover`).
@@ -49,6 +98,31 @@ public struct OpenClawMascotView: View {
}
}
extension View {
/// Eye-tracking hover support where pointers exist; no-op elsewhere.
@ViewBuilder
fileprivate func pointerTracking(
size: CGSize,
onMove: @escaping (CGSize?) -> Void) -> some View
{
#if os(iOS) || os(macOS)
self.onContinuousHover(coordinateSpace: .local) { phase in
switch phase {
case let .active(point):
guard size.width > 0, size.height > 0 else { return }
onMove(CGSize(
width: (point.x - size.width / 2) / (size.width / 2),
height: (point.y - size.height / 2) / (size.height / 2)))
case .ended:
onMove(nil)
}
}
#else
self
#endif
}
}
/// 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.
@@ -72,60 +146,101 @@ struct OpenClawMascotPalette: Equatable {
}
}
/// 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.
/// Part transforms and expression channels for one animation frame, produced
/// by `OpenClawMascotAnimator` (or `staticPose` under Reduce Motion) and
/// consumed by `OpenClawMascotCanvas`.
struct OpenClawMascotPose: Equatable {
var floatOffset: CGFloat = 0
var antennaDegrees: CGFloat = 0
/// 0..1: antennae fold outward and down (sadness, sneeze flop).
var antennaDroop: CGFloat = 0
var leftClawDegrees: CGFloat = 0
var rightClawDegrees: CGFloat = 0
var eyeGlowOpacity: CGFloat = 1
var glowScale: CGFloat = 1
var leftEyeOpenness: CGFloat = 1
var rightEyeOpenness: CGFloat = 1
/// 0..1: morph eyes into happy arcs.
var happyEyes: CGFloat = 0
/// Unit-ish look direction; drawn as a small eye/glow shift.
var gaze: CGSize = .zero
/// -1 frown +1 smile.
var mouthCurve: CGFloat = 0
/// 0..1 open grin depth (takes over from `mouthCurve`).
var mouthOpen: CGFloat = 0
/// 0..1 surprised/yawning "o" (takes over from both mouth channels).
var mouthRound: CGFloat = 0
var blush: CGFloat = 0
/// Degrees around the canvas center.
var bodyTilt: CGFloat = 0
/// Vertical squash-and-stretch about the feet; x compensates slightly.
var bodyStretch: CGFloat = 1
/// 0..1: glow dots orbit instead of shining too many clicks.
var dizzy: CGFloat = 0
var dizzyPhase: CGFloat = 0
var effect: OpenClawMascotEffect = .none
var effectPhase: CGFloat = 0
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
/// Motionless expression per mood for Reduce Motion users.
static func staticPose(for mood: OpenClawMascotMood) -> OpenClawMascotPose {
var pose = OpenClawMascotPose()
switch mood {
case .idle, .curious, .attentive:
break
case .thinking:
pose.gaze = CGSize(width: 0.3, height: -0.5)
case .happy:
pose.mouthCurve = 0.6
pose.happyEyes = 0.4
case .celebrating:
pose.mouthCurve = 0.9
pose.mouthOpen = 0.4
pose.happyEyes = 0.8
pose.leftClawDegrees = 30
pose.rightClawDegrees = -30
case .sad:
pose.antennaDroop = 0.75
pose.mouthCurve = -0.55
pose.eyeGlowOpacity = 0.6
pose.gaze = CGSize(width: 0, height: 0.5)
case .sleepy:
pose.leftEyeOpenness = 0.25
pose.rightEyeOpenness = 0.25
pose.eyeGlowOpacity = 0.5
pose.antennaDroop = 0.35
}
if phase < 0.9 {
return -8 * self.easeInOut((phase - 0.85) / 0.05)
}
return -8 * (1 - self.easeInOut((phase - 0.9) / 0.05))
return pose
}
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)
/// Keeps every channel inside the range the canvas can draw without
/// clipping the 120x120 art box (claws touch x=0/120, antennae y~5).
mutating func clampChannels() {
self.floatOffset = self.floatOffset.clamped(to: -12...2)
self.antennaDegrees = self.antennaDegrees.clamped(to: -14...14)
self.antennaDroop = self.antennaDroop.clamped(to: 0...1)
self.leftClawDegrees = self.leftClawDegrees.clamped(to: -45...45)
self.rightClawDegrees = self.rightClawDegrees.clamped(to: -45...45)
self.eyeGlowOpacity = self.eyeGlowOpacity.clamped(to: 0...1)
self.glowScale = self.glowScale.clamped(to: 0.5...1.6)
self.leftEyeOpenness = self.leftEyeOpenness.clamped(to: 0...1)
self.rightEyeOpenness = self.rightEyeOpenness.clamped(to: 0...1)
self.happyEyes = self.happyEyes.clamped(to: 0...1)
self.gaze.width = self.gaze.width.clamped(to: -1.2...1.2)
self.gaze.height = self.gaze.height.clamped(to: -1.2...1.2)
self.mouthCurve = self.mouthCurve.clamped(to: -1...1)
self.mouthOpen = self.mouthOpen.clamped(to: 0...1)
self.mouthRound = self.mouthRound.clamped(to: 0...1)
self.blush = self.blush.clamped(to: 0...1)
self.bodyTilt = self.bodyTilt.clamped(to: -8...8)
self.bodyStretch = self.bodyStretch.clamped(to: 0.86...1.05)
self.dizzy = self.dizzy.clamped(to: 0...1)
}
}
private struct OpenClawMascotCanvas: View {
/// Internal (not private) so tests and render harnesses can draw exact poses.
struct OpenClawMascotCanvas: View {
let pose: OpenClawMascotPose
let palette: OpenClawMascotPalette
@@ -139,11 +254,15 @@ private struct OpenClawMascotCanvas: View {
// 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)
private static let blushColor = Color(red: 1, green: 0.62, blue: 0.68)
private static let heartColor = Color(red: 1, green: 0.45, blue: 0.55)
// 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 leftEyeCenter = CGPoint(x: 45, y: 35)
private static let rightEyeCenter = CGPoint(x: 75, y: 35)
private static let bodyPath: Path = {
var path = Path()
@@ -206,10 +325,24 @@ private struct OpenClawMascotCanvas: View {
let scale = min(size.width, size.height) / 120
context.scaleBy(x: scale, y: scale)
// Squash-and-stretch about the feet (y=110); x compensates slightly to
// conserve volume without pushing the claws past the 0/120 box edges.
if pose.bodyStretch != 1 {
let stretchX = (1 + (1 - pose.bodyStretch) * 0.5).clamped(to: 0.97...1.03)
context.translateBy(x: 60, y: 110)
context.scaleBy(x: stretchX, y: pose.bodyStretch)
context.translateBy(x: -60, y: -110)
}
if pose.bodyTilt != 0 {
context.translateBy(x: 60, y: 60)
context.rotate(by: .degrees(pose.bodyTilt))
context.translateBy(x: -60, y: -60)
}
// Site antennae: stroke-width 2, `--coral-bright`.
let antennaStroke = StrokeStyle(lineWidth: 2, lineCap: .round)
// Same paint order as favicon.svg: body, claws, antennae, eyes.
// Same paint order as favicon.svg: body, claws, antennae, then face.
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))
@@ -217,25 +350,241 @@ private struct OpenClawMascotCanvas: View {
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)
// Droop folds each antenna down around its base (where it meets the
// head) rotating around the stroke center reads startled, not sad
// and damps the wiggle, which keeps its usual center pivot.
let wiggle = pose.antennaDegrees * (1 - pose.antennaDroop)
self.drawRotated(
context: context,
degrees: -pose.antennaDroop * 40,
pivot: CGPoint(x: 45, y: 15))
{
self.drawRotated(context: $0, degrees: wiggle, 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)
self.drawRotated(
context: context,
degrees: pose.antennaDroop * 40,
pivot: CGPoint(x: 75, y: 15))
{
self.drawRotated(context: $0, degrees: wiggle, 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))
self.drawBlush(context: context, pose: pose)
self.drawEye(context: context, center: self.leftEyeCenter, openness: pose.leftEyeOpenness, pose: pose)
self.drawEye(context: context, center: self.rightEyeCenter, openness: pose.rightEyeOpenness, pose: pose)
self.drawMouth(context: context, pose: pose)
self.drawEffect(context: context, pose: pose, palette: palette)
}
private static func drawEye(
context: GraphicsContext,
center: CGPoint,
openness: CGFloat,
pose: OpenClawMascotPose)
{
let shifted = CGPoint(
x: center.x + pose.gaze.width * 2.0,
y: center.y + pose.gaze.height * 1.5)
// Open eye: the upper lid closes downward, so the visible sliver
// keeps its lower edge as openness shrinks. The ellipse also collapses
// while the happy arc fades in, so the crossfade reads as one morph.
if pose.happyEyes < 1 {
let height = max(1.2, 12 * openness * (1 - 0.6 * pose.happyEyes))
let rect = CGRect(
x: shifted.x - 6,
y: shifted.y - 6 + (12 - height) * 0.65,
width: 12,
height: height)
var eyeContext = context
eyeContext.opacity = Double(1 - pose.happyEyes)
eyeContext.fill(Path(ellipseIn: rect), with: .color(self.eyeColor))
}
// Happy eyes: arcs replace the open ellipse.
if pose.happyEyes > 0 {
var arc = Path()
arc.move(to: CGPoint(x: shifted.x - 6, y: shifted.y + 2))
arc.addQuadCurve(
to: CGPoint(x: shifted.x + 6, y: shifted.y + 2),
control: CGPoint(x: shifted.x, y: shifted.y - 5.5))
var arcContext = context
arcContext.opacity = Double(pose.happyEyes)
arcContext.stroke(
arc,
with: .color(self.eyeColor),
style: StrokeStyle(lineWidth: 2.6, lineCap: .round))
}
if pose.dizzy > 0 {
// Orbiting dot the classic seeing-stars spiral.
let angle = pose.dizzyPhase * 2 * .pi + (center.x > 60 ? .pi : 0)
let dot = CGPoint(
x: shifted.x + cos(angle) * 3.4,
y: shifted.y + sin(angle) * 2.6)
var dizzyContext = context
dizzyContext.opacity = Double(pose.dizzy)
dizzyContext.fill(
Path(ellipseIn: CGRect(x: dot.x - 1.8, y: dot.y - 1.8, width: 3.6, height: 3.6)),
with: .color(self.eyeGlowColor))
}
let glowVisibility = Double(
pose.eyeGlowOpacity * openness * (1 - pose.happyEyes) * (1 - pose.dizzy))
guard glowVisibility > 0.01 else { return }
let glowRadius = 2 * pose.glowScale
let glowCenter = CGPoint(
x: shifted.x + 1 + pose.gaze.width * 1.2,
y: shifted.y - 1 + pose.gaze.height * 0.9)
var glowContext = context
glowContext.opacity = pose.eyeGlowOpacity
glowContext.opacity = glowVisibility
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)),
Path(ellipseIn: CGRect(
x: glowCenter.x - glowRadius,
y: glowCenter.y - glowRadius,
width: glowRadius * 2,
height: glowRadius * 2)),
with: .color(self.eyeGlowColor))
}
private static func drawMouth(context: GraphicsContext, pose: OpenClawMascotPose) {
if pose.mouthRound > 0.05 {
let rx = 1 + 3.2 * pose.mouthRound
let ry = 1 + 4.2 * pose.mouthRound
context.fill(
Path(ellipseIn: CGRect(x: 60 - rx, y: 51 - ry, width: rx * 2, height: ry * 2)),
with: .color(self.eyeColor))
return
}
if pose.mouthOpen > 0.05 {
var grin = Path()
grin.move(to: CGPoint(x: 52.5, y: 48.5))
grin.addQuadCurve(
to: CGPoint(x: 67.5, y: 48.5),
control: CGPoint(x: 60, y: 48.5 + 14 * pose.mouthOpen))
grin.closeSubpath()
context.fill(grin, with: .color(self.eyeColor))
return
}
guard abs(pose.mouthCurve) > 0.05 else { return }
var curve = Path()
curve.move(to: CGPoint(x: 52.5, y: 49))
curve.addQuadCurve(
to: CGPoint(x: 67.5, y: 49),
control: CGPoint(x: 60, y: 49 + 8 * pose.mouthCurve))
context.stroke(
curve,
with: .color(self.eyeColor),
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
}
private static func drawBlush(context: GraphicsContext, pose: OpenClawMascotPose) {
guard pose.blush > 0.02 else { return }
var blushContext = context
blushContext.opacity = Double(pose.blush * 0.55)
for x: CGFloat in [37, 83] {
blushContext.fill(
Path(ellipseIn: CGRect(x: x - 4.5, y: 42.5, width: 9, height: 5)),
with: .color(self.blushColor))
}
}
private static func drawEffect(
context: GraphicsContext,
pose: OpenClawMascotPose,
palette: OpenClawMascotPalette)
{
switch pose.effect {
case .none:
return
case .sparkles:
for index in 0..<6 {
let phase = (pose.effectPhase + CGFloat(index) * 0.37)
.truncatingRemainder(dividingBy: 1)
let alpha = OpenClawMascotGesture.bell(phase)
guard alpha > 0.05 else { continue }
// Fan the stars across the upper hemisphere.
let angle = CGFloat.pi + CGFloat.pi * (CGFloat(index) + 0.5) / 6
let center = CGPoint(
x: 60 + cos(angle) * (50 + CGFloat(index % 3) * 4),
y: 55 + sin(angle) * (40 + CGFloat((index * 5) % 3) * 4))
var starContext = context
starContext.opacity = Double(alpha)
starContext.fill(
self.sparklePath(center: center, size: 2.5 + 2 * alpha),
with: .color(index.isMultiple(of: 2) ? self.eyeGlowColor : palette.antenna))
}
case .hearts:
for index in 0..<3 {
let phase = (pose.effectPhase * 1.15 + CGFloat(index) / 3)
.truncatingRemainder(dividingBy: 1)
let alpha = phase < 0.15 ? phase / 0.15 : 1 - (phase - 0.15) / 0.85
guard alpha > 0.05 else { continue }
// Rise from the shoulders outward so they never cover the eyes.
let center = CGPoint(
x: 60 + CGFloat(index - 1) * 26 + 4 * sin(phase * 2 * .pi + CGFloat(index)),
y: 30 - 28 * phase)
var heartContext = context
heartContext.opacity = Double(alpha)
heartContext.fill(
self.heartPath(center: center, size: 4.5 + CGFloat(index % 2) * 1.5),
with: .color(self.heartColor))
}
case .zzz:
for index in 0..<3 {
let phase = (pose.effectPhase + CGFloat(index) * 0.33)
.truncatingRemainder(dividingBy: 1)
let alpha = phase < 0.2 ? phase / 0.2 : 1 - (phase - 0.2) / 0.8
guard alpha > 0.05 else { continue }
let position = CGPoint(
x: 86 + 14 * phase + 2 * sin(phase * 4 * .pi),
y: 24 - 20 * phase)
var text = context.resolve(
Text("z")
.font(.system(size: 6 + 4 * phase, weight: .bold, design: .rounded)))
text.shading = .color(self.eyeGlowColor)
var zContext = context
zContext.opacity = Double(alpha * 0.9)
zContext.draw(text, at: position)
}
}
}
/// Four-point sparkle: concave diamond built from quad curves.
private static func sparklePath(center: CGPoint, size: CGFloat) -> Path {
var path = Path()
path.move(to: CGPoint(x: center.x, y: center.y - size))
for (dx, dy) in [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)] {
path.addQuadCurve(
to: CGPoint(x: center.x + size * dx, y: center.y + size * dy),
control: center)
}
path.closeSubpath()
return path
}
/// Small heart: two lobes plus a point, forgiving at 4-6px sizes.
private static func heartPath(center: CGPoint, size: CGFloat) -> Path {
var path = Path()
let lobeRadius = size * 0.4
for side: CGFloat in [-0.35, 0.35] {
path.addEllipse(in: CGRect(
x: center.x + side * size - lobeRadius,
y: center.y - size * 0.25 - lobeRadius,
width: lobeRadius * 2,
height: lobeRadius * 2))
}
path.move(to: CGPoint(x: center.x - size * 0.7, y: center.y - size * 0.05))
path.addLine(to: CGPoint(x: center.x + size * 0.7, y: center.y - size * 0.05))
path.addLine(to: CGPoint(x: center.x, y: center.y + size * 0.75))
path.closeSubpath()
return path
}
/// 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.

View File

@@ -0,0 +1,177 @@
import Foundation
import Testing
@testable import OpenClawChatUI
struct OpenClawMascotAnimatorTests {
private func makeAnimator(seed: UInt64 = 7, interactive: Bool = false) -> OpenClawMascotAnimator {
OpenClawMascotAnimator(seed: seed, hourOfDay: 12, allowsAutoSleep: interactive)
}
@Test func `poses stay inside drawable bounds for every mood`() {
for mood in OpenClawMascotMood.allCases {
let animator = self.makeAnimator()
_ = animator.pose(at: 0)
animator.setMood(mood, at: 0)
var time: TimeInterval = 0
while time < 30 {
let pose = animator.pose(at: time)
#expect(pose.floatOffset.isFinite, "\(mood)")
#expect((-12...2).contains(pose.floatOffset), "\(mood)")
#expect((0.86...1.05).contains(pose.bodyStretch), "\(mood)")
#expect((-8...8).contains(pose.bodyTilt), "\(mood)")
#expect((0...1).contains(pose.leftEyeOpenness), "\(mood)")
#expect((0...1).contains(pose.rightEyeOpenness), "\(mood)")
#expect((0...1).contains(pose.eyeGlowOpacity), "\(mood)")
#expect((-45...45).contains(pose.leftClawDegrees), "\(mood)")
#expect((-45...45).contains(pose.rightClawDegrees), "\(mood)")
#expect(abs(pose.gaze.width) <= 1.2 && abs(pose.gaze.height) <= 1.2, "\(mood)")
time += 1.0 / 30
}
}
}
@Test func `idle blinks are occasional not constant`() {
let animator = self.makeAnimator()
var minOpenness: CGFloat = 1
var opennessSum: CGFloat = 0
var samples = 0
var time: TimeInterval = 0
while time < 12 {
let pose = animator.pose(at: time)
minOpenness = min(minOpenness, pose.leftEyeOpenness)
opennessSum += pose.leftEyeOpenness
samples += 1
time += 1.0 / 30
}
#expect(minOpenness < 0.5, "expected at least one blink within 12s")
#expect(opennessSum / CGFloat(samples) > 0.8, "eyes should be mostly open")
}
@Test func `celebrating entrance raises claws`() {
let animator = self.makeAnimator()
_ = animator.pose(at: 0)
animator.setMood(.celebrating, at: 1)
var raised = false
var time: TimeInterval = 1
while time < 2.5 {
let pose = animator.pose(at: time)
if pose.leftClawDegrees > 15, pose.rightClawDegrees < -15 {
raised = true
}
time += 1.0 / 30
}
#expect(raised)
}
@Test func `sad mood droops and frowns`() {
let animator = self.makeAnimator()
_ = animator.pose(at: 0)
animator.setMood(.sad, at: 1)
let pose = animator.pose(at: 5)
#expect(pose.antennaDroop > 0.5)
#expect(pose.mouthCurve < 0)
#expect(pose.eyeGlowOpacity < 0.9)
}
@Test func `affection taps trigger hearts`() {
let animator = self.makeAnimator()
_ = animator.pose(at: 0)
animator.handleTap(at: 1.0)
animator.handleTap(at: 1.3)
animator.handleTap(at: 1.6)
let pose = animator.pose(at: 2.2)
#expect(pose.effect == .hearts)
#expect(pose.blush > 0)
}
@Test func `rapid taps make dizzy then recover`() {
let animator = self.makeAnimator()
_ = animator.pose(at: 0)
for index in 0..<7 {
animator.handleTap(at: 1.0 + Double(index) * 0.2)
}
let dizzyPose = animator.pose(at: 3.0)
#expect(dizzyPose.dizzy > 0.5)
let recoveredPose = animator.pose(at: 8.0)
#expect(recoveredPose.dizzy == 0)
}
@Test func `interactive idle mascot dozes off and wakes on tap`() {
let animator = self.makeAnimator(interactive: true)
_ = animator.pose(at: 0)
// Max auto-sleep delay is 80s; 200s is safely asleep for any seed.
let sleeping = animator.pose(at: 200)
#expect(sleeping.leftEyeOpenness < 0.5)
#expect(sleeping.effect == .zzz)
animator.handleTap(at: 201)
let awake = animator.pose(at: 201.05)
#expect(awake.effect != .zzz)
#expect(awake.leftEyeOpenness > 0.5)
}
@Test func `hovering does not wake a sleeping mascot`() {
let animator = self.makeAnimator(interactive: true)
_ = animator.pose(at: 0)
_ = animator.pose(at: 200)
animator.setPointerTarget(CGSize(width: 1, height: 0), at: 200.1)
let pose = animator.pose(at: 200.2)
#expect(pose.effect == .zzz)
}
@Test func `non-interactive mascot never sleeps it has no wake path`() {
let animator = self.makeAnimator(interactive: false)
_ = animator.pose(at: 0)
let pose = animator.pose(at: 500)
#expect(pose.effect != .zzz)
#expect(pose.leftEyeOpenness > 0.5)
}
@Test func `pointer target steers gaze`() {
let animator = self.makeAnimator()
_ = animator.pose(at: 0)
animator.setPointerTarget(CGSize(width: 1, height: 0), at: 0.1)
var time: TimeInterval = 0.1
while time < 2 {
_ = animator.pose(at: time)
time += 1.0 / 30
}
let pose = animator.pose(at: 2)
#expect(pose.gaze.width > 0.6)
}
@Test func `same seed produces identical behavior`() {
let first = self.makeAnimator(seed: 42)
let second = self.makeAnimator(seed: 42)
var time: TimeInterval = 0
while time < 10 {
#expect(first.pose(at: time) == second.pose(at: time))
time += 1.0 / 30
}
}
@Test func `static poses carry the mood signature`() {
let sad = OpenClawMascotPose.staticPose(for: .sad)
#expect(sad.antennaDroop > 0)
#expect(sad.mouthCurve < 0)
let celebrating = OpenClawMascotPose.staticPose(for: .celebrating)
#expect(celebrating.leftClawDegrees > 0)
#expect(celebrating.mouthCurve > 0)
let idle = OpenClawMascotPose.staticPose(for: .idle)
#expect(idle == .still)
}
@Test func `clamp channels bounds every channel`() {
var pose = OpenClawMascotPose()
pose.floatOffset = -100
pose.bodyStretch = 3
pose.bodyTilt = -90
pose.leftClawDegrees = 400
pose.gaze = CGSize(width: 9, height: -9)
pose.clampChannels()
#expect(pose.floatOffset == -12)
#expect(pose.bodyStretch == 1.05)
#expect(pose.bodyTilt == -8)
#expect(pose.leftClawDegrees == 45)
#expect(pose.gaze == CGSize(width: 1.2, height: -1.2))
}
}