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
This commit is contained in:
Peter Steinberger
2026-07-22 01:06:51 -07:00
committed by GitHub
parent f1cfaa94a0
commit 5610f24fc8
4 changed files with 217 additions and 31 deletions
+22 -22
View File
@@ -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",
@@ -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)
@@ -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 {
@@ -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)
}
}