From 5610f24fc8ad2cf637e69ff28f149fb1d2ce3fd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 01:06:51 -0700 Subject: [PATCH] feat(apple): port the zen, drummer, and peekaboo claw stances (#112570) * feat(apple): add rare working claw stances Add zen, drummer, and peekaboo animations to the shared iOS and macOS working indicator, with deterministic stance and pose coverage. * chore(i18n): refresh native source anchors --- apps/.i18n/native-source.json | 44 ++++---- .../OpenClawChatUI/ChatWorkingClawView.swift | 100 +++++++++++++++++- .../OpenClawChatUI/ChatWorkingProgress.swift | 12 ++- .../ChatWorkingProgressTests.swift | 92 +++++++++++++++- 4 files changed, 217 insertions(+), 31 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 70fa1ff35f4e..c95d5c311952 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -40987,7 +40987,7 @@ }, { "kind": "ui-localized-call", - "line": 40, + "line": 46, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Shelling", "surface": "apple", @@ -40995,7 +40995,7 @@ }, { "kind": "ui-localized-call", - "line": 41, + "line": 47, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Scuttling", "surface": "apple", @@ -41003,7 +41003,7 @@ }, { "kind": "ui-localized-call", - "line": 42, + "line": 48, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Clawing", "surface": "apple", @@ -41011,7 +41011,7 @@ }, { "kind": "ui-localized-call", - "line": 43, + "line": 49, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Pinching", "surface": "apple", @@ -41019,7 +41019,7 @@ }, { "kind": "ui-localized-call", - "line": 44, + "line": 50, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Molting", "surface": "apple", @@ -41027,7 +41027,7 @@ }, { "kind": "ui-localized-call", - "line": 45, + "line": 51, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Bubbling", "surface": "apple", @@ -41035,7 +41035,7 @@ }, { "kind": "ui-localized-call", - "line": 46, + "line": 52, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Tiding", "surface": "apple", @@ -41043,7 +41043,7 @@ }, { "kind": "ui-localized-call", - "line": 47, + "line": 53, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Reefing", "surface": "apple", @@ -41051,7 +41051,7 @@ }, { "kind": "ui-localized-call", - "line": 48, + "line": 54, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Cracking", "surface": "apple", @@ -41059,7 +41059,7 @@ }, { "kind": "ui-localized-call", - "line": 49, + "line": 55, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Sifting", "surface": "apple", @@ -41067,7 +41067,7 @@ }, { "kind": "ui-localized-call", - "line": 50, + "line": 56, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Brining", "surface": "apple", @@ -41075,7 +41075,7 @@ }, { "kind": "ui-localized-call", - "line": 51, + "line": 57, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Nautiling", "surface": "apple", @@ -41083,7 +41083,7 @@ }, { "kind": "ui-localized-call", - "line": 52, + "line": 58, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Krilling", "surface": "apple", @@ -41091,7 +41091,7 @@ }, { "kind": "ui-localized-call", - "line": 53, + "line": 59, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Barnacling", "surface": "apple", @@ -41099,7 +41099,7 @@ }, { "kind": "ui-localized-call", - "line": 54, + "line": 60, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Lobstering", "surface": "apple", @@ -41107,7 +41107,7 @@ }, { "kind": "ui-localized-call", - "line": 55, + "line": 61, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Tidepooling", "surface": "apple", @@ -41115,7 +41115,7 @@ }, { "kind": "ui-localized-call", - "line": 56, + "line": 62, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Pearling", "surface": "apple", @@ -41123,7 +41123,7 @@ }, { "kind": "ui-localized-call", - "line": 57, + "line": 63, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Snapping", "surface": "apple", @@ -41131,7 +41131,7 @@ }, { "kind": "ui-localized-call", - "line": 58, + "line": 64, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Surfacing", "surface": "apple", @@ -41139,7 +41139,7 @@ }, { "kind": "ui-localized-call", - "line": 129, + "line": 135, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "Done in %@", "surface": "apple", @@ -41147,7 +41147,7 @@ }, { "kind": "ui-localized-call", - "line": 139, + "line": 145, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "1 token", "surface": "apple", @@ -41155,7 +41155,7 @@ }, { "kind": "ui-localized-call", - "line": 142, + "line": 148, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift", "source": "%@ tokens", "surface": "apple", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingClawView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingClawView.swift index b5bdcccf3dd8..0aacef55eb19 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingClawView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingClawView.swift @@ -6,13 +6,14 @@ private enum ChatWorkingClawSeed { static let salt = UInt32.random(in: UInt32.min...UInt32.max) } -private struct ChatWorkingClawPose { +struct ChatWorkingClawPose { var bodyRotation: CGFloat = 0 var jawRotation: CGFloat = -10 var xOffset: CGFloat = 0 var yOffset: CGFloat = 0 var zRotation: CGFloat = 0 var yRotation: CGFloat = 0 + var bodyScale: CGFloat = 1 var powOpacity: Double = 0 var powScale: CGFloat = 0.4 @@ -24,7 +25,24 @@ private struct ChatWorkingClawKeyframe { let value: CGFloat } -private enum ChatWorkingClawMotion { +enum ChatWorkingClawMotion { + private enum Easing { + case easeOut + case easeInOut + case linear + + func amount(for linear: Double) -> Double { + switch self { + case .easeOut: + 1 - pow(1 - linear, 3) + case .easeInOut: + UnitCurve.easeInOut.value(at: linear) + case .linear: + linear + } + } + } + private static let bodySnips = [ ChatWorkingClawKeyframe(progress: 0, value: 0), ChatWorkingClawKeyframe(progress: 0.06, value: 0), @@ -99,11 +117,72 @@ private enum ChatWorkingClawMotion { ChatWorkingClawKeyframe(progress: 0.78, value: -360), ChatWorkingClawKeyframe(progress: 1, value: -360), ] + private static let zenBodyScale = [ + ChatWorkingClawKeyframe(progress: 0, value: 1), + ChatWorkingClawKeyframe(progress: 0.30, value: 1.08), + ChatWorkingClawKeyframe(progress: 0.55, value: 1), + ChatWorkingClawKeyframe(progress: 1, value: 1), + ] + private static let zenJaw = [ + ChatWorkingClawKeyframe(progress: 0, value: -10), + ChatWorkingClawKeyframe(progress: 0.60, value: -10), + ChatWorkingClawKeyframe(progress: 0.70, value: -24), + ChatWorkingClawKeyframe(progress: 0.76, value: 2), + ChatWorkingClawKeyframe(progress: 0.86, value: -10), + ChatWorkingClawKeyframe(progress: 1, value: -10), + ] + private static let drummerBodyRotation = [ + ChatWorkingClawKeyframe(progress: 0, value: 0), + ChatWorkingClawKeyframe(progress: 0.15, value: -8), + ChatWorkingClawKeyframe(progress: 0.30, value: 0), + ChatWorkingClawKeyframe(progress: 0.55, value: 8), + ChatWorkingClawKeyframe(progress: 0.70, value: 0), + ChatWorkingClawKeyframe(progress: 1, value: 0), + ] + private static let drummerJaw = [ + ChatWorkingClawKeyframe(progress: 0, value: -10), + ChatWorkingClawKeyframe(progress: 0.10, value: -20), + ChatWorkingClawKeyframe(progress: 0.15, value: 2), + ChatWorkingClawKeyframe(progress: 0.25, value: -10), + ChatWorkingClawKeyframe(progress: 0.50, value: -20), + ChatWorkingClawKeyframe(progress: 0.55, value: 2), + ChatWorkingClawKeyframe(progress: 0.65, value: -10), + ChatWorkingClawKeyframe(progress: 1, value: -10), + ] + private static let peekabooY = [ + ChatWorkingClawKeyframe(progress: 0, value: 0), + ChatWorkingClawKeyframe(progress: 0.55, value: 0), + ChatWorkingClawKeyframe(progress: 0.62, value: 5), + ChatWorkingClawKeyframe(progress: 0.72, value: 5), + ChatWorkingClawKeyframe(progress: 0.78, value: -1.5), + ChatWorkingClawKeyframe(progress: 0.84, value: 0), + ChatWorkingClawKeyframe(progress: 1, value: 0), + ] + private static let peekabooScale = [ + ChatWorkingClawKeyframe(progress: 0, value: 1), + ChatWorkingClawKeyframe(progress: 0.55, value: 1), + ChatWorkingClawKeyframe(progress: 0.62, value: 0.72), + ChatWorkingClawKeyframe(progress: 0.72, value: 0.72), + ChatWorkingClawKeyframe(progress: 0.78, value: 1.06), + ChatWorkingClawKeyframe(progress: 0.84, value: 1), + ChatWorkingClawKeyframe(progress: 1, value: 1), + ] + private static let peekabooJaw = [ + ChatWorkingClawKeyframe(progress: 0, value: -10), + ChatWorkingClawKeyframe(progress: 0.55, value: -10), + ChatWorkingClawKeyframe(progress: 0.62, value: -2), + ChatWorkingClawKeyframe(progress: 0.72, value: -2), + ChatWorkingClawKeyframe(progress: 0.78, value: -28), + ChatWorkingClawKeyframe(progress: 0.86, value: -10), + ChatWorkingClawKeyframe(progress: 1, value: -10), + ] static func pose(stance: ChatWorkingClawStance, elapsed: TimeInterval) -> ChatWorkingClawPose { let duration: TimeInterval = switch stance { case .flurry: 1.3 case .spin: 3.6 + case .zen: 6 + case .drummer: 1.2 default: 2.4 } let progress = max(0, elapsed).truncatingRemainder(dividingBy: duration) / duration @@ -128,7 +207,7 @@ private enum ChatWorkingClawMotion { .init(progress: 1, value: 0), ], at: progress, - eased: false)) + easing: .linear)) pose.powScale = self.sample([ .init(progress: 0, value: 0.4), .init(progress: 0.46, value: 0.4), @@ -139,6 +218,16 @@ private enum ChatWorkingClawMotion { case .backflip: pose.yOffset = self.sample(self.backflipY, at: progress) pose.zRotation = self.sample(self.backflipRotation, at: progress) + case .zen: + pose.bodyScale = self.sample(self.zenBodyScale, at: progress, easing: .easeInOut) + pose.jawRotation = self.sample(self.zenJaw, at: progress) + case .drummer: + pose.bodyRotation = self.sample(self.drummerBodyRotation, at: progress) + pose.jawRotation = self.sample(self.drummerJaw, at: progress) + case .peekaboo: + pose.yOffset = self.sample(self.peekabooY, at: progress) + pose.bodyScale = self.sample(self.peekabooScale, at: progress) + pose.jawRotation = self.sample(self.peekabooJaw, at: progress) } return pose } @@ -146,14 +235,14 @@ private enum ChatWorkingClawMotion { private static func sample( _ frames: [ChatWorkingClawKeyframe], at progress: Double, - eased: Bool = true) -> CGFloat + easing: Easing = .easeOut) -> CGFloat { guard let first = frames.first else { return 0 } guard progress > first.progress else { return first.value } for pair in zip(frames, frames.dropFirst()) where progress <= pair.1.progress { let span = pair.1.progress - pair.0.progress let linear = span > 0 ? (progress - pair.0.progress) / span : 1 - let amount = eased ? 1 - pow(1 - linear, 3) : linear + let amount = easing.amount(for: linear) return pair.0.value + (pair.1.value - pair.0.value) * CGFloat(amount) } return frames.last?.value ?? first.value @@ -222,6 +311,7 @@ struct ChatWorkingClawView: View { .degrees(pose.yRotation), axis: (x: 0, y: 1, z: 0), perspective: 0.45) + .scaleEffect(pose.bodyScale) .offset(x: pose.xOffset, y: pose.yOffset) .scaleEffect(x: !self.parked && self.stance == .southpaw ? -1 : 1, y: 1) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift index 62dc5dd32274..598377a2e17a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift @@ -7,14 +7,20 @@ enum ChatWorkingClawStance: CaseIterable, Equatable, Sendable { case spin case shadowbox case backflip + case zen + case drummer + case peekaboo - private static let weightedStances: [(stance: Self, weight: Double)] = [ - (.standard, 66), - (.southpaw, 20), + static let weightedStances: [(stance: Self, weight: Double)] = [ + (.standard, 63), + (.southpaw, 19), (.flurry, 5), (.spin, 4), (.shadowbox, 3), (.backflip, 2), + (.zen, 2), + (.drummer, 1), + (.peekaboo, 1), ] static func seeded(_ key: String, salt: UInt32) -> Self { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatWorkingProgressTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatWorkingProgressTests.swift index 4b12d318f683..44a0665931a4 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatWorkingProgressTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatWorkingProgressTests.swift @@ -14,7 +14,93 @@ struct ChatWorkingProgressTests { #expect(first == ChatWorkingClawStance.seeded(run, salt: salt)) #expect(first == .southpaw) #expect(ChatWorkingClawStance.seeded("run-a", salt: salt) == .standard) - #expect(ChatWorkingClawStance.seeded("run-8", salt: salt) == .spin) + #expect(ChatWorkingClawStance.seeded("run-21", salt: salt) == .spin) + } + + @Test func `stance weights total one hundred and match the allowlist`() { + let weightedStances = ChatWorkingClawStance.weightedStances + #expect(weightedStances.reduce(0) { $0 + $1.weight } == 100) + #expect(weightedStances.map(\.weight) == [63, 19, 5, 4, 3, 2, 2, 1, 1]) + #expect(weightedStances.map(\.stance) == [ + .standard, + .southpaw, + .flurry, + .spin, + .shadowbox, + .backflip, + .zen, + .drummer, + .peekaboo, + ]) + #expect(weightedStances.map(\.stance) == ChatWorkingClawStance.allCases) + } + + @Test func `new rare stances are selectable`() { + let salt: UInt32 = 0xA1B2_C3D4 + #expect(ChatWorkingClawStance.seeded("run-10", salt: salt) == .zen) + #expect(ChatWorkingClawStance.seeded("run-299", salt: salt) == .drummer) + #expect(ChatWorkingClawStance.seeded("run-22", salt: salt) == .peekaboo) + } + + @Test func `zen samples the breath and deliberate snip keyframes`() { + for (elapsed, scale) in [(0.0, 1.0), (1.8, 1.08), (3.3, 1.0), (6.0, 1.0)] { + let pose = ChatWorkingClawMotion.pose(stance: .zen, elapsed: elapsed) + self.expectClose(pose.bodyScale, scale) + #expect(pose.bodyRotation == 0) + #expect(pose.xOffset == 0) + #expect(pose.yOffset == 0) + } + for (elapsed, jaw) in [(3.6, -10.0), (4.2, -24.0), (4.56, 2.0), (5.16, -10.0)] { + let pose = ChatWorkingClawMotion.pose(stance: .zen, elapsed: elapsed) + self.expectClose(pose.jawRotation, jaw) + } + } + + @Test func `drummer samples the two tilt and jaw hit keyframes`() { + for (elapsed, rotation) in [(0.0, 0.0), (0.18, -8.0), (0.36, 0.0), (0.66, 8.0), (0.84, 0.0)] { + let pose = ChatWorkingClawMotion.pose(stance: .drummer, elapsed: elapsed) + self.expectClose(pose.bodyRotation, rotation) + #expect(pose.bodyScale == 1) + #expect(pose.xOffset == 0) + #expect(pose.yOffset == 0) + } + for (elapsed, jaw) in [ + (0.12, -20.0), + (0.18, 2.0), + (0.30, -10.0), + (0.60, -20.0), + (0.66, 2.0), + (0.78, -10.0), + ] { + let pose = ChatWorkingClawMotion.pose(stance: .drummer, elapsed: elapsed) + self.expectClose(pose.jawRotation, jaw) + } + } + + @Test func `peekaboo samples the vertical duck pop and boo keyframes`() { + for (elapsed, yOffset, scale) in [ + (1.32, 0.0, 1.0), + (1.488, 5.0, 0.72), + (1.728, 5.0, 0.72), + (1.872, -1.5, 1.06), + (2.016, 0.0, 1.0), + ] { + let pose = ChatWorkingClawMotion.pose(stance: .peekaboo, elapsed: elapsed) + self.expectClose(pose.yOffset, yOffset) + self.expectClose(pose.bodyScale, scale) + #expect(pose.bodyRotation == 0) + #expect(pose.xOffset == 0) + } + for (elapsed, jaw) in [ + (1.32, -10.0), + (1.488, -2.0), + (1.728, -2.0), + (1.872, -28.0), + (2.064, -10.0), + ] { + let pose = ChatWorkingClawMotion.pose(stance: .peekaboo, elapsed: elapsed) + self.expectClose(pose.jawRotation, jaw) + } } @Test func `working identity survives run rekey but resets for turn and session`() throws { @@ -349,4 +435,8 @@ struct ChatWorkingProgressTests { runtimeMs: runtimeMs, outputTokens: outputTokens) } + + private func expectClose(_ actual: CGFloat, _ expected: CGFloat) { + #expect(abs(actual - expected) < 0.0001) + } }