Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift
Peter Steinberger f9ebefba29 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
2026-07-11 01:01:26 -07:00

133 lines
4.3 KiB
Swift

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, mood: OpenClawMascotMood = .idle) {
self.size = size
self.mood = mood
}
var body: some View {
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
}
}
}