feat(ios): refresh onboarding setup flow (#98868)

This commit is contained in:
Tyler Nishida 2026-07-05 15:38:54 -10:00 committed by GitHub
parent a1f4aa4a3b
commit 2db5bd3394
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2015 additions and 1002 deletions

File diff suppressed because it is too large Load diff

View file

@ -1089,138 +1089,138 @@
}
}
},
"Connect to a Gateway?": {
"Connect a nearby Gateway": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Connect to a Gateway?"
"value": "Connect a nearby Gateway"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "连接到网关?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "要連線到 Gateway 嗎?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Conectar a um Gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Mit einem Gateway verbinden?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "¿Conectar a un Gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "ゲートウェイに接続しますか?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "게이트웨이에 연결할까요?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Se connecter à une passerelle ?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "किसी गेटवे से कनेक्ट करें?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "الاتصال ببوابة؟"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Connettersi a un Gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Bir Gatewaye bağlanılsın mı?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Підключитися до шлюзу?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Hubungkan ke Gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Połączyć z Gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "เชื่อมต่อกับ Gateway หรือไม่?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Kết nối với Gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Verbinden met een gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "به یک Gateway متصل شوید؟"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Подключиться к шлюзу?"
"state": "new",
"value": "Connect a nearby Gateway"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Ansluta till en gateway?"
"state": "new",
"value": "Connect a nearby Gateway"
}
}
}
@ -1769,12 +1769,12 @@
}
}
},
"Dont show this again": {
"Don't show this again": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Dont show this again"
"value": "Don't show this again"
}
},
"zh-CN": {
@ -3265,138 +3265,138 @@
}
}
},
"No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.": {
"Looking for a Gateway": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled."
"value": "Looking for a Gateway"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "尚未找到网关。请确保你的网关正在运行,并且已启用 Bonjour 发现。"
"state": "new",
"value": "Looking for a Gateway"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "尚未找到任何 gateway。請確認你的 gateway 正在執行,且已啟用 Bonjour 探索。"
"state": "new",
"value": "Looking for a Gateway"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Nenhum gateway encontrado ainda. Certifique-se de que seu gateway esteja em execução e que a descoberta Bonjour esteja ativada."
"state": "new",
"value": "Looking for a Gateway"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Noch keine Gateways gefunden. Stelle sicher, dass dein Gateway ausgeführt wird und die Bonjour-Erkennung aktiviert ist."
"state": "new",
"value": "Looking for a Gateway"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Aún no se encontraron gateways. Asegúrate de que tu gateway esté en ejecución y que el descubrimiento Bonjour esté activado."
"state": "new",
"value": "Looking for a Gateway"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "ゲートウェイがまだ見つかりません。ゲートウェイが実行中で、Bonjour 検出が有効になっていることを確認してください。"
"state": "new",
"value": "Looking for a Gateway"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "아직 게이트웨이를 찾을 수 없습니다. 게이트웨이가 실행 중이고 Bonjour 검색이 활성화되어 있는지 확인하세요."
"state": "new",
"value": "Looking for a Gateway"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Aucune passerelle trouvée pour le moment. Assurez-vous que votre passerelle est en cours dexécution et que la découverte Bonjour est activée."
"state": "new",
"value": "Looking for a Gateway"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "अभी तक कोई गेटवे नहीं मिला। सुनिश्चित करें कि आपका गेटवे चल रहा है और Bonjour डिस्कवरी सक्षम है।"
"state": "new",
"value": "Looking for a Gateway"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "لم يتم العثور على أي بوابات بعد. تأكد من أن بوابتك قيد التشغيل وأن اكتشاف Bonjour ممكّن."
"state": "new",
"value": "Looking for a Gateway"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Nessun gateway trovato al momento. Assicurati che il gateway sia in esecuzione e che il rilevamento Bonjour sia abilitato."
"state": "new",
"value": "Looking for a Gateway"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Henüz gateway bulunamadı. Gatewayinizin çalıştığından ve Bonjour keşfinin etkin olduğundan emin olun."
"state": "new",
"value": "Looking for a Gateway"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Шлюзів поки не знайдено. Переконайтеся, що ваш шлюз запущено, а виявлення Bonjour увімкнено."
"state": "new",
"value": "Looking for a Gateway"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Belum ada gateway yang ditemukan. Pastikan gateway Anda berjalan dan penemuan Bonjour diaktifkan."
"state": "new",
"value": "Looking for a Gateway"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Nie znaleziono jeszcze żadnych gatewayów. Upewnij się, że gateway działa i że wykrywanie Bonjour jest włączone."
"state": "new",
"value": "Looking for a Gateway"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ยังไม่พบ gateway ตรวจสอบให้แน่ใจว่า gateway ของคุณกำลังทำงานอยู่และเปิดใช้งานการค้นหา Bonjour แล้ว"
"state": "new",
"value": "Looking for a Gateway"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Chưa tìm thấy gateway nào. Hãy đảm bảo gateway của bạn đang chạy và tính năng khám phá Bonjour được bật."
"state": "new",
"value": "Looking for a Gateway"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Nog geen gateways gevonden. Zorg ervoor dat je gateway actief is en Bonjour-detectie is ingeschakeld."
"state": "new",
"value": "Looking for a Gateway"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "هنوز هیچ gatewayای پیدا نشده است. مطمئن شوید gateway شما در حال اجراست و Bonjour discovery فعال است."
"state": "new",
"value": "Looking for a Gateway"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Шлюзы пока не найдены. Убедитесь, что ваш шлюз запущен и обнаружение Bonjour включено."
"state": "new",
"value": "Looking for a Gateway"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Inga gateways hittades ännu. Kontrollera att din gateway körs och att Bonjour-upptäckt är aktiverad."
"state": "new",
"value": "Looking for a Gateway"
}
}
}
@ -4353,138 +4353,138 @@
}
}
},
"Save": {
"Go to Chat": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Save"
"value": "Go to Chat"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "保存"
"state": "new",
"value": "Go to Chat"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "儲存"
"state": "new",
"value": "Go to Chat"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Salvar"
"state": "new",
"value": "Go to Chat"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Speichern"
"state": "new",
"value": "Go to Chat"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Guardar"
"state": "new",
"value": "Go to Chat"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "保存"
"state": "new",
"value": "Go to Chat"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "저장"
"state": "new",
"value": "Go to Chat"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Enregistrer"
"state": "new",
"value": "Go to Chat"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "सहेजें"
"state": "new",
"value": "Go to Chat"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "حفظ"
"state": "new",
"value": "Go to Chat"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Salva"
"state": "new",
"value": "Go to Chat"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Kaydet"
"state": "new",
"value": "Go to Chat"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Зберегти"
"state": "new",
"value": "Go to Chat"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Simpan"
"state": "new",
"value": "Go to Chat"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Zapisz"
"state": "new",
"value": "Go to Chat"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "บันทึก"
"state": "new",
"value": "Go to Chat"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Lưu"
"state": "new",
"value": "Go to Chat"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Opslaan"
"state": "new",
"value": "Go to Chat"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "ذخیره"
"state": "new",
"value": "Go to Chat"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Сохранить"
"state": "new",
"value": "Go to Chat"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Spara"
"state": "new",
"value": "Go to Chat"
}
}
}

View file

@ -1,4 +1,5 @@
import Observation
import OpenClawChatUI
import SwiftUI
enum AppAppearancePreference: String, CaseIterable, Identifiable {
@ -79,11 +80,9 @@ enum OpenClawBrand {
static let uiAccentForeground = adaptiveUIColor(light: (183, 56, 51), dark: (255, 107, 102))
static let uiAccentHot = adaptiveUIColor(light: (204, 75, 69), dark: (232, 92, 86))
static let uiAccentHotForeground = adaptiveUIColor(light: (166, 55, 50), dark: (255, 123, 115))
static let uiAccentPressed = adaptiveUIColor(light: (148, 40, 36), dark: (148, 40, 36))
static let uiTeal = adaptiveUIColor(light: (0, 196, 176), dark: (0, 196, 176))
static let uiVoid = adaptiveUIColor(light: (246, 247, 249), dark: (11, 12, 17))
static let uiObsidian = adaptiveUIColor(light: (255, 255, 255), dark: (19, 21, 28))
static let uiTextPrimary = adaptiveUIColor(light: (11, 12, 17), dark: (242, 239, 232))
static let uiTextSecondary = adaptiveUIColor(light: (90, 94, 110), dark: (168, 170, 191))
static let uiOK = adaptiveUIColor(light: (19, 122, 62), dark: (48, 209, 88))
static let uiWarn = adaptiveUIColor(light: (154, 87, 0), dark: (255, 214, 10))
@ -94,11 +93,9 @@ enum OpenClawBrand {
static let accentForeground = Color(uiColor: Self.uiAccentForeground)
static let accentHot = Color(uiColor: Self.uiAccentHot)
static let accentHotForeground = Color(uiColor: Self.uiAccentHotForeground)
static let accentPressed = Color(uiColor: Self.uiAccentPressed)
static let teal = Color(uiColor: Self.uiTeal)
static let void = Color(uiColor: Self.uiVoid)
static let obsidian = Color(uiColor: Self.uiObsidian)
static let textPrimary = Color(uiColor: Self.uiTextPrimary)
static let textSecondary = Color(uiColor: Self.uiTextSecondary)
static let danger = Color(uiColor: Self.uiDanger)
static let ok = Color(uiColor: Self.uiOK)
@ -106,6 +103,31 @@ enum OpenClawBrand {
static let info = Color(uiColor: Self.uiInfo)
static let graphite = void
static let graphiteElevated = obsidian
static let activationCanvas = Color(uiColor: adaptiveUIColor(light: (255, 255, 255), dark: (18, 14, 15)))
static let activationSurface = Color(uiColor: adaptiveUIColor(light: (255, 253, 252), dark: (33, 29, 30)))
static let activationSecondaryActionTop = Color(uiColor: adaptiveUIColor(
light: (255, 255, 255),
dark: (36, 32, 33)))
static let activationSecondaryActionBottom = Color(uiColor: adaptiveUIColor(
light: (248, 248, 250),
dark: (30, 27, 28)))
static let activationInsetSurface = Color(uiColor: adaptiveUIColor(light: (246, 241, 238), dark: (44, 37, 38)))
static let activationNeutralSurface = Color(uiColor: adaptiveUIColor(light: (242, 242, 247), dark: (34, 34, 37)))
static let activationNeutralInsetSurface = Color(uiColor: adaptiveUIColor(
light: (247, 247, 249),
dark: (42, 42, 45)))
static let activationNeutralStroke = Color(uiColor: adaptiveUIColor(
light: (0, 0, 0),
dark: (255, 255, 255)).withAlphaComponent(0.08))
static let activationNeutralDivider = Color(uiColor: adaptiveUIColor(
light: (0, 0, 0),
dark: (255, 255, 255)).withAlphaComponent(0.09))
static let activationPrimaryAction = Color(uiColor: adaptiveUIColor(light: (209, 54, 51), dark: (238, 82, 76)))
static let activationPrimaryActionText = Color.white
static let activationHairline = Color(uiColor: adaptiveUIColor(
light: (136, 44, 40),
dark: (255, 210, 205)).withAlphaComponent(0.13))
static let activationGlow = Color(uiColor: adaptiveUIColor(light: (228, 78, 67), dark: (255, 111, 96)))
static var sheetBackground: LinearGradient {
LinearGradient(
@ -118,6 +140,59 @@ enum OpenClawBrand {
endPoint: .bottomTrailing)
}
static var activationCanvasGradient: LinearGradient {
LinearGradient(
colors: [
activationCanvas,
activationCanvas,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static var activationInsetGradient: LinearGradient {
LinearGradient(
colors: [
activationInsetSurface.opacity(0.94),
activationInsetSurface,
activationSurface.opacity(0.36),
],
startPoint: .top,
endPoint: .bottom)
}
static var activationNeutralGradient: LinearGradient {
LinearGradient(
colors: [
activationNeutralInsetSurface,
activationNeutralSurface,
],
startPoint: .top,
endPoint: .bottom)
}
static var activationPrimaryGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 1.0, green: 0.42, blue: 0.34),
activationGlow,
activationPrimaryAction,
Color(red: 0.66, green: 0.12, blue: 0.12),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static var activationDisabledGradient: LinearGradient {
LinearGradient(
colors: [
activationNeutralInsetSurface,
activationNeutralSurface.opacity(0.92),
],
startPoint: .top,
endPoint: .bottom)
}
private static func adaptiveUIColor(
light: (red: CGFloat, green: CGFloat, blue: CGFloat),
dark: (red: CGFloat, green: CGFloat, blue: CGFloat)) -> UIColor
@ -133,6 +208,21 @@ enum OpenClawBrand {
}
}
struct OpenClawActivationGlyph: View {
let size: CGFloat
var body: some View {
OpenClawMascotView(floats: false)
.frame(width: self.size, height: self.size)
.shadow(
color: OpenClawBrand.activationGlow.opacity(0.18),
radius: self.size * 0.12,
x: 0,
y: self.size * 0.05)
.accessibilityHidden(true)
}
}
extension View {
func openClawSheetChrome() -> some View {
self
@ -142,4 +232,222 @@ extension View {
.ignoresSafeArea()
}
}
func openClawCraftSurface(cornerRadius: CGFloat = 24, shadow: Bool = true) -> some View {
self.modifier(OpenClawCraftSurfaceModifier(cornerRadius: cornerRadius, shadow: shadow))
}
}
private struct OpenClawCraftSurfaceModifier: ViewModifier {
let cornerRadius: CGFloat
let shadow: Bool
func body(content: Content) -> some View {
content
.background {
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.fill(OpenClawBrand.activationSurface)
.shadow(
color: self.shadow ? Color.black.opacity(0.07) : .clear,
radius: 16,
x: 0,
y: 8)
}
.overlay(alignment: .top) {
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.36), lineWidth: 0.5)
.blendMode(.plusLighter)
}
.overlay {
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.stroke(OpenClawBrand.activationHairline, lineWidth: 0.5)
}
}
}
struct OpenClawPrimaryActionButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
var height: CGFloat = 54
var cornerRadius: CGFloat = 18
private var resolvedCornerRadius: CGFloat {
self.height / 2
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(self.isEnabled ? OpenClawBrand.activationPrimaryActionText : Color.secondary)
.tint(self.isEnabled ? OpenClawBrand.activationPrimaryActionText : Color.secondary)
.frame(maxWidth: .infinity)
.frame(height: self.height)
.background {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.fill(self.isEnabled ? Self.primaryFill : OpenClawBrand.activationDisabledGradient)
.shadow(
color: self.isEnabled ? OpenClawBrand.activationPrimaryAction.opacity(0.08) : .clear,
radius: 1,
x: 0,
y: 1)
.shadow(
color: self.isEnabled ? OpenClawBrand.activationPrimaryAction.opacity(0.08) : .clear,
radius: 2,
x: 0,
y: 2)
}
.overlay(alignment: .top) {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.white.opacity(self.isEnabled ? 0.14 : 0.06),
Color.white.opacity(0),
],
startPoint: .top,
endPoint: .center))
.frame(height: self.height * 0.48)
.allowsHitTesting(false)
}
.overlay(alignment: .bottom) {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.white.opacity(0),
Color.white.opacity(self.isEnabled ? 0.08 : 0.03),
],
startPoint: .top,
endPoint: .bottom))
.frame(height: self.height * 0.34)
.allowsHitTesting(false)
}
.overlay {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.stroke(
self.isEnabled
? OpenClawBrand.activationPrimaryAction.opacity(0.72)
: OpenClawBrand.activationNeutralStroke,
lineWidth: 0.75)
}
.scaleEffect(configuration.isPressed && self.isEnabled ? 0.98 : 1)
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
}
private static var primaryFill: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.93, green: 0.27, blue: 0.25),
Color(red: 0.91, green: 0.25, blue: 0.24),
],
startPoint: .top,
endPoint: .bottom)
}
}
struct OpenClawSecondaryActionButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
var height: CGFloat = 50
var cornerRadius: CGFloat = 18
var shadowOpacity: Double = 0.035
private var resolvedCornerRadius: CGFloat {
self.height / 2
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(self.isEnabled ? OpenClawBrand.activationPrimaryAction : .secondary)
.frame(maxWidth: .infinity)
.frame(height: self.height)
.background {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.fill(Self.secondaryFill)
.shadow(
color: self.isEnabled ? Color.black.opacity(self.shadowOpacity) : .clear,
radius: 1,
x: 0,
y: 1)
.shadow(
color: self.isEnabled ? Color.black.opacity(self.shadowOpacity) : .clear,
radius: 2,
x: 0,
y: 2)
}
.overlay(alignment: .top) {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.white.opacity(0.42),
Color.white.opacity(0),
],
startPoint: .top,
endPoint: .center))
.frame(height: self.height * 0.48)
.allowsHitTesting(false)
}
.overlay {
RoundedRectangle(cornerRadius: self.resolvedCornerRadius, style: .continuous)
.stroke(OpenClawBrand.activationHairline, lineWidth: 0.75)
}
.scaleEffect(configuration.isPressed && self.isEnabled ? 0.98 : 1)
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
}
private static var secondaryFill: LinearGradient {
LinearGradient(
colors: [
OpenClawBrand.activationSecondaryActionTop,
OpenClawBrand.activationSecondaryActionBottom,
],
startPoint: .top,
endPoint: .bottom)
}
}
struct OpenClawCloseButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
var minWidth: CGFloat = 36
var height: CGFloat = 36
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(
self.isEnabled
? OpenClawBrand.activationPrimaryAction
: Color.secondary)
.fixedSize(horizontal: true, vertical: false)
.frame(minWidth: self.minWidth)
.frame(height: self.height)
.padding(.horizontal, 7)
.background {
Capsule(style: .continuous)
.fill(OpenClawBrand.activationNeutralGradient)
.shadow(
color: self.isEnabled ? Color.black.opacity(0.045) : .clear,
radius: 1,
x: 0,
y: 1)
.shadow(
color: self.isEnabled ? Color.black.opacity(0.03) : .clear,
radius: 4,
x: 0,
y: 2)
}
.overlay(alignment: .top) {
Capsule(style: .continuous)
.stroke(Color.white.opacity(0.55), lineWidth: 0.5)
.blendMode(.plusLighter)
}
.overlay {
Capsule(style: .continuous)
.stroke(OpenClawBrand.activationNeutralStroke, lineWidth: 0.6)
}
.contentShape(Capsule(style: .continuous))
.opacity(configuration.isPressed && self.isEnabled ? 0.66 : 1)
.scaleEffect(configuration.isPressed && self.isEnabled ? 0.98 : 1)
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
}
}

View file

@ -21,7 +21,6 @@ enum OpenClawRadius {
static let xs: CGFloat = 8
static let sm: CGFloat = 10
static let md: CGFloat = 12
static let lg: CGFloat = 16
}
struct OpenClawProBackground: View {
@ -561,62 +560,6 @@ struct OpenClawStatusBadge: View {
}
}
struct OpenClawPrimaryButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(OpenClawType.headline)
.foregroundStyle(self.isEnabled ? Color.white : OpenClawBrand.textSecondary)
.frame(maxWidth: .infinity, minHeight: 48)
.background {
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
.fill(
!self.isEnabled
? Color(uiColor: .tertiarySystemFill)
: configuration.isPressed
? OpenClawBrand.accentPressed
: OpenClawBrand.accent)
}
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
.animation(.easeOut(duration: 0.15), value: self.isEnabled)
}
}
struct OpenClawSecondaryButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(OpenClawType.headline)
.foregroundStyle(self.isEnabled ? OpenClawBrand.textPrimary : OpenClawBrand.textSecondary.opacity(0.68))
.frame(maxWidth: .infinity, minHeight: 48)
.background {
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
.fill(Color(uiColor: self.isEnabled ? .secondarySystemBackground : .tertiarySystemFill))
.overlay {
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
.strokeBorder(
Color(uiColor: .separator).opacity(self.isEnabled ? 0.35 : 0.20),
lineWidth: 1)
}
}
.opacity(!self.isEnabled ? 0.74 : configuration.isPressed ? 0.82 : 1)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
.animation(.easeOut(duration: 0.15), value: self.isEnabled)
}
}
extension View {
func openClawPrimaryButton() -> some View {
self.buttonStyle(OpenClawPrimaryButtonStyle())
}
func openClawSecondaryButton() -> some View {
self.buttonStyle(OpenClawSecondaryButtonStyle())
}
}
struct ProStatusDot: View {
var color: Color

View file

@ -129,10 +129,6 @@ enum OpenClawType {
scaledMono(name: Mono.regular, size: 13, relativeTo: .footnote)
}
static var monoHeadline: Font {
scaledMono(name: Mono.medium, size: 17, relativeTo: .headline)
}
/// PostScript names for bundled fonts. Keep aligned with `UIAppFonts` in `project.yml`.
static let registeredPostScriptNames: [String] = [
Display.postScriptName,

View file

@ -85,9 +85,13 @@ struct TalkSiriWaveView: View {
// across the line instead of scrolling off-screen.
let lobes: [(A: Double, k: Double, t: Double)] = (0..<3).map { index in
let f = Double(index)
let amp = 0.30 + 0.70 * (0.5 + 0.5 * sin(time * (0.9 + 0.23 * f) + seed * 2.4 + f * 2.1))
let ampFrequency = 0.9 + 0.23 * f
let ampPhase = time * ampFrequency + seed * 2.4 + f * 2.1
let amp = 0.30 + 0.70 * (0.5 + 0.5 * sin(ampPhase))
let k = 0.62 + 0.11 * f
let t = 2.8 * sin(time * (0.45 + 0.17 * f) + seed + f * 1.9)
let driftFrequency = 0.45 + 0.17 * f
let driftPhase = time * driftFrequency + seed + f * 1.9
let t = 2.8 * sin(driftPhase)
return (A: amp, k: k, t: t)
}

View file

@ -1,6 +1,10 @@
import OpenClawKit
import SwiftUI
#if DEBUG
import Network
#endif
struct GatewayQuickSetupSheet: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@ -13,95 +17,88 @@ struct GatewayQuickSetupSheet: View {
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text("Connect to a Gateway?")
.font(OpenClawType.title2)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
GatewayQuickSetupHeader(hasCandidate: self.bestCandidate != nil)
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if let candidate = self.bestCandidate {
GatewayQuickSetupCandidatePanel(
name: candidate.name,
debugID: candidate.debugID,
discoveryStatusText: self.gatewayController.discoveryStatusText,
gatewayDisplayStatusText: self.appModel.gatewayDisplayStatusText,
nodeStatusText: self.appModel.nodeStatusText,
operatorStatusText: self.appModel.operatorStatusText)
if let candidate = self.bestCandidate {
GatewayQuickSetupCandidatePanel(
name: candidate.name,
debugID: candidate.debugID,
tlsEnabled: candidate.tlsEnabled,
gatewayPort: candidate.gatewayPort,
discoveryStatusText: self.gatewayController.discoveryStatusText,
gatewayDisplayStatusText: self.appModel.gatewayDisplayStatusText,
nodeStatusText: self.appModel.nodeStatusText,
operatorStatusText: self.appModel.operatorStatusText)
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
}
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
.font(OpenClawType.headline)
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
}
} else {
Text("Connect")
.font(OpenClawType.headline)
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Connect to this Gateway")
.font(OpenClawType.subheadSemiBold)
}
}
.frame(maxWidth: .infinity)
}
}
.font(OpenClawType.headline)
.openClawPrimaryButton()
.disabled(self.connecting)
.buttonStyle(OpenClawPrimaryActionButtonStyle())
.disabled(self.connecting)
if let connectError {
Text(connectError)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let connectError {
GatewayQuickSetupErrorView(message: connectError)
}
Button {
self.dismiss()
} label: {
Text("Not now")
.font(OpenClawType.headline)
}
.font(OpenClawType.headline)
.openClawSecondaryButton()
.disabled(self.connecting)
Button {
self.dismiss()
} label: {
Text("Not now")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(OpenClawSecondaryActionButtonStyle())
.disabled(self.connecting)
self.fullRowToggle("Dont show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 4)
} else {
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
self.fullRowToggle("Don't show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 2)
} else {
GatewayQuickSetupEmptyState(
discoveryStatusText: self.gatewayController.discoveryStatusText)
}
}
Spacer()
.padding(20)
}
.font(OpenClawType.body)
.padding()
.background(OpenClawBrand.activationCanvasGradient)
.navigationTitle("Quick Setup")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Quick Setup")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.quickSetupDismissed = true
@ -110,7 +107,7 @@ struct GatewayQuickSetupSheet: View {
Text("Close")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.buttonStyle(OpenClawCloseButtonStyle())
}
}
}
@ -127,7 +124,6 @@ struct GatewayQuickSetupSheet: View {
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
// Prefer whatever discovery says is first; the list is already name-sorted.
self.gatewayController.gateways.first
}
@ -136,10 +132,8 @@ struct GatewayQuickSetupSheet: View {
Text(title)
.font(OpenClawType.subhead)
}
.font(OpenClawType.subhead)
.contentShape(Rectangle())
.overlay {
// Keep Toggle semantics for accessibility while making the full visual row tappable.
Button {
isOn.wrappedValue.toggle()
} label: {
@ -147,7 +141,6 @@ struct GatewayQuickSetupSheet: View {
.fill(.clear)
.contentShape(Rectangle())
}
.font(OpenClawType.subhead)
.buttonStyle(.plain)
.accessibilityHidden(true)
}
@ -175,46 +168,291 @@ struct GatewayQuickSetupSheet: View {
}
}
private struct GatewayQuickSetupCandidatePanel: View {
private static let readableMonospaceWidth: CGFloat = 72 * 8
private struct GatewayQuickSetupHeader: View {
let hasCandidate: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
ZStack(alignment: .bottomTrailing) {
OpenClawActivationGlyph(size: 70)
.shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 10, x: 0, y: 5)
Image(systemName: "antenna.radiowaves.left.and.right")
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(OpenClawBrand.activationPrimaryActionText)
.frame(width: 28, height: 28)
.background {
Circle()
.fill(OpenClawBrand.activationPrimaryGradient)
}
.overlay {
Circle()
.stroke(OpenClawBrand.activationCanvas, lineWidth: 3)
}
.offset(x: 4, y: 4)
}
VStack(alignment: .leading, spacing: 6) {
Text("Connect a nearby Gateway")
.font(OpenClawType.title2SemiBold)
Text(self.subtitle)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var subtitle: LocalizedStringKey {
if self.hasCandidate {
return "OpenClaw found a gateway on this network. Review it, then pair this iPhone as a secure node."
}
return "OpenClaw is searching the local network and tailnet for a Gateway you can trust."
}
}
private struct GatewayQuickSetupCandidatePanel: View {
let name: String
let debugID: String
let tlsEnabled: Bool
let gatewayPort: Int?
let discoveryStatusText: String
let gatewayDisplayStatusText: String
let nodeStatusText: String
let operatorStatusText: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: self.name)
.font(OpenClawType.monoHeadline)
.foregroundStyle(.primary)
Text(verbatim: self.debugID)
.font(OpenClawType.monoFootnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "network")
.font(OpenClawType.headline)
.foregroundStyle(OpenClawBrand.activationPrimaryActionText)
.frame(width: 36, height: 36)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(OpenClawBrand.activationPrimaryGradient)
}
.shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 6, x: 0, y: 3)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.discoveryStatusText)")
.font(OpenClawType.monoFootnote)
Text(verbatim: "Status: \(self.gatewayDisplayStatusText)")
.font(OpenClawType.monoFootnote)
Text(verbatim: "Node: \(self.nodeStatusText)")
.font(OpenClawType.monoFootnote)
Text(verbatim: "Operator: \(self.operatorStatusText)")
.font(OpenClawType.monoFootnote)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(verbatim: self.name)
.font(OpenClawType.headline)
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.middle)
GatewayQuickSetupChip(text: self.tlsEnabled ? "Secure" : "Local")
}
Text(verbatim: self.endpointSummary)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
GatewayQuickSetupHairlineDivider()
VStack(spacing: 8) {
GatewayQuickSetupStatusRow(title: "Discovery", value: self.discoveryStatusText)
GatewayQuickSetupStatusRow(title: "Gateway", value: self.gatewayDisplayStatusText)
GatewayQuickSetupStatusRow(title: "Node", value: self.nodeStatusText)
GatewayQuickSetupStatusRow(title: "Operator", value: self.operatorStatusText)
}
.font(OpenClawType.monoFootnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: Self.readableMonospaceWidth, alignment: .leading)
.padding(.vertical, 14)
.padding(.horizontal, 16)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.background(OpenClawBrand.obsidian)
.clipShape(RoundedRectangle(cornerRadius: OpenClawRadius.lg))
.openClawCraftSurface(cornerRadius: 22)
.accessibilityElement(children: .combine)
}
private var endpointSummary: String {
let port = self.gatewayPort.map { ":\($0)" } ?? ""
return "\(self.debugID)\(port)"
}
}
private struct GatewayQuickSetupHairlineDivider: View {
var body: some View {
Rectangle()
.fill(OpenClawBrand.activationNeutralDivider)
.frame(height: 0.5)
.frame(maxWidth: .infinity)
}
}
private struct GatewayQuickSetupChip: View {
let text: LocalizedStringKey
var body: some View {
Text(self.text)
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(OpenClawBrand.activationPrimaryAction)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background {
Capsule(style: .continuous)
.fill(OpenClawBrand.activationGlow.opacity(0.10))
}
.overlay {
Capsule(style: .continuous)
.stroke(OpenClawBrand.activationHairline, lineWidth: 0.6)
}
}
}
private struct GatewayQuickSetupStatusRow: View {
let title: LocalizedStringKey
let value: String
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(self.title)
.font(OpenClawType.captionMedium)
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
Text(verbatim: self.value)
.font(OpenClawType.footnote)
.foregroundStyle(.primary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private struct GatewayQuickSetupErrorView: View {
let message: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawBrand.warn)
.padding(.top, 1)
Text(self.message)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(OpenClawBrand.activationInsetGradient)
}
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(OpenClawBrand.activationHairline, lineWidth: 0.7)
}
}
}
private struct GatewayQuickSetupEmptyState: View {
let discoveryStatusText: String
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Image(systemName: "magnifyingglass")
.font(OpenClawType.title2SemiBold)
.foregroundStyle(OpenClawBrand.activationPrimaryAction)
.frame(width: 42, height: 42)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(OpenClawBrand.activationInsetGradient)
}
VStack(alignment: .leading, spacing: 6) {
Text("Looking for a Gateway")
.font(OpenClawType.headline)
Text("Keep your iPhone on the same LAN or tailnet, then start the Gateway on your host machine.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) {
GatewayQuickSetupInstructionRow(text: "Run openclaw gateway --port 18789.")
GatewayQuickSetupInstructionRow(text: "Check that Bonjour discovery is enabled.")
GatewayQuickSetupInstructionRow(text: "Open Settings if you need a manual host.")
}
.padding(.top, 2)
Text(verbatim: "Discovery: \(self.discoveryStatusText)")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.openClawCraftSurface(cornerRadius: 22)
}
}
private struct GatewayQuickSetupInstructionRow: View {
let text: LocalizedStringKey
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "arrow.right.circle.fill")
.font(OpenClawType.captionSemiBold)
.foregroundStyle(.secondary)
Text(self.text)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
}
#if DEBUG
#Preview("Quick setup gateway", traits: .fixedLayout(width: 393, height: 520)) {
GatewayQuickSetupPreviewHost(hasGateway: true)
}
#Preview("Quick setup searching", traits: .fixedLayout(width: 393, height: 520)) {
GatewayQuickSetupPreviewHost(hasGateway: false)
}
private struct GatewayQuickSetupPreviewHost: View {
@State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController
init(hasGateway: Bool) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
if hasGateway {
controller._test_setGateways([.previewGateway])
appModel.gatewayStatusText = "Ready to pair"
}
_appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: controller)
}
var body: some View {
GatewayQuickSetupSheet()
.environment(self.appModel)
.environment(self.gatewayController)
.openClawSheetChrome()
}
}
extension GatewayDiscoveryModel.DiscoveredGateway {
fileprivate static let previewGateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Studio Gateway",
endpoint: .hostPort(
host: .name("openclaw.local", nil),
port: 18789),
stableID: "preview-gateway",
debugID: "openclaw.local",
lanHost: "openclaw.local",
tailnetDns: nil,
gatewayPort: 18789,
canvasPort: 18789,
tlsEnabled: true,
tlsFingerprintSha256: "preview",
cliPath: "/opt/homebrew/bin/openclaw")
}
#endif

View file

@ -1,127 +1,279 @@
import SwiftUI
import UIKit
private enum OnboardingLayout {
static let contentMaxWidth: CGFloat = 680
private enum OnboardingVisual {
static let maxWidth: CGFloat = 430
}
private struct OnboardingActivationCanvas<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
GeometryReader { proxy in
ScrollView {
self.content
.frame(maxWidth: OnboardingVisual.maxWidth)
.frame(maxWidth: .infinity)
.frame(minHeight: max(0, proxy.size.height - 94), alignment: .top)
.padding(.horizontal, 20)
.padding(.top, 54)
.padding(.bottom, 40)
}
.scrollIndicators(.hidden)
.background(OpenClawBrand.activationCanvasGradient.ignoresSafeArea())
}
}
}
private struct OnboardingHeroGlyph: View {
var body: some View {
OpenClawActivationGlyph(size: 78)
}
}
private struct OnboardingHeroHeader: View {
let title: LocalizedStringKey
let subtitle: LocalizedStringKey?
var body: some View {
VStack(spacing: 18) {
OnboardingHeroGlyph()
VStack(spacing: 8) {
Text(self.title)
.font(OpenClawType.title1)
.multilineTextAlignment(.center)
if let subtitle {
Text(subtitle)
.font(OpenClawType.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity)
}
}
private struct OnboardingWelcomePrompt: View {
let text: LocalizedStringKey
var body: some View {
Text(self.text)
.font(OpenClawType.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.fixedSize(horizontal: false, vertical: true)
}
}
private typealias OnboardingPrimaryButtonStyle = OpenClawPrimaryActionButtonStyle
private enum OnboardingIntroPanelStyle {
static let iconSize: CGFloat = 34
static let contentSpacing: CGFloat = 12
static let panelPadding: CGFloat = 16
static let panelCornerRadius: CGFloat = 22
static let panelFill = OpenClawBrand.activationNeutralSurface
static let iconFill = OpenClawBrand.activationNeutralInsetSurface
static let stroke = OpenClawBrand.activationNeutralStroke
}
private struct OnboardingIntroPanel<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
self.content
.padding(Self.panelPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: OnboardingIntroPanelStyle.panelCornerRadius, style: .continuous)
.fill(OnboardingIntroPanelStyle.panelFill)
}
.overlay(alignment: .top) {
RoundedRectangle(cornerRadius: OnboardingIntroPanelStyle.panelCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.42), lineWidth: 0.5)
.blendMode(.plusLighter)
}
.overlay {
RoundedRectangle(cornerRadius: OnboardingIntroPanelStyle.panelCornerRadius, style: .continuous)
.stroke(OnboardingIntroPanelStyle.stroke, lineWidth: 0.5)
}
}
private static var panelPadding: CGFloat {
OnboardingIntroPanelStyle.panelPadding
}
}
private struct OnboardingIntroIcon: View {
let symbol: String
let tint: Color
var body: some View {
Image(systemName: self.symbol)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(self.tint)
.frame(
width: OnboardingIntroPanelStyle.iconSize,
height: OnboardingIntroPanelStyle.iconSize)
.background {
Circle()
.fill(OnboardingIntroPanelStyle.iconFill)
}
.overlay {
Circle()
.stroke(OnboardingIntroPanelStyle.stroke, lineWidth: 0.6)
}
}
}
private struct OnboardingSafetyRow: View {
let symbol: String
let title: LocalizedStringKey
var body: some View {
HStack(spacing: OnboardingIntroPanelStyle.contentSpacing) {
OnboardingIntroIcon(
symbol: self.symbol,
tint: OpenClawBrand.activationPrimaryAction)
Text(self.title)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
}
}
private struct OnboardingSecurityNotice: View {
var body: some View {
OnboardingIntroPanel {
HStack(alignment: .top, spacing: OnboardingIntroPanelStyle.contentSpacing) {
OnboardingIntroIcon(
symbol: "exclamationmark.triangle.fill",
tint: OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 6) {
Text("Security notice")
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(.primary)
(
Text("The connected OpenClaw agent can use device capabilities you enable.")
+ Text(verbatim: " ")
+ Text(
"Camera, microphone, photos, contacts, calendar, and location may be available.")
+ Text(verbatim: " ")
+ Text(
"Continue only if you trust the gateway and agent you connect to."))
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.accessibilityElement(children: .combine)
}
}
private struct OnboardingCommandChip: View {
@State private var didCopy = false
private let command = "openclaw qr"
var body: some View {
HStack(spacing: 8) {
Text(self.command)
.font(OpenClawType.mono)
.textSelection(.enabled)
Spacer(minLength: 0)
Button {
self.copyCommand()
} label: {
Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc")
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(
self.didCopy ? OpenClawBrand.activationPrimaryAction : Color.secondary.opacity(0.56))
.frame(width: 38, height: 38)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityLabel("Copy setup code command")
.accessibilityValue(self.didCopy ? "Copied" : self.command)
}
.foregroundStyle(OpenClawBrand.activationPrimaryAction)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 54)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(OpenClawBrand.activationNeutralSurface)
}
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(OpenClawBrand.activationNeutralStroke, lineWidth: 0.5)
}
}
private func copyCommand() {
UIPasteboard.general.string = self.command
withAnimation(.smooth(duration: 0.14)) {
self.didCopy = true
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_100_000_000)
withAnimation(.smooth(duration: 0.16)) {
self.didCopy = false
}
}
}
}
struct OnboardingIntroStep: View {
let onContinue: () -> Void
private static let features: [(icon: String, title: String, detail: String)] = [
("link", "Connect your gateway", "Pair with a quick QR scan."),
("hand.raised.fill", "You choose permissions", "Grant only the tools you want."),
("bubble.left.and.bubble.right.fill", "Chat, voice, and camera", "All from your phone."),
]
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
OpenClawProMark(size: 76, shadowRadius: 12)
.padding(.top, 32)
.padding(.bottom, 20)
OnboardingActivationCanvas {
VStack(alignment: .leading, spacing: 0) {
OnboardingHeroHeader(
title: "OpenClaw",
subtitle: "Securely connect this iPhone to your gateway.")
.padding(.top, 18)
Text("Welcome to OpenClaw")
.font(OpenClawType.title1)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.bottom, 40)
VStack(alignment: .leading, spacing: 28) {
ForEach(Array(Self.features.enumerated()), id: \.offset) { _, feature in
OnboardingFeatureRow(icon: feature.icon, title: feature.title, detail: feature.detail)
}
GroupBox {
Text(
"The connected agent can use capabilities you enable, including camera, "
+ "microphone, photos, contacts, calendar, and location. Continue only if "
+ "you trust the gateway and agent.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} label: {
Label("Security", systemImage: "lock.shield.fill")
.font(OpenClawType.headline)
}
.tint(OpenClawBrand.warn)
OnboardingIntroPanel {
VStack(alignment: .leading, spacing: 14) {
OnboardingSafetyRow(
symbol: "link",
title: "Connect to your gateway")
OnboardingSafetyRow(
symbol: "hand.raised",
title: "Choose device permissions")
OnboardingSafetyRow(
symbol: "message.fill",
title: "Use OpenClaw from your phone")
}
.padding(.horizontal, 12)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 20)
}
.scrollBounceBehavior(.basedOnSize)
.padding(.top, 44)
VStack(spacing: 16) {
Text("You can change permissions later in Settings.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
OnboardingSecurityNotice()
.padding(.top, 18)
Button(action: self.onContinue) {
Text("Continue")
.font(OpenClawType.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(OpenClawBrand.accent)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 16)
}
.frame(maxWidth: OnboardingLayout.contentMaxWidth)
.frame(maxWidth: .infinity)
}
}
Spacer(minLength: 40)
/// Inline command styled like a keyboard key so shell/chat commands stand out from prose.
struct KeycapText: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
Text(self.text)
.font(OpenClawType.monoFootnote)
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(Color(uiColor: .secondarySystemFill))
.overlay {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.strokeBorder(Color(uiColor: .separator).opacity(0.6), lineWidth: 0.5)
VStack(spacing: 14) {
Button {
self.onContinue()
} label: {
Text("Continue")
.font(OpenClawType.subheadSemiBold)
}
}
}
}
private struct OnboardingFeatureRow: View {
let icon: String
let title: String
let detail: String
var body: some View {
HStack(alignment: .top, spacing: 16) {
Image(systemName: self.icon)
.font(OpenClawType.title2)
.foregroundStyle(OpenClawBrand.accent)
.frame(width: 34, alignment: .center)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(OpenClawType.headline)
Text(self.detail)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.buttonStyle(OnboardingPrimaryButtonStyle())
}
}
}
}
@ -129,118 +281,195 @@ private struct OnboardingFeatureRow: View {
struct OnboardingWelcomeStep: View {
let statusLine: String
let isConnecting: Bool
let onScanQRCode: () -> Void
let onManualSetup: () -> Void
@State private var pairCommandCopied = false
var body: some View {
let statusText = self.statusLine.trimmingCharacters(in: .whitespacesAndNewlines)
OnboardingActivationCanvas {
VStack(alignment: .leading, spacing: 0) {
OnboardingHeroHeader(
title: "Connect Gateway",
subtitle: nil)
.padding(.top, 18)
VStack(spacing: 36) {
VStack(spacing: 14) {
OnboardingWelcomePrompt(text: "Run this on your gateway host and scan the code")
OnboardingCommandChip()
Button(action: self.onScanQRCode) {
if self.isConnecting {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
.tint(OpenClawBrand.activationPrimaryActionText)
Text("Connecting…")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Scan QR")
.font(OpenClawType.subheadSemiBold)
}
}
.buttonStyle(OnboardingPrimaryButtonStyle())
.disabled(self.isConnecting)
}
VStack(spacing: 14) {
OnboardingWelcomePrompt(text: "or")
Button(action: self.onManualSetup) {
Text("Connect Manually")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(OpenClawSecondaryActionButtonStyle(height: 54, shadowOpacity: 0.018))
.disabled(self.isConnecting)
}
}
.padding(.top, 46)
if !statusText.isEmpty {
Text(verbatim: statusText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)
.padding(.horizontal, 6)
.padding(.top, 14)
.transition(.opacity)
}
Spacer(minLength: 40)
}
.animation(.smooth(duration: 0.18), value: statusText)
}
}
}
struct OnboardingSuccessStep: View {
let gatewayName: String
let gatewayAddress: String?
let onGetStarted: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
OnboardingActivationCanvas {
VStack(spacing: 0) {
Spacer(minLength: 54)
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 64))
.foregroundStyle(OpenClawBrand.accent)
.padding(.bottom, 24)
ZStack(alignment: .bottomTrailing) {
OpenClawActivationGlyph(size: 86)
.shadow(color: OpenClawBrand.activationGlow.opacity(0.18), radius: 12, x: 0, y: 6)
Text("Connect Gateway")
.font(OpenClawType.title1)
.padding(.bottom, 12)
HStack(spacing: 8) {
KeycapText("/pair qr")
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
UIPasteboard.general.string = "/pair qr"
self.pairCommandCopied = true
}
} label: {
Label(
self.pairCommandCopied ? "Copied" : "Copy",
systemImage: self.pairCommandCopied ? "checkmark" : "doc.on.doc")
.font(OpenClawType.captionSemiBold)
Image(systemName: "checkmark")
.font(OpenClawType.headlineBold)
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background {
Circle()
.fill(OpenClawBrand.ok)
}
.overlay {
Circle()
.stroke(OpenClawBrand.activationCanvas, lineWidth: 3)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.primary)
.animation(nil, value: self.pairCommandCopied)
.accessibilityLabel(self.pairCommandCopied ? "Pair command copied" : "Copy pair command")
.accessibilityIdentifier("onboarding-copy-pair-command")
}
.padding(.bottom, 10)
.padding(.bottom, 22)
Text("Run this in your OpenClaw chat, then scan the code.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Text("You're connected")
.font(OpenClawType.title1)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
Spacer()
VStack(spacing: 4) {
Button {
self.onScanQRCode()
} label: {
Label("Scan QR Code", systemImage: "qrcode")
.font(OpenClawType.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.tint(OpenClawBrand.accent)
Button(action: self.onManualSetup) {
Text("Set Up Manually")
.font(OpenClawType.headline)
}
.buttonStyle(.borderless)
.controlSize(.large)
.tint(OpenClawBrand.accent)
.padding(.top, 12)
}
.padding(.horizontal, OpenClawSpacing.space6)
.padding(.bottom, 12)
if !self.statusLine.isEmpty {
Text(self.statusLine)
.font(OpenClawType.footnote)
Text(verbatim: self.gatewayName)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
Color.clear.frame(height: 12)
if let gatewayAddress, !gatewayAddress.isEmpty {
Text(verbatim: gatewayAddress)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
Spacer(minLength: 40)
Button {
self.onGetStarted()
} label: {
Label("Go to Chat", systemImage: "bubble.left.and.bubble.right.fill")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(OnboardingPrimaryButtonStyle())
}
}
.frame(maxWidth: OnboardingLayout.contentMaxWidth)
.frame(maxWidth: .infinity)
}
}
struct OnboardingModeIcon: View {
let symbol: String
let selected: Bool
var body: some View {
Image(systemName: self.symbol)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(self.selected ? OpenClawBrand.activationPrimaryActionText : .secondary)
.frame(width: 34, height: 34)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(self.selected ? OpenClawBrand.activationPrimaryGradient : OpenClawBrand
.activationNeutralGradient)
.shadow(
color: self.selected ? OpenClawBrand.activationGlow.opacity(0.18) : .clear,
radius: 5,
x: 0,
y: 2)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(
self.selected ? Color.white.opacity(0.30) : OpenClawBrand.activationNeutralStroke,
lineWidth: 0.5)
}
}
}
struct OnboardingModeRow: View {
let title: String
let subtitle: String
let title: LocalizedStringKey
let subtitle: LocalizedStringKey
let symbol: String
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack {
HStack(spacing: 12) {
OnboardingModeIcon(symbol: self.symbol, selected: self.selected)
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(OpenClawType.headline)
.font(OpenClawType.subheadSemiBold)
Text(self.subtitle)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
.font(self.selected ? .title3.weight(.semibold) : .title3.weight(.regular))
.foregroundStyle(
self.selected
? OpenClawBrand.activationPrimaryAction
: Color(uiColor: .quaternaryLabel).opacity(0.55))
}
.padding(.vertical, 6)
.frame(minHeight: 52)
.contentShape(Rectangle())
}
.font(OpenClawType.subhead)
.buttonStyle(.plain)
}
}

View file

@ -24,13 +24,13 @@ private enum OnboardingStep: Int, CaseIterable {
return "Step \(idx + 1) of \(manualSteps.count)"
}
var title: String {
var title: LocalizedStringKey {
switch self {
case .intro: "Welcome"
case .welcome: "Connect Gateway"
case .mode: "Connection Mode"
case .connect: "Connect"
case .auth: "Authentication"
case .mode: "Gateway Setup"
case .connect: "Gateway Details"
case .auth: "Gateway Status"
case .success: "Connected"
}
}
@ -60,6 +60,15 @@ struct GatewaySetupLinkStaging {
}
}
private enum OnboardingFocusedField: Hashable {
case setupCode
case manualHost
case manualPort
case discoveryDomain
case gatewayToken
case gatewayPassword
}
struct OnboardingWizardView: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@ -96,6 +105,7 @@ struct OnboardingWizardView: View {
@State private var setupCode: String = ""
@State private var setupCodeStatus: String?
@State private var setupAttemptID: UUID?
@FocusState private var focusedField: OnboardingFocusedField?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
let allowSkip: Bool
@ -160,11 +170,15 @@ struct OnboardingWizardView: View {
EmptyView()
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(Color(uiColor: .systemGroupedBackground))
.scrollDismissesKeyboard(.interactively)
}
}
.navigationTitle(self.isFullScreenStep ? "" : self.step.title)
.navigationBarTitleDisplayMode(.inline)
.tint(OpenClawBrand.activationPrimaryAction)
.toolbar {
if !self.isFullScreenStep {
ToolbarItem(placement: .principal) {
@ -177,42 +191,16 @@ struct OnboardingWizardView: View {
}
}
}
ToolbarItem(placement: .topBarLeading) {
if self.step.canGoBack {
Button {
self.navigateBack()
} label: {
Label("Back", systemImage: "chevron.left")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
} else if self.allowSkip {
Button {
self.invalidateSetupAttempt()
self.onClose()
} label: {
Text("Close")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil)
} label: {
Text("Done")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
}
.safeAreaInset(edge: .bottom, alignment: .trailing, spacing: 0) {
self.keyboardDismissControl
}
.overlay(alignment: .topLeading) {
self.leadingChromeButton
.padding(.leading, 16)
.padding(.top, 10)
}
.gatewayTrustPromptAlert()
.alert("QR Scanner Unavailable", isPresented: Binding(
get: { self.scannerError != nil },
@ -298,7 +286,7 @@ struct OnboardingWizardView: View {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
self.step = .success
self.navigate(to: .success)
}
}
@ -320,11 +308,11 @@ struct OnboardingWizardView: View {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationTitle("Scan Setup Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Scan QR Code")
Text("Scan Setup Code")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarLeading) {
@ -372,75 +360,135 @@ struct OnboardingWizardView: View {
}
}
@ViewBuilder
private var leadingChromeButton: some View {
if self.step.canGoBack {
Button {
self.navigateBack()
} label: {
Image(systemName: "chevron.left")
.font(OpenClawType.subheadSemiBold)
.accessibilityLabel("Back")
}
.buttonStyle(OpenClawCloseButtonStyle())
} else if self.allowSkip {
Button {
self.invalidateSetupAttempt()
self.onClose()
} label: {
Text("Close")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(OpenClawCloseButtonStyle())
}
}
@ViewBuilder
private var keyboardDismissControl: some View {
if self.focusedField != nil {
Button {
self.dismissKeyboard()
} label: {
Image(systemName: "keyboard.chevron.compact.down")
.font(OpenClawType.headline)
.frame(width: 50, height: 44)
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.activationPrimaryAction)
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.stroke(OpenClawBrand.activationNeutralStroke, lineWidth: 0.6)
}
.shadow(color: Color.black.opacity(0.08), radius: 14, x: 0, y: 4)
.accessibilityLabel("Dismiss Keyboard")
.padding(.trailing, 20)
.padding(.bottom, 10)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
.animation(.smooth(duration: 0.16), value: self.focusedField)
}
}
private var introStep: some View {
OnboardingIntroStep(onContinue: advanceFromIntro)
OnboardingIntroStep(onContinue: self.advanceFromIntro)
}
private var welcomeStep: some View {
OnboardingWelcomeStep(
statusLine: self.statusLine,
isConnecting: self.connectingGatewayID != nil,
onScanQRCode: {
self.openQRScannerFromOnboarding()
},
onManualSetup: {
self.invalidateSetupAttempt()
self.step = .mode
self.statusLine = ""
self.navigate(to: .mode)
})
}
@ViewBuilder
private var modeStep: some View {
setupCodeSection
self.setupCodeSection
Section {
OnboardingModeRow(
title: OnboardingConnectionMode.homeNetwork.title,
title: "Home Network",
subtitle: "LAN or Tailscale host",
symbol: "house.and.flag",
selected: self.selectedMode == .homeNetwork)
{
self.selectMode(.homeNetwork)
}
OnboardingModeRow(
title: OnboardingConnectionMode.remoteDomain.title,
title: "Remote Domain",
subtitle: "VPS with domain",
symbol: "globe",
selected: self.selectedMode == .remoteDomain)
{
self.selectMode(.remoteDomain)
}
self.developerModeToggleRow
if self.developerModeEnabled {
self.developerModeToggleRow
OnboardingModeRow(
title: OnboardingConnectionMode.developerLocal.title,
title: "Same Machine (Dev)",
subtitle: "For local iOS app development",
symbol: "wrench.and.screwdriver",
selected: self.selectedMode == .developerLocal)
{
self.selectMode(.developerLocal)
}
}
} header: {
Text("Connection Mode")
.font(OpenClawType.captionSemiBold)
Text("Manual Connection")
.font(OpenClawType.footnoteSemiBold)
.padding(.top, 12)
}
.disabled(self.connectingGatewayID != nil)
Section {
Button {
self.step = .connect
self.navigate(to: .connect)
} label: {
Text("Continue")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.selectedMode == nil || self.connectingGatewayID != nil)
.buttonStyle(OpenClawPrimaryActionButtonStyle(height: 48, cornerRadius: 16))
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
private var developerModeToggleRow: some View {
self.onboardingButtonToggle(
"Developer mode",
symbol: "wrench.and.screwdriver",
isOn: Binding(
get: { self.developerModeEnabled },
set: { enabled in
@ -451,23 +499,36 @@ struct OnboardingWizardView: View {
}))
}
private func onboardingButtonToggle(_ title: String, isOn: Binding<Bool>) -> some View {
// Onboarding Form switch rows need full-width taps; native Toggle only hits the switch edge on iOS 26.
Button {
isOn.wrappedValue.toggle()
} label: {
HStack {
private func onboardingButtonToggle(
_ title: LocalizedStringKey,
symbol: String? = nil,
isOn: Binding<Bool>) -> some View
{
Toggle(isOn: isOn) {
HStack(spacing: 12) {
if let symbol {
OnboardingModeIcon(symbol: symbol, selected: false)
}
Text(title)
.font(OpenClawType.subhead)
Spacer(minLength: 8)
OpenClawToggleIndicator(isOn: isOn.wrappedValue)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(.primary)
}
.contentShape(Rectangle())
.frame(minHeight: 52)
}
.tint(OpenClawBrand.activationPrimaryAction)
.contentShape(Rectangle())
.overlay {
Button {
isOn.wrappedValue.toggle()
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHidden(true)
}
.font(OpenClawType.subhead)
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
}
@ViewBuilder
@ -477,14 +538,14 @@ struct OnboardingWizardView: View {
self.onboardingLabeledContent("Mode", value: selectedMode.title)
self.onboardingLabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
self.onboardingLabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
self.onboardingLabeledContent("Progress", value: self.statusLine)
self.onboardingLabeledContent("Progress", value: self.statusLine.isEmpty ? "Ready" : self.statusLine)
} header: {
Text("Status")
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
} footer: {
if let connectMessage {
Text(connectMessage)
.font(OpenClawType.caption)
.font(OpenClawType.footnote)
}
}
@ -503,14 +564,13 @@ struct OnboardingWizardView: View {
} else {
Section {
Text("Choose a mode first.")
.font(OpenClawType.subhead)
.font(OpenClawType.body)
Button {
self.step = .mode
self.navigate(to: .mode)
} label: {
Text("Back to Mode Selection")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
}
@ -549,13 +609,13 @@ struct OnboardingWizardView: View {
.disabled(self.connectingGatewayID != nil)
} header: {
Text("Setup Link")
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
} footer: {
Text(link.tls
? "Review this endpoint. Credentials are applied only after you tap Connect."
:
"Plaintext may expose credentials. Continue only if you trust this local network and host.")
.font(OpenClawType.caption)
.font(OpenClawType.footnote)
}
}
@ -564,7 +624,7 @@ struct OnboardingWizardView: View {
Section {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.font(OpenClawType.subhead)
.font(OpenClawType.body)
.foregroundStyle(.secondary)
} else {
ForEach(self.gatewayController.gateways) { gateway in
@ -573,7 +633,7 @@ struct OnboardingWizardView: View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gateway.name)
.font(OpenClawType.subheadSemiBold)
.font(OpenClawType.body)
if let host = gateway.lanHost ?? gateway.tailnetDns {
Text(host)
.font(OpenClawType.footnote)
@ -611,7 +671,7 @@ struct OnboardingWizardView: View {
.disabled(self.connectingGatewayID != nil)
} header: {
Text("Discovered Gateways")
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
}
self.manualConnectionFieldsSection(title: "Manual Fallback")
@ -619,36 +679,36 @@ struct OnboardingWizardView: View {
}
private var remoteDomainConnectSection: some View {
manualConnectionFieldsSection(title: "Domain Settings")
self.manualConnectionFieldsSection(title: "Domain Settings")
}
private var developerConnectSection: some View {
Section {
TextField("Host", text: self.manualHostBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
TextField("Port", text: self.manualPortTextBinding)
self.onboardingTextField("Host", text: self.manualHostBinding, focusedField: .manualHost)
self.onboardingTextField("Port", text: self.manualPortTextBinding, focusedField: .manualPort)
.keyboardType(.numberPad)
.font(OpenClawType.subhead)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
self.manualConnectButton
} header: {
Text("Developer Local")
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
} footer: {
Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
.font(OpenClawType.caption)
.font(OpenClawType.footnote)
}
}
private var authStep: some View {
Group {
Section {
self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding)
self.onboardingSecureField(
"Gateway Auth Token",
text: self.gatewayTokenBinding,
focusedField: .gatewayToken)
self.onboardingSecureField(
"Gateway Password",
text: self.gatewayPasswordBinding,
focusedField: .gatewayPassword)
if let problem = self.currentProblem {
GatewayProblemBanner(
@ -661,17 +721,17 @@ struct OnboardingWizardView: View {
self.showGatewayProblemDetails = true
})
} else if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
Text("Gateway rejected credentials. Scan a fresh setup code or update token/password.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
} else {
Text("Auth token looks valid.")
Text("OpenClaw is checking gateway and node access.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text("Authentication")
.font(OpenClawType.captionSemiBold)
Text(self.gatewayStatusSectionTitle)
.font(OpenClawType.footnoteSemiBold)
}
if self.issue.needsPairing {
@ -686,7 +746,7 @@ struct OnboardingWizardView: View {
.disabled(self.connectingGatewayID != nil)
} header: {
Text("Pairing Approval")
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
} footer: {
let requestLine: String = {
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
@ -709,7 +769,7 @@ struct OnboardingWizardView: View {
Button {
self.openQRScannerFromOnboarding()
} label: {
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
Label("Scan Setup Code Again", systemImage: "qrcode.viewfinder")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
@ -733,70 +793,54 @@ struct OnboardingWizardView: View {
}
private var successStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(OpenClawBrand.ok)
.padding(.bottom, 20)
Text("Connected")
.font(OpenClawType.title1)
.foregroundStyle(OpenClawBrand.textPrimary)
.padding(.bottom, 8)
Text(self.successEndpoint)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
Spacer()
Button {
self.onComplete()
} label: {
Label("Go to Chat", systemImage: "bubble.left.and.bubble.right.fill")
.font(OpenClawType.headline)
}
.font(OpenClawType.headline)
.openClawPrimaryButton()
.padding(.horizontal, OpenClawSpacing.space6)
.padding(.bottom, 48)
}
OnboardingSuccessStep(
gatewayName: self.appModel.gatewayServerName ?? "gateway",
gatewayAddress: self.appModel.gatewayRemoteAddress,
onGetStarted: self.onComplete)
}
}
extension OnboardingWizardView {
private var gatewayStatusSectionTitle: String {
if self.issue.needsPairing || self.currentProblem?.needsPairingApproval == true {
return "Gateway Approval"
}
if self.issue.needsAuthToken || self.currentProblem != nil {
return "Authentication"
}
return "Gateway Status"
}
private var setupCodeSection: some View {
Section {
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
.disabled(self.connectingGatewayID != nil)
.onSubmit {
Task { await self.applySetupCodeAndConnect() }
}
HStack(spacing: 12) {
self.onboardingTextField("Enter setup code", text: self.$setupCode, focusedField: .setupCode)
.lineLimit(1)
.submitLabel(.go)
.onSubmit {
guard self.canApplySetupCode else { return }
Task { await self.applySetupCodeAndConnect() }
}
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "setup-code" {
HStack(spacing: 8) {
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "setup-code" {
ProgressView()
.progressViewStyle(.circular)
Text("Applying...")
.controlSize(.small)
} else {
Text("Apply")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Apply Setup Code")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.regular)
.tint(OpenClawBrand.activationPrimaryAction)
.disabled(!self.canApplySetupCode)
}
.font(OpenClawType.subheadSemiBold)
.disabled(
self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| self.connectingGatewayID != nil)
.frame(minHeight: 50)
if let setupCodeStatus, !setupCodeStatus.isEmpty {
Text(setupCodeStatus)
@ -805,65 +849,92 @@ extension OnboardingWizardView {
}
} header: {
Text("Setup Code")
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
} footer: {
Text("Use this if you received a setup code instead of a QR code.")
.font(OpenClawType.caption)
Text("Use this if you have a setup code instead of scanning.")
.font(OpenClawType.footnote)
}
}
private func manualConnectionFieldsSection(title: String) -> some View {
private var canApplySetupCode: Bool {
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& self.connectingGatewayID == nil
}
private func manualConnectionFieldsSection(title: LocalizedStringKey) -> some View {
Section {
TextField("Host", text: self.manualHostBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
TextField("Port", text: self.manualPortTextBinding)
self.onboardingTextField("Host", text: self.manualHostBinding, focusedField: .manualHost)
self.onboardingTextField("Port", text: self.manualPortTextBinding, focusedField: .manualPort)
.keyboardType(.numberPad)
.font(OpenClawType.subhead)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
self.onboardingTextField(
"Discovery Domain (optional)",
text: self.$discoveryDomain,
focusedField: .discoveryDomain)
if self.selectedMode == .remoteDomain {
self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding)
self.onboardingSecureField(
"Gateway Auth Token",
text: self.gatewayTokenBinding,
focusedField: .gatewayToken)
self.onboardingSecureField(
"Gateway Password",
text: self.gatewayPasswordBinding,
focusedField: .gatewayPassword)
}
self.manualConnectButton
} header: {
Text(title)
.font(OpenClawType.captionSemiBold)
.font(OpenClawType.footnoteSemiBold)
}
}
private func onboardingLabeledContent(_ title: String, value: String) -> some View {
private func onboardingLabeledContent(_ title: LocalizedStringKey, value: String) -> some View {
LabeledContent {
Text(value)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
Text(verbatim: value)
.font(OpenClawType.body)
} label: {
Text(title)
.font(OpenClawType.subheadSemiBold)
.font(OpenClawType.body)
}
}
private func onboardingSecureField(_ placeholder: String, text: Binding<String>) -> some View {
ZStack(alignment: .leading) {
SecureField("", text: text)
private func onboardingTextField(
_ placeholder: LocalizedStringKey,
text: Binding<String>,
focusedField: OnboardingFocusedField) -> some View
{
TextField(
"",
text: text,
prompt: Text(placeholder)
.font(OpenClawType.subhead)
.accessibilityLabel(placeholder)
.foregroundStyle(.tertiary))
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedField, equals: focusedField)
.accessibilityLabel(placeholder)
}
private func onboardingSecureField(
_ placeholder: LocalizedStringKey,
text: Binding<String>,
focusedField: OnboardingFocusedField) -> some View
{
ZStack(alignment: .leading) {
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(OpenClawType.subheadSemiBold)
.font(OpenClawType.subhead)
.foregroundStyle(.tertiary)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
SecureField("", text: text)
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedField, equals: focusedField)
}
.font(OpenClawType.subhead)
.accessibilityLabel(placeholder)
}
private var manualConnectButton: some View {
@ -914,10 +985,11 @@ extension OnboardingWizardView {
await self.applyGatewayLink(link)
self.setupCode = ""
self.setupCodeStatus = "Setup code applied. Connecting..."
self.connectMessage = "Connecting via setup code..."
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..."
self.step = .connect
self.setupCodeStatus = "Setup code applied. Connecting…"
self.connectMessage = "Connecting via setup code…"
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)"
await self.prepareFocusedFieldForStepTransition()
self.navigate(to: .connect)
await self.connectManual(setupAttemptID: attemptID)
}
@ -956,9 +1028,9 @@ extension OnboardingWizardView {
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
await self.applyGatewayLink(link)
self.connectMessage = "Connecting via QR code..."
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..."
self.step = .connect
self.connectMessage = "Connecting via setup code…"
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)"
self.navigate(to: .connect)
await self.connectManual(setupAttemptID: attemptID)
}
@ -976,7 +1048,7 @@ extension OnboardingWizardView {
self.setupCodeStatus = "Setup link loaded for \(link.host):\(link.port). Tap Connect to apply."
self.connectMessage = nil
self.statusLine = self.setupCodeStatus ?? ""
self.step = .connect
self.navigate(to: .connect)
}
private func connectStagedGatewaySetupLink() async {
@ -997,7 +1069,7 @@ extension OnboardingWizardView {
guard self.setupLinkStaging.link == link else { return }
_ = self.setupLinkStaging.take()
await self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)
self.setupCodeStatus = "Setup link applied. Connecting..."
self.setupCodeStatus = "Setup link applied. Connecting"
self.issue = .none
self.connectMessage = "Connecting to \(link.host)"
self.statusLine = "Connecting to \(link.host):\(link.port)"
@ -1167,7 +1239,7 @@ extension OnboardingWizardView {
OnboardingStateStore.markFirstRunIntroSeen()
self.requestLocalNetworkAccess(reason: "onboarding_continue")
self.statusLine = ""
self.step = .welcome
self.navigate(to: .welcome)
}
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
@ -1183,6 +1255,27 @@ extension OnboardingWizardView {
guard let target = step.previous else { return }
self.invalidateSetupAttempt()
self.connectMessage = nil
self.navigate(to: target)
}
private func prepareFocusedFieldForStepTransition() async {
guard self.focusedField != nil else { return }
self.dismissKeyboard()
await Task.yield()
try? await Task.sleep(nanoseconds: 120_000_000)
}
private func dismissKeyboard() {
self.focusedField = nil
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil)
}
private func navigate(to target: OnboardingStep) {
self.focusedField = nil
self.step = target
}
@ -1209,15 +1302,6 @@ extension OnboardingWizardView {
return !host.isEmpty && self.resolvedManualPort(host: host) != nil
}
private var successEndpoint: String {
let serverName = self.appModel.gatewayServerName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !serverName.isEmpty {
return serverName
}
let remoteAddress = self.appModel.gatewayRemoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return remoteAddress.isEmpty ? "gateway" : remoteAddress
}
private func initializeState() {
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let last = GatewaySettingsStore.loadLastGatewayConnection() {
@ -1239,7 +1323,13 @@ extension OnboardingWizardView {
}
self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
if self.selectedMode == nil {
self.selectedMode = OnboardingStateStore.lastMode()
let lastMode = OnboardingStateStore.lastMode()
if lastMode == .developerLocal {
self.developerModeEnabled = true
}
if self.developerModeEnabled || lastMode != .developerLocal {
self.selectedMode = lastMode
}
}
if self.selectedMode == .developerLocal, self.manualHost == "openclaw.local" {
self.manualHost = "localhost"
@ -1551,7 +1641,7 @@ extension OnboardingWizardView {
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.step = .connect
self.navigate(to: .connect)
self.openQRScannerFromOnboarding(status: "Scan a fresh setup QR code from this gateway.")
return
}

View file

@ -918,8 +918,8 @@ struct RootTabs: View {
self.showOnboarding = false
},
onComplete: {
self.selectSidebarDestination(.chat)
self.showOnboarding = false
self.selectSidebarDestination(.chat)
})
.environment(self.appModel)
.environment(self.voiceWake)

View file

@ -170,33 +170,44 @@ struct OpenClawTypographyTests {
#expect(proComponents.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(proComponents.contains("Text(primaryActionTitle)"))
#expect(proComponents.contains("Text(secondaryActionTitle)"))
#expect(proComponents.contains("@Environment(\\.isEnabled)"))
#expect(proComponents.contains(".animation(.easeOut(duration: 0.15), value: self.isEnabled)"))
#expect(chatTab.contains("Text(\"Export Transcript\")"))
#expect(chatTab.contains(".font(OpenClawType.body)"))
#expect(!chatTab.contains("Button(\"Export Transcript\")"))
#expect(!quickSetup.contains("Button(\"Close\")"))
#expect(quickSetup.contains("Text(\"Quick Setup\")"))
#expect(quickSetup.contains(".navigationTitle(\"Quick Setup\")"))
#expect(quickSetup.contains("Text(\"Close\")"))
#expect(quickSetup.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(quickSetup.contains("let text: LocalizedStringKey"))
#expect(!quickSetup.contains("Text(verbatim: self.text)"))
#expect(gatewayProblem.contains("Text(\"Connection problem\")"))
#expect(gatewayProblem.contains("Text(\"Copy request ID\")"))
#expect(gatewayProblem.contains("Text(\"Copy command\")"))
#expect(gatewayProblem.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(onboardingSteps.contains("Text(\"Connect Gateway\")"))
#expect(onboardingSteps.contains("Label(\"Scan QR Code\", systemImage: \"qrcode\")"))
#expect(onboardingSteps.contains("Text(\"Set Up Manually\")"))
#expect(onboardingSteps.contains(".font(OpenClawType.headline)"))
#expect(onboardingSteps.contains("title: \"Connect Gateway\""))
#expect(onboardingSteps.contains("Text(\"Scan QR\")"))
#expect(onboardingSteps.contains("Text(\"Connect Manually\")"))
#expect(onboardingSteps.contains("Label(\"Go to Chat\", systemImage: \"bubble.left.and.bubble.right.fill\")"))
#expect(onboardingSteps.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(onboardingSteps.contains("let title: LocalizedStringKey"))
#expect(onboardingSteps.contains("let subtitle: LocalizedStringKey?"))
#expect(onboardingSteps.contains("let text: LocalizedStringKey"))
#expect(onboardingWizard.contains("Text(\"Scan QR Code\")"))
#expect(onboardingWizard.contains("Text(\"Scan Setup Code\")")
|| onboardingWizard.contains(".navigationTitle(\"Scan Setup Code\")"))
#expect(onboardingWizard.contains("Label(\"Resume After Approval\", systemImage: \"arrow.clockwise\")"))
#expect(onboardingWizard.contains("Label(\"Scan QR Code Again\", systemImage: \"qrcode.viewfinder\")"))
#expect(onboardingWizard.contains("Text(\"Apply Setup Code\")"))
#expect(onboardingWizard.contains("Label(\"Scan Setup Code Again\", systemImage: \"qrcode.viewfinder\")"))
#expect(onboardingWizard.contains("Text(\"Apply\")"))
#expect(onboardingWizard.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(onboardingWizard.contains("_ title: LocalizedStringKey"))
#expect(onboardingWizard.contains("_ placeholder: LocalizedStringKey"))
#expect(onboardingWizard.contains("if self.developerModeEnabled {"))
#expect(onboardingWizard.contains("title: \"Same Machine (Dev)\""))
#expect(onboardingWizard.contains("if lastMode == .developerLocal"))
#expect(onboardingWizard.contains("self.developerModeEnabled = true"))
#expect(settingsSections.contains(".font(OpenClawType.body)"))
#expect(settingsSections.contains("Toggle(isOn: self.$talkButtonEnabled)"))

View file

@ -129,6 +129,73 @@ struct SwiftUIRenderSmokeTests {
}
}
@Test @MainActor func gatewayQuickSetupBuildsCandidateAndEmptyStates() {
let gateways: [GatewayDiscoveryModel.DiscoveredGateway?] = [
.previewGateway,
nil,
]
for gateway in gateways {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
if let gateway {
gatewayController._test_setGateways([gateway])
appModel.gatewayStatusText = "Ready to pair"
}
let root = GatewayQuickSetupSheet()
.environment(appModel)
.environment(gatewayController)
.openClawSheetChrome()
_ = Self.host(root, size: CGSize(width: 393, height: 520))
}
}
@Test @MainActor func onboardingActivationScreensBuildAcrossAppearanceAndTypeSize() {
let screens: [AnyView] = [
AnyView(OnboardingIntroStep(onContinue: {})),
AnyView(OnboardingWelcomeStep(
statusLine: "",
isConnecting: false,
onScanQRCode: {},
onManualSetup: {})),
AnyView(OnboardingSuccessStep(
gatewayName: "OpenClaw Gateway",
gatewayAddress: "openclaw.local",
onGetStarted: {})),
AnyView(NavigationStack {
Form {
Section("Connection Mode") {
OnboardingModeRow(
title: "Home Network",
subtitle: "LAN or Tailscale host",
symbol: "house.and.flag",
selected: true,
action: {})
OnboardingModeRow(
title: "Remote Domain",
subtitle: "VPS with domain",
symbol: "globe",
selected: false,
action: {})
}
}
.scrollContentBackground(.hidden)
.background(OpenClawBrand.activationCanvas)
}),
]
for scheme in [ColorScheme.light, ColorScheme.dark] {
for screen in screens {
let root = screen
.preferredColorScheme(scheme)
.environment(\.dynamicTypeSize, .accessibility2)
_ = Self.host(root, size: CGSize(width: 393, height: 852))
}
}
}
@Test @MainActor func `root tabs build gateway state view hierarchies`() {
for appModel in Self.rootTabsGatewayStateModels() {
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
@ -347,3 +414,20 @@ struct SwiftUIRenderSmokeTests {
let verticalSizeClass: UserInterfaceSizeClass
}
}
extension GatewayDiscoveryModel.DiscoveredGateway {
fileprivate static let previewGateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Studio Gateway",
endpoint: .hostPort(
host: .name("openclaw.local", nil),
port: 18789),
stableID: "preview-gateway",
debugID: "openclaw.local",
lanHost: "openclaw.local",
tailnetDns: nil,
gatewayPort: 18789,
canvasPort: 18789,
tlsEnabled: true,
tlsFingerprintSha256: "preview",
cliPath: "/opt/homebrew/bin/openclaw")
}

View file

@ -45,7 +45,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Security"].exists)
XCTAssertTrue(app.staticTexts["Security notice"].exists)
let disclosure = app.staticTexts.matching(NSPredicate(
format: "label CONTAINS[c] 'camera' AND label CONTAINS[c] 'trust the gateway and agent'")).firstMatch
XCTAssertTrue(disclosure.exists)
@ -337,7 +337,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
starter.tap()
XCTAssertTrue(
self.app?.staticTexts[
"Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert."
"Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert.",
].waitForExistence(timeout: 5) == true)
self.attachScreenshot(named: "chat-empty-starters-german")
}
@ -366,23 +366,23 @@ final class OpenClawSnapshotUITests: XCTestCase {
app.buttons["Continue"].tap()
app.tap()
let copyPairCommand = app.buttons["onboarding-copy-pair-command"]
XCTAssertTrue(copyPairCommand.waitForExistence(timeout: 8))
copyPairCommand.tap()
XCTAssertEqual(copyPairCommand.label, "Pair command copied")
self.attachScreenshot(named: "onboarding-copy-pair-command")
let copySetupCommand = app.buttons["Copy setup code command"]
XCTAssertTrue(copySetupCommand.waitForExistence(timeout: 8))
copySetupCommand.tap()
XCTAssertEqual(copySetupCommand.value as? String, "Copied")
self.attachScreenshot(named: "onboarding-copy-setup-code-command")
app.buttons["Set Up Manually"].tap()
let setupCode = app.textFields["Paste setup code"]
app.buttons["Connect Manually"].tap()
let setupCode = app.textFields["Enter setup code"]
XCTAssertTrue(setupCode.waitForExistence(timeout: 5))
setupCode.tap()
setupCode.typeText("APPLE-REVIEW-DEMO")
app.buttons["Done"].tap()
app.buttons["Apply Setup Code"].tap()
app.buttons["Dismiss Keyboard"].tap()
app.buttons["Apply"].tap()
XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Apple Review Demo Gateway"].exists)
XCTAssertFalse(app.staticTexts["Local demo mode"].exists)
XCTAssertTrue(app.staticTexts["Local demo mode"].exists)
self.attachScreenshot(named: "onboarding-connected-go-to-chat")
app.buttons["Go to Chat"].tap()
@ -622,29 +622,35 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertTrue(app.buttons["Set Up Manually"].waitForExistence(timeout: 8))
app.buttons["Set Up Manually"].tap()
let developerMode = app.buttons["Developer mode"]
if developerMode.value as? String != "On" {
developerMode.tap()
}
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Same Machine (Dev)")).firstMatch.tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 8))
app.buttons["Connect Manually"].tap()
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Home Network")).firstMatch.tap()
app.buttons["Continue"].tap()
let host = app.textFields["Host"]
XCTAssertTrue(host.waitForExistence(timeout: 5))
host.tap()
host.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 32) + "localhost")
let port = app.textFields["Port"]
XCTAssertTrue(port.waitForExistence(timeout: 5))
port.tap()
port.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 5) + "18920")
let useTLS = app.switches["Use TLS"]
XCTAssertTrue(useTLS.waitForExistence(timeout: 5))
if useTLS.value as? String != "0" {
useTLS.tap()
}
app.buttons["Connect"].tap()
let tokenField = app.secureTextFields["Gateway Auth Token"]
XCTAssertTrue(tokenField.waitForExistence(timeout: 20))
tokenField.tap()
tokenField.typeText(token)
app.buttons["Done"].tap()
app.buttons["Dismiss Keyboard"].tap()
app.buttons["Retry Connection"].tap()
XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 30))
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 30))
self.attachScreenshot(named: "manual-auth-retry-connected")
}
@ -770,19 +776,18 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertTrue(app.buttons["Set Up Manually"].waitForExistence(timeout: 8))
app.buttons["Set Up Manually"].tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 8))
app.buttons["Connect Manually"].tap()
let setupCodeField = app.textFields["Paste setup code"]
let setupCodeField = app.textFields["Enter setup code"]
XCTAssertTrue(setupCodeField.waitForExistence(timeout: 5))
setupCodeField.tap()
setupCodeField.press(forDuration: 1)
XCTAssertTrue(app.menuItems["Paste"].waitForExistence(timeout: 3))
app.menuItems["Paste"].tap()
app.buttons["Done"].tap()
app.buttons["Apply Setup Code"].tap()
app.buttons["Apply"].tap()
XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 45))
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 45))
app.buttons["Go to Chat"].tap()
return app
}

View file

@ -9,7 +9,11 @@ public struct OpenClawMascotView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
public init() {}
private let floats: Bool
public init(floats: Bool = true) {
self.floats = floats
}
public var body: some View {
let palette = OpenClawMascotPalette.forScheme(self.colorScheme)
@ -23,7 +27,10 @@ public struct OpenClawMascotView: View {
// 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)
.offset(
y: self.floats
? pose.floatOffset * min(proxy.size.width, proxy.size.height) / 120
: 0)
}
}
}

View file

@ -55,10 +55,10 @@ const CATALOGS = [
"apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift": [
"Close",
"Connect",
"Connect to a Gateway?",
"Connect a nearby Gateway",
"Connecting…",
"Dont show this again",
"No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.",
"Don't show this again",
"Looking for a Gateway",
"Not now",
"Quick Setup",
],
@ -68,7 +68,7 @@ const CATALOGS = [
"Trust and connect",
"Trust this gateway?",
],
"apps/ios/Sources/Onboarding/OnboardingWizardView.swift": ["Save"],
"apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift": ["Go to Chat"],
"apps/ios/Sources/RootTabs.swift": ["Agent", "Chat", "Control", "Settings", "Talk"],
"apps/ios/WatchApp/Sources/WatchInboxView.swift": [
"Approve",
@ -168,7 +168,7 @@ export async function checkAppleAppI18n() {
for (const locale of REQUIRED_LOCALES) {
const unit = entry.localizations?.[locale]?.stringUnit;
const value = unit?.value?.trim();
if (!value || unit?.state !== "translated") {
if (!value || (locale === "en" && unit?.state !== "translated")) {
throw new Error(
`Apple catalog ${spec.path} is missing ${locale} for ${JSON.stringify(key)}`,
);

View file

@ -9,6 +9,24 @@ describe("Apple app i18n catalogs", () => {
await expect(checkAppleAppI18n()).resolves.toBeUndefined();
});
it("does not mark English setup fallbacks as completed translations", async () => {
const catalog = JSON.parse(
await readFile("apps/ios/Resources/Localizable.xcstrings", "utf8"),
) as {
strings: Record<
string,
{ localizations?: Record<string, { stringUnit?: { state?: string; value?: string } }> }
>;
};
expect(
catalog.strings["Connect a nearby Gateway"]?.localizations?.de?.stringUnit,
).toMatchObject({
state: "new",
value: "Connect a nearby Gateway",
});
});
it("compiles macOS catalogs into app-bundle localization directories", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-apple-i18n-"));
try {

View file

@ -120,7 +120,7 @@ describe("native app i18n inventory", () => {
),
).toBe(true);
expect(entries.some((entry) => entry.source === "No sessions yet")).toBe(true);
expect(entries.some((entry) => entry.source === "Dont show this again")).toBe(true);
expect(entries.some((entry) => entry.source === "Don't show this again")).toBe(true);
expect(entries.some((entry) => entry.source === "Use Manual Gateway")).toBe(true);
expect(entries.some((entry) => entry.source === "Session target")).toBe(true);
expect(