dsh-notch 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/BASELINE.md +13 -0
  2. package/DESIGN.md +34 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +136 -0
  5. package/README.md +145 -0
  6. package/cordis.yml +5 -0
  7. package/docs/motion-audit.md +65 -0
  8. package/docs/motion-continuity.md +77 -0
  9. package/docs/releases/v0.3.0.md +20 -0
  10. package/dshx.yml +14 -0
  11. package/macos/Package.swift +17 -0
  12. package/macos/STATUS-MOTION.md +49 -0
  13. package/macos/Sources/Client.swift +139 -0
  14. package/macos/Sources/IdleRobot.swift +423 -0
  15. package/macos/Sources/NotchMarkdown.swift +266 -0
  16. package/macos/Sources/Panel.swift +153 -0
  17. package/macos/Sources/Resources/Idle/balance.json +1 -0
  18. package/macos/Sources/Resources/Idle/blink.json +1 -0
  19. package/macos/Sources/Resources/Idle/cube-in.json +1 -0
  20. package/macos/Sources/Resources/Idle/dance.json +1 -0
  21. package/macos/Sources/Resources/Idle/hop.json +1 -0
  22. package/macos/Sources/Resources/Idle/nod.json +1 -0
  23. package/macos/Sources/Resources/Idle/satellite-in.json +1 -0
  24. package/macos/Sources/Resources/Idle/satellite-out.json +1 -0
  25. package/macos/Sources/Resources/Idle/scan.json +1 -0
  26. package/macos/Sources/Resources/Idle/sleep.json +1 -0
  27. package/macos/Sources/Resources/Idle/sneeze.json +1 -0
  28. package/macos/Sources/Resources/Idle/stretch.json +1 -0
  29. package/macos/Sources/Resources/Idle/tilt.json +1 -0
  30. package/macos/Sources/ReviewStudio.swift +337 -0
  31. package/macos/Sources/RootView.swift +1093 -0
  32. package/macos/Sources/StatusOrbit.swift +642 -0
  33. package/macos/Sources/main.swift +153 -0
  34. package/macos/Tests/ExpandedHeightProbe.swift +108 -0
  35. package/macos/Tests/GeometryProbe.swift +98 -0
  36. package/macos/Tests/IdleProbe.swift +294 -0
  37. package/macos/Tests/MotionProbe.swift +106 -0
  38. package/macos/Tests/OutcomeProbe.swift +133 -0
  39. package/macos/Tests/ScrollbarProbe.swift +91 -0
  40. package/macos/Tests/expanded-height.sh +13 -0
  41. package/macos/Tests/geometry.sh +14 -0
  42. package/macos/Tests/idle.sh +14 -0
  43. package/macos/Tests/motion.sh +14 -0
  44. package/macos/Tests/outcome.sh +14 -0
  45. package/macos/Tests/scrollbar.sh +13 -0
  46. package/package.json +46 -0
  47. package/src/board.ts +299 -0
  48. package/src/browse-sync.ts +59 -0
  49. package/src/dsh-notch.ts +60 -0
  50. package/src/http.ts +215 -0
  51. package/src/session-state.ts +31 -0
  52. package/src/store.ts +49 -0
  53. package/src/types.ts +65 -0
  54. package/tests/answer-lifecycle.test.mjs +23 -0
  55. package/tests/board.test.mjs +7 -0
  56. package/tests/browse-sync.test.mjs +20 -0
  57. package/tests/mirror.test.mjs +4 -0
  58. package/tests/peer-range.test.mjs +21 -0
  59. package/tests/session-state.test.mjs +54 -0
  60. package/tests/unread-hold.test.mjs +90 -0
  61. package/tools/desktop-shell/README.md +56 -0
  62. package/tools/desktop-shell/child-log.mjs +6 -0
  63. package/tools/desktop-shell/child-log.test.mjs +14 -0
  64. package/tools/desktop-shell/package-recovery.py +78 -0
  65. package/tools/desktop-shell/recovery-probe.cjs +50 -0
  66. package/tools/desktop-shell/renderer-recovery.mjs +95 -0
  67. package/tools/desktop-shell/renderer-recovery.test.mjs +24 -0
  68. package/tools/diagnostics/completion-watch.py +101 -0
  69. package/tools/diagnostics/completion-watch.test.py +17 -0
  70. package/tools/diagnostics/renderer-diagnostics.mjs +56 -0
  71. package/tools/diagnostics/renderer-diagnostics.test.mjs +32 -0
  72. package/tools/idle/LICENSE.open-bot-motion +21 -0
  73. package/tools/idle/export.mjs +40 -0
  74. package/tools/idle/index.html +4 -0
  75. package/tools/idle/motions.js +83 -0
  76. package/tools/idle/original.js +1673 -0
  77. package/tools/idle/renderer.js +1673 -0
  78. package/tools/recording/Client.swift +76 -0
  79. package/tools/recording/DemoCatalog.swift +174 -0
  80. package/tools/recording/README.md +23 -0
  81. package/tools/recording/build.sh +41 -0
  82. package/tools/recording/main.swift +100 -0
  83. package/tools/review/ReviewStudio.swift +236 -0
  84. package/tools/review/audit.sh +19 -0
  85. package/tools/review/build.sh +7 -0
  86. package/tools/review/main.swift +158 -0
  87. package/tools/review/render-audit.swift +32 -0
@@ -0,0 +1,139 @@
1
+ import Foundation
2
+
3
+ struct RuntimeFile: Decodable {
4
+ var origin: String
5
+ var token: String
6
+ }
7
+
8
+ struct NotchOption: Decodable, Identifiable {
9
+ var label: String
10
+ var description: String?
11
+ var id: String { label }
12
+ }
13
+
14
+ struct NotchQuestion: Decodable, Identifiable {
15
+ var id: String
16
+ var question: String
17
+ var detail: String?
18
+ var header: String?
19
+ var options: [NotchOption]?
20
+ var multiSelect: Bool?
21
+ }
22
+
23
+ struct NotchApproval: Decodable {
24
+ var id: String
25
+ var toolName: String
26
+ var reason: String?
27
+ }
28
+
29
+ struct NotchAsk: Decodable {
30
+ var id: String
31
+ var questions: [NotchQuestion]
32
+ }
33
+
34
+ struct NotchLastTurn: Decodable {
35
+ var at: Double
36
+ var kind: String
37
+ var failed: Bool
38
+ }
39
+
40
+ struct NotchRow: Decodable, Identifiable {
41
+ var id: String
42
+ var title: String
43
+ var child: Bool
44
+ var busy: Bool
45
+ var unread: Bool
46
+ var lastTurn: NotchLastTurn?
47
+ var approval: NotchApproval?
48
+ var ask: NotchAsk?
49
+
50
+ var needsAction: Bool { approval != nil || ask != nil }
51
+ /// A red lamp is a finished unsuccessful turn, never a session that is still running.
52
+ var isFailedResult: Bool { lastTurn?.failed == true && !busy && !needsAction }
53
+ }
54
+
55
+ struct NotchSnapshot: Decodable {
56
+ var ok: Bool
57
+ var generatedAt: Double
58
+ var origin: String
59
+ var rows: [NotchRow]
60
+ }
61
+
62
+ enum NotchClientError: Error {
63
+ case noRuntime
64
+ case http(Int)
65
+ }
66
+
67
+ final class NotchClient: @unchecked Sendable {
68
+ private var origin = ""
69
+ private var token = ""
70
+
71
+ func reloadRuntime() throws {
72
+ let url = ProcessInfo.processInfo.environment["DSH_NOTCH_RUNTIME_FILE"].map { URL(fileURLWithPath: $0) }
73
+ ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".dsh/dsh-notch/runtime.json")
74
+ let data = try Data(contentsOf: url)
75
+ let file = try JSONDecoder().decode(RuntimeFile.self, from: data)
76
+ origin = file.origin
77
+ token = file.token
78
+ }
79
+
80
+ func status() async throws -> NotchSnapshot {
81
+ try reloadRuntime()
82
+ return try await get("/dsh-notch/status")
83
+ }
84
+
85
+ func approve(id: String, outcome: String) async throws {
86
+ try await post("/dsh-notch/approve", body: ["id": id, "outcome": outcome])
87
+ }
88
+
89
+ @MainActor
90
+ func answer(id: String, answers: [[String: Any]]) async throws {
91
+ try await postJSON("/dsh-notch/answer", payload: ["id": id, "answers": answers])
92
+ }
93
+
94
+ func seen(sessionId: String) async throws {
95
+ try await post("/dsh-notch/seen", body: ["sessionId": sessionId])
96
+ }
97
+
98
+ func seenAll() async throws {
99
+ try await postJSON("/dsh-notch/seen", payload: ["all": true])
100
+ }
101
+
102
+ func focus(sessionId: String) async throws {
103
+ try await post("/dsh-notch/focus", body: ["sessionId": sessionId])
104
+ }
105
+
106
+ private func get(_ path: String) async throws -> NotchSnapshot {
107
+ var request = try makeRequest(path)
108
+ request.httpMethod = "GET"
109
+ let (data, response) = try await URLSession.shared.data(for: request)
110
+ try throwIfBad(response)
111
+ return try JSONDecoder().decode(NotchSnapshot.self, from: data)
112
+ }
113
+
114
+ private func post(_ path: String, body: [String: String]) async throws {
115
+ try await postJSON(path, payload: body)
116
+ }
117
+
118
+ private func postJSON(_ path: String, payload: [String: Any]) async throws {
119
+ var request = try makeRequest(path)
120
+ request.httpMethod = "POST"
121
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
122
+ request.httpBody = try JSONSerialization.data(withJSONObject: payload)
123
+ let (_, response) = try await URLSession.shared.data(for: request)
124
+ try throwIfBad(response)
125
+ }
126
+
127
+ private func makeRequest(_ path: String) throws -> URLRequest {
128
+ if origin.isEmpty { try reloadRuntime() }
129
+ guard let url = URL(string: origin + path) else { throw NotchClientError.noRuntime }
130
+ var request = URLRequest(url: url, timeoutInterval: 8)
131
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
132
+ return request
133
+ }
134
+
135
+ private func throwIfBad(_ response: URLResponse) throws {
136
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
137
+ if status < 200 || status >= 300 { throw NotchClientError.http(status) }
138
+ }
139
+ }
@@ -0,0 +1,423 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ struct IdleEye: Decodable {
5
+ var x: Double; var y: Double; var w: Double; var h: Double
6
+ var r: Double; var angle: Double; var opacity: Double
7
+ }
8
+ struct IdleFrame: Decodable {
9
+ var points: [[Double]]; var eyes: [IdleEye]; var body: Int; var eye: Int
10
+ }
11
+ struct IdleClip: Decodable {
12
+ var fps: Double; var duration: Double; var frames: [IdleFrame]
13
+ }
14
+
15
+ enum IdleInterpolation {
16
+ static func mix(_ a: IdleFrame, _ b: IdleFrame, _ t: Double) -> IdleFrame {
17
+ let w = max(0, min(1, t))
18
+ func n(_ x: Double, _ y: Double) -> Double { x + (y-x)*w }
19
+ func color(_ x: Int, _ y: Int) -> Int {
20
+ [16,8,0].reduce(0) { $0 | (Int(n(Double((x >> $1)&255), Double((y >> $1)&255)).rounded()) << $1) }
21
+ }
22
+ let points = zip(a.points,b.points).map { [n($0[0],$1[0]),n($0[1],$1[1])] }
23
+ let eyes = zip(a.eyes,b.eyes).map { e,f in
24
+ IdleEye(x:n(e.x,f.x),y:n(e.y,f.y),w:n(e.w,f.w),h:n(e.h,f.h),r:n(e.r,f.r),angle:n(e.angle,f.angle),opacity:n(e.opacity,f.opacity))
25
+ }
26
+ return IdleFrame(points:points,eyes:eyes,body:color(a.body,b.body),eye:color(a.eye,b.eye))
27
+ }
28
+ // Shape-preserving cubic Hermite interpolation: continuous velocity between
29
+ // authored samples, without overshoot or changing the clip's choreography.
30
+ static func sample(_ clip: IdleClip, at position: Double) -> IdleFrame {
31
+ let p = min(Double(clip.frames.count-1), max(0, position)), i = Int(p), t = p-Double(i)
32
+ let a=clip.frames[max(0,i-1)], b=clip.frames[i]
33
+ let c=clip.frames[min(i+1,clip.frames.count-1)], d=clip.frames[min(i+2,clip.frames.count-1)]
34
+ func curve(_ a:Double,_ b:Double,_ c:Double,_ d:Double) -> Double {
35
+ func slope(_ x:Double,_ y:Double)->Double { x*y > 0 ? 2*x*y/(x+y) : 0 }
36
+ let m=slope(b-a,c-b), n=slope(c-b,d-c), t2=t*t, t3=t2*t
37
+ return (2*t3-3*t2+1)*b+(t3-2*t2+t)*m+(-2*t3+3*t2)*c+(t3-t2)*n
38
+ }
39
+ var result=mix(b,c,t)
40
+ result.points=b.points.indices.map { j in (0...1).map { k in curve(a.points[j][k],b.points[j][k],c.points[j][k],d.points[j][k]) } }
41
+ result.eyes=b.eyes.indices.map { j in
42
+ let a=a.eyes[j],b=b.eyes[j],c=c.eyes[j],d=d.eyes[j]
43
+ return IdleEye(x:curve(a.x,b.x,c.x,d.x),y:curve(a.y,b.y,c.y,d.y),w:curve(a.w,b.w,c.w,d.w),h:curve(a.h,b.h,c.h,d.h),r:curve(a.r,b.r,c.r,d.r),angle:curve(a.angle,b.angle,c.angle,d.angle),opacity:curve(a.opacity,b.opacity,c.opacity,d.opacity))
44
+ }
45
+ return result
46
+ }
47
+ static func carry(_ source:IdleFrame, previous:IdleFrame?, seconds:Double) -> IdleFrame {
48
+ guard let previous else { return source }
49
+ var result=source
50
+ let distance=seconds*240
51
+ result.points=zip(source.points,previous.points).map { a,b in [a[0]+(a[0]-b[0])*distance,a[1]+(a[1]-b[1])*distance] }
52
+ result.eyes=zip(source.eyes,previous.eyes).map { a,b in
53
+ func n(_ x:Double,_ y:Double)->Double { x+(x-y)*distance }
54
+ return IdleEye(x:n(a.x,b.x),y:n(a.y,b.y),w:max(0,n(a.w,b.w)),h:max(0,n(a.h,b.h)),r:max(0,n(a.r,b.r)),angle:n(a.angle,b.angle),opacity:min(1,max(0,n(a.opacity,b.opacity))))
55
+ }
56
+ return result
57
+ }
58
+ static func smooth(_ value: Double) -> Double {
59
+ let t=max(0,min(1,value));return t*t*t*(t*(t*6-15)+10)
60
+ }
61
+ }
62
+
63
+ @MainActor
64
+ final class IdleLibrary {
65
+ static let shared = IdleLibrary()
66
+ private var clips: [String: IdleClip] = [:]
67
+ func clip(_ id: String) -> IdleClip? {
68
+ if let clip = clips[id] { return clip }
69
+ guard let url = Bundle.module.url(forResource: id, withExtension: "json", subdirectory: "Idle"),
70
+ let data = try? Data(contentsOf: url), let clip = try? JSONDecoder().decode(IdleClip.self, from: data),
71
+ !clip.frames.isEmpty else { return nil }
72
+ clips[id] = clip
73
+ return clip
74
+ }
75
+ }
76
+
77
+ /// Only advances while an idle robot is visible. Screen sleep never queues overdue gags.
78
+ @MainActor
79
+ final class IdleDirector: ObservableObject {
80
+ static weak var previewInstance: IdleDirector?
81
+ static let basics = ["blink", "scan", "tilt", "nod", "stretch", "hop", "balance", "sneeze", "sleep"]
82
+ @Published var action: String? = "blink"
83
+ @Published var began = Date()
84
+ @Published var asleep = false
85
+ private(set) var currentDuration: Double = 7
86
+ static func restDuration() -> Double { Double.random(in: 5...10) }
87
+ private var timer: Timer?
88
+ private var nextBasic = Date()
89
+ private var nextRare = Date()
90
+ private var previous = "blink"
91
+ private var blendFrom: IdleFrame?
92
+ private var blendPrevious: IdleFrame?
93
+ private var blendBegan = Date()
94
+ private let blinkEpoch=Date()
95
+ var animating: Bool { timer != nil || action != nil || blendFrom != nil }
96
+ static func blinkClosure(at elapsed:Double) -> Double {
97
+ let durations=[3.7,4.9,3.2,4.6,4.1,3.5]
98
+ let cycle=durations.reduce(0,+)
99
+ var phase=max(0,elapsed).truncatingRemainder(dividingBy:cycle)
100
+ for duration in durations {
101
+ if phase < duration {
102
+ let t=phase-(duration-0.28)
103
+ guard t >= 0 else { return 0 }
104
+ return t < 0.09 ? IdleInterpolation.smooth(t/0.09) : 1-IdleInterpolation.smooth((t-0.09)/0.19)
105
+ }
106
+ phase-=duration
107
+ }
108
+ return 0
109
+ }
110
+ func displayFrame(at now:Date)->IdleFrame? {
111
+ guard var f=frame(at:now) else { return nil }
112
+ guard !reduceMotion,action != "sleep",action != "blink",action != "sneeze" else { return f }
113
+ let close=Self.blinkClosure(at:now.timeIntervalSince(blinkEpoch))
114
+ f.eyes=f.eyes.map { eye in var e=eye;e.h=max(0.5,e.h*(1-0.95*close));e.r=min(e.r,e.h/2);return e }
115
+ return f
116
+ }
117
+ func frame(at now: Date) -> IdleFrame? {
118
+ guard let neutral=IdleLibrary.shared.clip("blink")?.frames.first else { return nil }
119
+ var target=neutral
120
+ if let id=action,let clip=IdleLibrary.shared.clip(id) {
121
+ let elapsed=max(0,now.timeIntervalSince(began))
122
+ let position=min(elapsed*clip.fps,Double(clip.frames.count-1))
123
+ target=IdleInterpolation.sample(clip,at:position)
124
+ if id == "dance" {
125
+ let weight=IdleInterpolation.smooth(min(elapsed/0.35,(currentDuration-elapsed)/0.45))
126
+ target=IdleInterpolation.mix(neutral,target,weight)
127
+ }
128
+ }
129
+ if let source=blendFrom {
130
+ let elapsed=max(0,now.timeIntervalSince(blendBegan))
131
+ let moving=IdleInterpolation.carry(source,previous:blendPrevious,seconds:0.06*(1-exp(-elapsed/0.06)))
132
+ return IdleInterpolation.mix(moving,target,IdleInterpolation.smooth(elapsed/0.35))
133
+ }
134
+ return target
135
+ }
136
+ private func beginBlend(from source: IdleFrame?, at now: Date) {
137
+ let previous=frame(at:now.addingTimeInterval(-1.0/240))
138
+ blendPrevious=previous;blendFrom=source;blendBegan=now
139
+ }
140
+
141
+ var automaticActions = true
142
+ private var sequence = 0
143
+ private var observers: [NSObjectProtocol] = []
144
+ var reduceMotion: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
145
+ func start(playImmediately: Bool = true) {
146
+ Self.previewInstance = self
147
+ guard timer == nil else { return }
148
+ blendFrom = nil
149
+ currentDuration = IdleLibrary.shared.clip("blink")?.duration ?? 7
150
+ began = Date(); action = reduceMotion || !playImmediately ? nil : "blink"
151
+ schedule(from: began)
152
+ let nc = NSWorkspace.shared.notificationCenter
153
+ observers = [nc.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main) { [weak self] _ in
154
+ Task { @MainActor in self?.asleep = true; self?.action = nil }
155
+ }, nc.addObserver(forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main) { [weak self] _ in
156
+ Task { @MainActor in guard let self else { return }; self.asleep = false; self.schedule(from: Date()) }
157
+ }]
158
+ timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
159
+ Task { @MainActor in self?.tick(Date()) }
160
+ }
161
+ }
162
+ private func schedule(from date: Date) {
163
+ nextBasic = date.addingTimeInterval(Self.restDuration())
164
+ nextRare = date.addingTimeInterval(Double.random(in: 1200...2400))
165
+ }
166
+ func tick(_ now: Date) {
167
+ guard !asleep, !reduceMotion else { blendFrom = nil; action = nil; return }
168
+ if blendFrom != nil && now.timeIntervalSince(blendBegan) >= 0.35 { blendFrom = nil; objectWillChange.send() }
169
+ if action != nil {
170
+ let duration = currentDuration
171
+ if now.timeIntervalSince(began) >= duration { let source=frame(at:now); beginBlend(from:source,at:now); self.action = nil; nextBasic = now.addingTimeInterval(Self.restDuration()) }
172
+ return
173
+ }
174
+ guard automaticActions else { return }
175
+ if now >= nextRare {
176
+ play("dance"); nextRare = now.addingTimeInterval(Double.random(in: 1200...2400))
177
+ } else if now >= nextBasic {
178
+ play(Self.basics.filter { $0 != previous }.randomElement() ?? "blink")
179
+ }
180
+ }
181
+ func play(_ id: String, at now: Date = Date()) {
182
+ guard !asleep, !reduceMotion else { return }
183
+ guard IdleLibrary.shared.clip(id) != nil else { return }
184
+ let source=frame(at:now)
185
+ beginBlend(from:source,at:now)
186
+ currentDuration = id == "dance" ? Double.random(in: 3...5) : (IdleLibrary.shared.clip(id)?.duration ?? 7)
187
+ previous = id; began = now; action = id
188
+ if id == "dance" { nextRare = began.addingTimeInterval(Double.random(in: 1200...2400)) }
189
+ }
190
+ func resume(_ id: String, elapsed: Double, from source: IdleFrame? = nil) {
191
+ guard !reduceMotion, !asleep else { return }
192
+ beginBlend(from:source,at:Date()); blendPrevious=source
193
+ currentDuration = id == "dance" ? Double.random(in: 3...5) : (IdleLibrary.shared.clip(id)?.duration ?? 7)
194
+ previous = id; action = id; began = Date().addingTimeInterval(-elapsed)
195
+ }
196
+ func tryNext() { play(Self.basics[sequence % Self.basics.count]); sequence += 1 }
197
+ func stop() {
198
+ if Self.previewInstance === self { Self.previewInstance = nil }
199
+ timer?.invalidate(); timer = nil; action = nil; blendFrom = nil
200
+ for observer in observers { NSWorkspace.shared.notificationCenter.removeObserver(observer) }
201
+ observers.removeAll()
202
+ }
203
+ }
204
+
205
+ struct IdleRobotCanvas: View {
206
+ let clip: IdleClip?
207
+ let elapsed: Double
208
+ var visibility: Double = 1
209
+ var entering: Bool = false
210
+ var entryColor: Int? = nil
211
+ var exitColor: Int = 0x4d6bfe
212
+ private func color(_ rgb: Int) -> Color {
213
+ Color(red: Double((rgb >> 16) & 255) / 255, green: Double((rgb >> 8) & 255) / 255, blue: Double(rgb & 255) / 255)
214
+ }
215
+ var body: some View {
216
+ let neutralFrame = IdleLibrary.shared.clip("blink")?.frames.first
217
+ return Canvas { context, size in
218
+ guard let clip, !clip.frames.isEmpty else { return }
219
+ // Sampled vector paths are interpolated at display cadence, never enlarged bitmaps.
220
+ let position = min(max(0, elapsed * clip.fps), Double(clip.frames.count - 1))
221
+ let index = Int(position), amount = position - Double(index)
222
+ let a = clip.frames[index], b = clip.frames[min(index + 1, clip.frames.count - 1)]
223
+ let edge = clip.duration > 10 ? min(1, max(0, min(elapsed / 0.35, (clip.duration - elapsed) / 0.45))) : 1
224
+ let danceBlend = edge * edge * (3 - 2 * edge)
225
+ func mix(_ x: Double, _ y: Double) -> Double { x + (y - x) * amount }
226
+ func mixedColor(_ x: Int, _ y: Int) -> Color {
227
+ Color(red: mix(Double((x >> 16) & 255), Double((y >> 16) & 255)) / 255,
228
+ green: mix(Double((x >> 8) & 255), Double((y >> 8) & 255)) / 255,
229
+ blue: mix(Double(x & 255), Double(y & 255)) / 255)
230
+ }
231
+ context.translateBy(x: size.width / 2, y: size.height / 2)
232
+ context.scaleBy(x: 40.0 / 280.0, y: 40.0 / 280.0)
233
+ var centerX = 0.0, centerY = 0.0
234
+ for i in a.points.indices {
235
+ let n = neutralFrame?.points[i] ?? a.points[i]
236
+ centerX += mix(a.points[i][0], b.points[i][0]) * danceBlend + n[0] * (1-danceBlend)
237
+ centerY += mix(a.points[i][1], b.points[i][1]) * danceBlend + n[1] * (1-danceBlend)
238
+ }
239
+ centerX /= Double(a.points.count); centerY /= Double(a.points.count)
240
+ var bodyPath = Path()
241
+ for i in a.points.indices {
242
+ let q = a.points[i], r = b.points[i]
243
+ let n = neutralFrame?.points[i] ?? q
244
+ let x = mix(q[0], r[0]) * danceBlend + n[0] * (1 - danceBlend)
245
+ let y = mix(q[1], r[1]) * danceBlend + n[1] * (1 - danceBlend)
246
+ let angle = atan2(y - centerY, x - centerX)
247
+ let radius = (entering ? 9.5:5.7) * 280.0 / 40.0
248
+ let point = CGPoint(x: x * visibility + cos(angle) * radius * (1 - visibility),
249
+ y: y * visibility + sin(angle) * radius * (1 - visibility))
250
+ if i == 0 { bodyPath.move(to: point) } else { bodyPath.addLine(to: point) }
251
+ }
252
+ bodyPath.closeSubpath()
253
+ // Dance begins and ends through the neutral body, retaining original bounce speed.
254
+ let pigment = mixedColor(a.body, b.body)
255
+ var filled = context
256
+ filled.opacity = entering ? 1 : max(0, (visibility - 0.30) / 0.70)
257
+ let entryBlend=visibility
258
+ let origin=entryColor ?? 0x4d6bfe
259
+ func entryChannel(_ shift:Int,_ end:Double)->Double { (Double((origin >> shift)&255)*(1-entryBlend)+end*entryBlend)/255 }
260
+ let entryInk=Color(red:entryChannel(16,229),green:entryChannel(8,229),blue:entryChannel(0,231))
261
+ filled.fill(bodyPath, with: .color(entering ? entryInk : color(0xe5e5e7)))
262
+ var painted = filled; painted.opacity *= danceBlend * (entering ? entryBlend:1); painted.fill(bodyPath, with: .color(pigment))
263
+ if !entering && visibility < 1 {
264
+ var outline = context; outline.opacity = min(1, (1 - visibility) * 3) * min(1, visibility * 4)
265
+ func inkChannel(_ shift: Int, _ neutral: Double, _ blue: Double) -> Double {
266
+ let current = mix(Double((a.body >> shift) & 255), Double((b.body >> shift) & 255)) * danceBlend + neutral * (1-danceBlend)
267
+ return (current * visibility + Double((exitColor >> shift)&255) * (1-visibility))/255
268
+ }
269
+ let ink = Color(red: inkChannel(16,229,77), green: inkChannel(8,229,107), blue: inkChannel(0,231,254))
270
+ outline.stroke(bodyPath, with: .color(ink), lineWidth: 1.7 * 280/40)
271
+ }
272
+ context.clip(to: bodyPath)
273
+ for i in a.eyes.indices where i < b.eyes.count {
274
+ let rawE = a.eyes[i], rawF = b.eyes[i]
275
+ let neutral = neutralFrame?.eyes[i] ?? rawE
276
+ func blendEye(_ v: IdleEye) -> IdleEye {
277
+ func blend(_ x: Double, _ n: Double) -> Double { x*danceBlend+n*(1-danceBlend) }
278
+ return IdleEye(x:blend(v.x,neutral.x),y:blend(v.y,neutral.y),w:blend(v.w,neutral.w),h:blend(v.h,neutral.h),r:blend(v.r,neutral.r),angle:blend(v.angle,neutral.angle),opacity:blend(v.opacity,neutral.opacity))
279
+ }
280
+ let e = blendEye(rawE), f = blendEye(rawF)
281
+ var eyeContext = context
282
+ eyeContext.translateBy(x: mix(e.x, f.x) * visibility, y: mix(e.y, f.y) * visibility)
283
+ eyeContext.rotate(by: .degrees(mix(e.angle, f.angle)))
284
+ eyeContext.opacity = mix(e.opacity, f.opacity) * max(0, min(1, (visibility - 0.60) / 0.40))
285
+ let w = mix(e.w, f.w), h = mix(e.h, f.h), r = mix(e.r, f.r)
286
+ eyeContext.fill(Path(roundedRect: CGRect(x: -w/2, y: -h/2, width: w, height: h), cornerRadius: r), with: .color(mixedColor(a.eye, b.eye)))
287
+ }
288
+ }
289
+ .allowsHitTesting(false)
290
+ }
291
+ }
292
+
293
+ struct RobotDeparture {
294
+ let progress:Double
295
+ static let duration=1.22
296
+ private var robotProgress:Double { progress*Self.duration/0.9 }
297
+ var scale:Double { 1-0.94*IdleInterpolation.smooth((robotProgress-0.48)/0.20) }
298
+ var opacity:Double { 1-IdleInterpolation.smooth((robotProgress-0.68)/0.06) }
299
+ var statusOpacity:Double { IdleInterpolation.smooth((robotProgress-0.68)/0.06) }
300
+ var statusScale:Double { min(1,max(0,(progress*Self.duration-0.666)/(Self.duration-0.666))) }
301
+ }
302
+
303
+ @MainActor
304
+ final class IdlePresence: ObservableObject {
305
+ @Published var visibility: Double = 1
306
+ var returnColor: Int = 0x4d6bfe
307
+ @Published var entering = true
308
+ @Published var transitioning = false
309
+ @Published var departureProgress = 1.0
310
+ private var transitionAt=Date()
311
+ private var transitionIdle=true
312
+ private var blendReversal=false
313
+ func presentedFrame(at now:Date)->IdleFrame? {
314
+ guard transitioning,let clip=IdleLibrary.shared.clip(transitionIdle ? "cube-in":"satellite-out") else { return coastFrame(at:now) }
315
+ let elapsed=max(0,now.timeIntervalSince(transitionAt))
316
+ let target=IdleInterpolation.sample(clip,at:elapsed*clip.fps)
317
+ guard (!transitionIdle || blendReversal),let source=coastFrame(at:now) else { return target }
318
+ return IdleInterpolation.mix(source,target,IdleInterpolation.smooth(elapsed/0.22))
319
+ }
320
+ var frozenFrame: IdleFrame?
321
+ var frozenID = "blink"
322
+ var frozenElapsed = 0.0
323
+ private var frozenPrevious: IdleFrame?
324
+ private var frozenAt=Date()
325
+ func coastFrame(at now:Date)->IdleFrame? {
326
+ guard let frozenFrame else { return nil }
327
+ let t=max(0,now.timeIntervalSince(frozenAt))
328
+ return IdleInterpolation.carry(frozenFrame,previous:frozenPrevious,seconds:0.06*(1-exp(-t/0.06)))
329
+ }
330
+ private var timer: Timer?
331
+ private var generation = 0
332
+ func set(_ idle: Bool, director: IdleDirector, animated: Bool = true) {
333
+ let reversing=transitioning
334
+ if reversing { frozenFrame=presentedFrame(at:Date());frozenPrevious=nil;frozenAt=Date() }
335
+ blendReversal=reversing
336
+ timer?.invalidate(); timer = nil
337
+ generation += 1; let current = generation
338
+ if !idle {
339
+ if visibility >= 1 && !blendReversal {
340
+ frozenAt=Date()
341
+ frozenPrevious=director.displayFrame(at:frozenAt.addingTimeInterval(-1.0/240))
342
+ frozenFrame = director.displayFrame(at:frozenAt)
343
+ frozenID = director.action ?? "blink"
344
+ frozenElapsed = director.action == nil ? 0 : max(0, Date().timeIntervalSince(director.began))
345
+ }
346
+ director.stop()
347
+ } else {
348
+ frozenFrame=coastFrame(at:Date());frozenPrevious=nil
349
+ director.start(playImmediately: false)
350
+ // Continue the same geometry when a partially completed transition reverses.
351
+ if visibility == 0 { frozenID = "blink"; frozenElapsed = 0; frozenFrame = IdleLibrary.shared.clip("blink")?.frames.first }
352
+ }
353
+ // Preserve circle geometry when reversing mid-flight.
354
+ if !blendReversal { entering = idle }
355
+ let start = visibility, end = idle ? 1.0 : 0.0
356
+ transitionAt=Date();transitionIdle=idle;transitioning=animated
357
+ if !idle { departureProgress=animated ? 0:1 }
358
+
359
+ guard animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { transitioning=false;visibility = end; return }
360
+ let began = ProcessInfo.processInfo.systemUptime
361
+ timer = Timer.scheduledTimer(withTimeInterval: 1/60.0, repeats: true) { [weak self] _ in
362
+ Task { @MainActor in
363
+ guard let self, self.generation == current else { return }
364
+ let t = min(1, (ProcessInfo.processInfo.systemUptime - began) / (idle ? 1.05 : RobotDeparture.duration))
365
+ if !idle { self.departureProgress=t }
366
+ let smooth = idle ? IdleInterpolation.smooth(t/0.32) : IdleInterpolation.smooth((t-0.68)/0.32)
367
+ self.visibility = start + (end-start)*smooth
368
+ if t >= 1 {
369
+ self.timer?.invalidate(); self.timer = nil
370
+ let final=self.presentedFrame(at:Date())
371
+ self.transitioning=false
372
+ if idle { director.resume("blink", elapsed:0, from:final) }
373
+ }
374
+ }
375
+ }
376
+ }
377
+ func stop() { generation += 1; timer?.invalidate(); timer = nil }
378
+ }
379
+
380
+ struct IdleStatusSlot: View {
381
+ @ObservedObject var model: BoardModel
382
+ @StateObject private var director = IdleDirector()
383
+ @StateObject private var presence = IdlePresence()
384
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
385
+ private var idle: Bool { model.showsIdleRobot }
386
+ private var hasStatus: Bool { model.orbitLayout.total > 0.0001 || model.anyFailed || model.completedUnreadCount > 0 || model.busyCount > 0 || model.statusFlight != nil }
387
+ var body: some View {
388
+ let departing = presence.transitioning && !presence.entering
389
+ let departure = RobotDeparture(progress:presence.departureProgress)
390
+ ZStack {
391
+ if hasStatus {
392
+ StatusOrbitView(model: model,workReveal:departing ? departure.statusScale:1)
393
+ .opacity(departing ? departure.statusOpacity:1-presence.visibility)
394
+ }
395
+ if presence.visibility > 0 || idle {
396
+ TimelineView(.animation(minimumInterval: 1/60.0, paused: (!director.animating && !presence.transitioning && presence.visibility >= 1) || director.asleep || reduceMotion)) { timeline in
397
+ let stable = idle && !presence.transitioning
398
+ let frame = stable ? director.displayFrame(at: timeline.date) : presence.presentedFrame(at:timeline.date)
399
+ let sampled = frame.map { IdleClip(fps:1,duration:7,frames:[$0]) }
400
+ IdleRobotCanvas(clip: sampled, elapsed: 0, visibility: departing ? 1:presence.visibility, entering: presence.entering, entryColor:presence.returnColor,exitColor:model.needsAction ? 0xf2ff14:0x4d6bfe)
401
+ }
402
+ .frame(width: 30, height: 42)
403
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
404
+ .scaleEffect(departing ? departure.scale:1)
405
+ .opacity(departing ? departure.opacity:1)
406
+ .accessibilityLabel("待机机器人")
407
+ .contextMenu {
408
+ if idle {
409
+ Button("试试下一个待机动作") { director.tryNext() }
410
+ Button("播放变色跳舞彩蛋") { director.play("dance") }
411
+ }
412
+ }
413
+ }
414
+ }
415
+ .frame(height: !hasStatus && !idle ? 0 : nil)
416
+ .onAppear { presence.set(idle, director: director, animated: false) }
417
+ .onChange(of: idle) { _, value in
418
+ if value { presence.returnColor=model.orbitLayout.decision > 0.1 ? 0xf2ff14:model.retainedFailureCount > 0 ? 0xff4000:model.retainedSuccessCount > 0 ? 0x34c759:0x4d6bfe }
419
+ presence.set(value, director: director)
420
+ }
421
+ .onDisappear { presence.stop(); director.stop() }
422
+ }
423
+ }