Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
Peter Steinberger 05c9dccf35 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.
2026-07-05 01:13:26 -07:00

251 lines
9.2 KiB
Swift

import AppKit
import SwiftUI
extension OnboardingView {
var body: some View {
VStack(spacing: 0) {
GlowingOpenClawIcon(size: 130)
.offset(y: 10)
.frame(height: 145)
GeometryReader { _ in
HStack(spacing: 0) {
ForEach(self.pageOrder, id: \.self) { pageIndex in
self.pageView(for: pageIndex)
.frame(width: self.pageWidth)
}
}
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
.animation(
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: self.currentPage)
.frame(height: self.contentHeight, alignment: .top)
.clipped()
}
.frame(height: self.contentHeight)
Spacer(minLength: 0)
self.navigationBar
}
.frame(width: pageWidth, height: Self.windowHeight)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
self.onboardingVisible = true
self.currentPage = 0
self.updateMonitoring(for: 0)
}
.onChange(of: currentPage) { _, newValue in
self.updateMonitoring(for: self.activePageIndex(for: newValue))
}
.onChange(of: state.connectionMode) { _, _ in
let oldActive = self.activePageIndex
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex)
}
.onChange(of: needsBootstrap) { _, _ in
if self.currentPage >= self.pageOrder.count {
self.currentPage = max(0, self.pageOrder.count - 1)
}
}
.onChange(of: cliInstalled) { _, installed in
guard installed else { return }
self.updateMonitoring(for: self.activePageIndex)
}
.onDisappear {
self.onboardingVisible = false
self.stopPermissionMonitoring()
self.stopDiscovery()
}
.task {
await self.refreshPerms()
await self.refreshCLIStatus()
await self.loadWorkspaceDefaults()
await self.ensureDefaultWorkspace()
self.refreshBootstrapStatus()
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
}
}
func activePageIndex(for pageCursor: Int) -> Int {
guard !pageOrder.isEmpty else { return 0 }
let clamped = min(max(0, pageCursor), pageOrder.count - 1)
return pageOrder[clamped]
}
func reconcilePageForModeChange(previousActivePageIndex: Int) {
if let exact = pageOrder.firstIndex(of: previousActivePageIndex) {
withAnimation { self.currentPage = exact }
return
}
if let next = pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) {
withAnimation { self.currentPage = next }
return
}
withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) }
}
var navigationBar: some View {
let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex)
let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex)
let crestodianLockIndex = pageOrder.firstIndex(of: crestodianPageIndex)
return HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
})
.buttonStyle(.plain)
.opacity(0)
.disabled(true)
if self.currentPage > 0 {
Button(action: self.handleBack, label: {
Label("Back", systemImage: "chevron.left")
.labelStyle(.iconOnly)
})
.buttonStyle(.plain)
.foregroundColor(.secondary)
.opacity(0.8)
.disabled(self.installingCLI)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
}
.frame(minWidth: 80, alignment: .leading)
Spacer()
HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in
let isInstallLocked = self.installingCLI && index != self.currentPage
let isConnectionLocked = self.isConnectionSelectionBlocking &&
index > (connectionLockIndex ?? 0)
let isCLILocked = cliLockIndex != nil && !self.cliInstalled && index > (cliLockIndex ?? 0)
// Dots must honor the same setup gate as Next: no jumping
// past the Crestodian page before setup authored the config.
let isCrestodianLocked = crestodianLockIndex != nil &&
self.state.connectionMode == .local &&
!self.crestodianSetupComplete &&
index > (crestodianLockIndex ?? 0)
let isLocked = isInstallLocked || isConnectionLocked || isCLILocked ||
isCrestodianLocked
Button {
withAnimation { self.currentPage = index }
} label: {
Circle()
.fill(index == self.currentPage ? Color.accentColor : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
}
.buttonStyle(.plain)
.disabled(isLocked)
.opacity(isLocked ? 0.3 : 1)
}
}
Spacer()
Button(action: self.handleNext) {
Text(self.buttonTitle)
.frame(minWidth: 88)
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
.disabled(!self.canAdvance)
}
.padding(.horizontal, 28)
.padding(.bottom, 13)
.frame(minHeight: 60, alignment: .bottom)
}
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
let scrollIndicatorGutter: CGFloat = 18
return ScrollView {
VStack(spacing: 16) {
content()
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.trailing, scrollIndicatorGutter)
}
.scrollIndicators(.automatic)
.padding(.horizontal, 28)
.frame(width: pageWidth, alignment: .top)
}
func onboardingCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: spacing) {
content()
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
}
func onboardingGlassCard(
spacing: CGFloat = 12,
padding: CGFloat = 16,
@ViewBuilder _ content: () -> some View) -> some View
{
let shape = RoundedRectangle(cornerRadius: 16, style: .continuous)
return VStack(alignment: .leading, spacing: spacing) {
content()
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.clear)
.clipShape(shape)
.overlay(shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
}
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
self.featureRowContent(title: title, subtitle: subtitle, systemImage: systemImage)
}
func featureActionRow(
title: String,
subtitle: String,
systemImage: String,
buttonTitle: String,
action: @escaping () -> Void) -> some View
{
self.featureRowContent(
title: title,
subtitle: subtitle,
systemImage: systemImage,
action: AnyView(
Button(buttonTitle, action: action)
.buttonStyle(.link)
.padding(.top, 2)))
}
private func featureRowContent(
title: String,
subtitle: String,
systemImage: String,
action: AnyView? = nil) -> some View
{
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
if let action {
action
}
}
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
}