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,642 @@
1
+ import SwiftUI
2
+ import AppKit
3
+ import CoreText
4
+
5
+ struct OrbitLayout: Equatable {
6
+ var top: Double = 0
7
+ var middle: Double = 0
8
+ var bottom: Double = 0
9
+ var decision: Double = 0
10
+ var total: Double { top+middle+bottom+decision }
11
+ var height: CGFloat { 20+28*max(0,total-1) }
12
+ var middleY: CGFloat { 10+28*(top+max(0,decision+middle-1)) }
13
+ var bottomY: CGFloat { height-10 }
14
+ static func mix(_ a:Self,_ b:Self,_ t:Double)->Self {
15
+ Self(top:a.top+(b.top-a.top)*t,middle:a.middle+(b.middle-a.middle)*t,bottom:a.bottom+(b.bottom-a.bottom)*t,decision:a.decision+(b.decision-a.decision)*t)
16
+ }
17
+ static func flight(from a:Self,to b:Self,progress:Double,flight:StatusFlight)->Self {
18
+ let growth=OrbitMotionFrame.ease(progress/0.48)
19
+ var result=mix(a,b,growth)
20
+ if !flight.returnsToRunning {
21
+ let source=OrbitMotionFrame(progress:progress,failed:flight.failed,returns:false,angle:flight.angle).sourceOpacity
22
+ // Existing result: close the vacated slot as ink leaves it. A new result
23
+ // first needs room for its drawn circle, then releases the source slot.
24
+ let remaining=flight.destinationBefore > 0 ? source:1-OrbitMotionFrame.ease((progress-0.72)/0.28)
25
+ result.middle=max(a.middle,1)*remaining
26
+ }
27
+ return result
28
+ }
29
+ }
30
+
31
+ enum StatusOutcome {
32
+ case success, failure, decision
33
+ var rgb: (Double, Double, Double) {
34
+ switch self {
35
+ case .success: (0.204, 0.780, 0.349)
36
+ case .failure: (1.0, 0.251, 0.0)
37
+ case .decision: (0.949, 1.0, 0.078)
38
+ }
39
+ }
40
+ var color: Color { let c=rgb; return Color(red:c.0,green:c.1,blue:c.2) }
41
+ }
42
+
43
+ struct StatusFlight: Identifiable {
44
+ let id = UUID()
45
+ let outcome: StatusOutcome
46
+ var failed: Bool { outcome == .failure }
47
+ var decision: Bool { outcome == .decision }
48
+ let startedAt: Date
49
+ let busyBefore: Int
50
+ let destinationBefore: Int
51
+ let returnsToRunning: Bool
52
+ let angle: Double
53
+ init(failed: Bool, startedAt: Date, busyBefore: Int, destinationBefore: Int, returnsToRunning: Bool = true, angle:Double? = nil) {
54
+ self.init(outcome:failed ? .failure:.success,startedAt:startedAt,busyBefore:busyBefore,destinationBefore:destinationBefore,returnsToRunning:returnsToRunning,angle:angle)
55
+ }
56
+ init(outcome: StatusOutcome, startedAt: Date, busyBefore: Int, destinationBefore: Int, returnsToRunning: Bool = true, angle:Double? = nil) {
57
+ self.outcome = outcome
58
+ self.startedAt = startedAt
59
+ self.busyBefore = busyBefore
60
+ self.destinationBefore = destinationBefore
61
+ self.returnsToRunning = returnsToRunning
62
+ self.angle=angle ?? startedAt.timeIntervalSinceReferenceDate*2 * .pi/3
63
+ }
64
+ static let duration: TimeInterval = 0.95
65
+ }
66
+
67
+ /// A reply returns the same task to the running lamp. It owns a clock so
68
+ /// polling and panel folding cannot reset its ink, count, or layout progress.
69
+ struct DecisionReturn: Identifiable {
70
+ let id = UUID()
71
+ let startedAt: Date
72
+ let busyBefore: Int
73
+ let from: OrbitLayout
74
+ let angle: Double
75
+ var travelling: Bool { from.middle > 0.001 || from.top > 0.001 }
76
+ var duration: Double { travelling ? StatusFlight.duration:DecisionMorph.resumeDuration }
77
+ func progress(at now:Date)->Double { min(1,max(0,now.timeIntervalSince(startedAt)/duration)) }
78
+ }
79
+
80
+ struct DecisionReturnFrame {
81
+ let progress: Double
82
+ let distance: CGFloat
83
+ let tailLength: CGFloat
84
+ let tint: Double
85
+ let fill: Double
86
+ let strokeOpacity: Double
87
+ let countMix: Double
88
+ let collapse: Double
89
+ let baseRingOpacity: Double
90
+ init(progress:Double,angle:Double) {
91
+ let p=min(1,max(0,progress));self.progress=p
92
+ let route=OrbitBrushRoute(failed:false,angle:angle)
93
+ let u=min(1,max(0,(p-0.14)/0.86))
94
+ let span=route.total-route.resultDrawn
95
+ let terminal=9.5*DecisionSpin.runningVelocity*StatusFlight.duration*0.86/span
96
+ let paced=(3*u*u-2*u*u*u)+terminal*(u*u*u-u*u)
97
+ distance=route.resultDrawn+span*paced
98
+ let leaving=OrbitMotionFrame.ease((distance-route.resultDrawn)/max(1,route.returned-route.resultDrawn))
99
+ let joined=OrbitMotionFrame.ease((distance-route.returned)/max(1,route.total-route.returned))
100
+ tailLength=(2 * .pi*9.5)+(18-2 * .pi*9.5)*leaving+(route.departure-18)*joined
101
+ tint=1-leaving
102
+ fill=1-OrbitMotionFrame.ease(p/0.28)
103
+ strokeOpacity=OrbitMotionFrame.ease(p/0.14)
104
+ countMix=joined
105
+ collapse=OrbitMotionFrame.ease((p-0.35)/0.65)
106
+ // The existing ring keeps rotating while the incoming tip approaches it;
107
+ // the travelling trail becomes that same ring at the final phase/velocity.
108
+ baseRingOpacity=1-OrbitMotionFrame.ease((leaving-0.55)/0.45)
109
+ }
110
+ }
111
+
112
+ struct DecisionReturnStroke: Shape {
113
+ let frame:DecisionReturnFrame
114
+ let angle:Double
115
+ let gap:CGFloat
116
+ func path(in rect:CGRect)->Path {
117
+ let route=OrbitBrushRoute(failed:false,angle:angle,gap:gap)
118
+ let original=OrbitBrushRoute(failed:false,angle:angle)
119
+ let source=[original.arrival,original.resultDrawn,original.returned,original.total]
120
+ let target=[route.arrival,route.resultDrawn,route.returned,route.total]
121
+ func mapped(_ d:CGFloat)->CGFloat {
122
+ for i in 1..<source.count where d <= source[i] {
123
+ return target[i-1]+(target[i]-target[i-1])*(d-source[i-1])/max(0.00001,source[i]-source[i-1])
124
+ }
125
+ return route.total
126
+ }
127
+ return route.path(in:mapped(frame.distance-frame.tailLength)...mapped(frame.distance))
128
+ .offsetBy(dx:rect.midX,dy:rect.midY)
129
+ }
130
+ }
131
+
132
+ struct OrbitMotionFrame {
133
+ let progress: Double
134
+ let failed: Bool
135
+ let returns: Bool
136
+ let distance: CGFloat
137
+ let tailLength: CGFloat
138
+ let tint: Double
139
+ let resultOpacity: Double
140
+ let sourceOpacity: Double
141
+ let arrived: Bool
142
+ let returning: Bool
143
+ let returned: Bool
144
+ static func ease(_ value: Double) -> Double {
145
+ let t = min(1, max(0, value))
146
+ return t * t * (3 - 2 * t)
147
+ }
148
+ private static func pacedHead(_ t:Double,_ values:[CGFloat])->CGFloat {
149
+ let times=[0.0,0.18,0.56,0.94,1.0]
150
+ func slope(_ i:Int)->CGFloat {
151
+ guard i > 0 && i < 4 else { return 0 }
152
+ let a=(values[i]-values[i-1])/(times[i]-times[i-1])
153
+ let b=(values[i+1]-values[i])/(times[i+1]-times[i])
154
+ return a*b > 0 ? 2*a*b/(a+b):0
155
+ }
156
+ let i=(0..<4).first { t <= times[$0+1] } ?? 3
157
+ let dt=times[i+1]-times[i],u=min(1,max(0,(t-times[i])/dt)),u2=u*u,u3=u2*u
158
+ return (2*u3-3*u2+1)*values[i]+(u3-2*u2+u)*dt*slope(i)+(-2*u3+3*u2)*values[i+1]+(u3-u2)*dt*slope(i+1)
159
+ }
160
+ init(progress: Double, failed: Bool, returns: Bool, angle: Double = 0) {
161
+ self.progress = min(1, max(0, progress))
162
+ self.failed = failed
163
+ self.returns = returns
164
+ let route = OrbitBrushRoute(failed: failed, angle: angle)
165
+ let end = returns ? route.total : route.resultDrawn + 18
166
+ let u=self.progress
167
+ let terminal=9.5*DecisionSpin.runningVelocity*StatusFlight.duration/(end-route.departure)
168
+ let paced=(3*u*u-2*u*u*u)+terminal*(u-3*u*u+2*u*u*u)
169
+ let head = returns ? route.departure + (end - route.departure) * paced : Self.pacedHead(self.progress,[route.departure,route.sourceExit,route.arrival,route.resultDrawn,route.resultDrawn+18])
170
+ distance = min(head, returns ? route.total : route.resultDrawn)
171
+ let outbound = Self.ease((head - route.sourceExit) / (route.arrival - route.sourceExit))
172
+ let inbound = Self.ease((head - route.resultDrawn + 8) / (route.returned - route.resultDrawn + 16))
173
+ let tintStart = max(route.departure, route.sourceExit - 8)
174
+ let outgoingTint = Self.ease((head - tintStart) / (route.arrival + 8 - tintStart))
175
+ tint = returns && head > route.resultDrawn - 8 ? 1 - inbound : outgoingTint
176
+ resultOpacity = Self.ease((head - route.arrival - 0.55 * (route.resultDrawn - route.arrival)) / (0.35 * (route.resultDrawn - route.arrival)))
177
+ arrived = resultOpacity > 0
178
+ returning = returns && head > route.resultDrawn
179
+ returned = returns && head >= route.returned
180
+ sourceOpacity = returns ? 1 : 1 - outbound
181
+ if head < route.arrival { tailLength = route.departure + (18 - route.departure) * outbound }
182
+ else if !returns && head > route.resultDrawn { tailLength = max(0, 18 - (head - route.resultDrawn)) }
183
+ else if head > route.returned {
184
+ let t = (head - route.returned) / (route.total - route.returned)
185
+ tailLength = 18 + (route.departure - 18) * (3*t*t-2*t*t*t)
186
+ } else { tailLength = 18 }
187
+ }
188
+ }
189
+
190
+ /// An arc-length trail: the head draws forward and the tail follows on the same route.
191
+ struct OrbitBrushRoute {
192
+ private(set) var points: [CGPoint] = []
193
+ private(set) var lengths: [CGFloat] = []
194
+ private(set) var departure: CGFloat = 0
195
+ private(set) var sourceExit: CGFloat = 0
196
+ private(set) var arrival: CGFloat = 0
197
+ private(set) var resultDrawn: CGFloat = 0
198
+ private(set) var returned: CGFloat = 0
199
+ var total: CGFloat { lengths.last ?? 0 }
200
+
201
+ init(failed: Bool, angle: Double, gap: CGFloat = 28) {
202
+ let r: CGFloat = 9.5
203
+ let direction: CGFloat = failed ? 1 : -1
204
+ let y = direction * gap
205
+ arc(center: .zero, from: angle, sweep: 1.4 * .pi, radius: r)
206
+ departure = total
207
+ let exitAngle = failed ? Double.pi / 2 : -Double.pi / 2
208
+ let sourceHead = angle + 1.4 * .pi
209
+ let extra = (exitAngle - sourceHead).truncatingRemainder(dividingBy: 2 * .pi)
210
+ arc(center: .zero, from: sourceHead, sweep: extra < 0 ? extra + 2 * .pi : extra, radius: r)
211
+ sourceExit = total
212
+ let start = points.last!
213
+ curve(from: start, c1: CGPoint(x: start.x - 4 * sin(exitAngle), y: start.y + 4 * cos(exitAngle)),
214
+ c2: CGPoint(x: -r, y: y + 10), to: CGPoint(x: -r, y: y))
215
+ arrival = total
216
+ arc(center: CGPoint(x: 0, y: y), from: .pi, sweep: 2 * .pi, radius: r)
217
+ resultDrawn = total
218
+ let finalAngle = angle + StatusFlight.duration * 2 * .pi / 3
219
+ let end = CGPoint(x: r * cos(exitAngle), y: r * sin(exitAngle))
220
+ curve(from: points.last!, c1: CGPoint(x: -r, y: y - 10),
221
+ c2: CGPoint(x: end.x + 4 * sin(exitAngle), y: end.y - 4 * cos(exitAngle)), to: end)
222
+ returned = total
223
+ let resume = (finalAngle - exitAngle).truncatingRemainder(dividingBy: 2 * .pi)
224
+ arc(center: .zero, from: exitAngle, sweep: (resume < 0 ? resume + 2 * .pi : resume) + 1.4 * .pi, radius: r)
225
+ }
226
+
227
+ func range(for frame: OrbitMotionFrame) -> ClosedRange<CGFloat> {
228
+ max(0, frame.distance - frame.tailLength)...frame.distance
229
+ }
230
+
231
+ func path(in range: ClosedRange<CGFloat>) -> Path {
232
+ var path = Path()
233
+ guard range.upperBound - range.lowerBound > 0.01 else { return path }
234
+ var began = false
235
+ for i in 1..<points.count {
236
+ let a = lengths[i - 1], b = lengths[i]
237
+ guard b > a, b >= range.lowerBound, a <= range.upperBound else { continue }
238
+ let lo = max(a, range.lowerBound), hi = min(b, range.upperBound)
239
+ func point(_ d: CGFloat) -> CGPoint {
240
+ let t = (d - a) / (b - a)
241
+ return CGPoint(x: points[i-1].x + (points[i].x-points[i-1].x)*t,
242
+ y: points[i-1].y + (points[i].y-points[i-1].y)*t)
243
+ }
244
+ if !began { path.move(to: point(lo)); began = true }
245
+ path.addLine(to: point(hi))
246
+ }
247
+ return path
248
+ }
249
+
250
+ private mutating func append(_ point: CGPoint) {
251
+ let distance = points.last.map { hypot(point.x - $0.x, point.y - $0.y) } ?? 0
252
+ lengths.append(total + distance)
253
+ points.append(point)
254
+ }
255
+ private mutating func arc(center: CGPoint, from: Double, sweep: Double, radius: CGFloat) {
256
+ for i in 0...96 {
257
+ let a = from + sweep * Double(i) / 96
258
+ append(CGPoint(x: center.x + radius * cos(a), y: center.y + radius * sin(a)))
259
+ }
260
+ }
261
+ private mutating func curve(from: CGPoint, c1: CGPoint, c2: CGPoint, to: CGPoint) {
262
+ for i in 1...64 {
263
+ let t = CGFloat(i) / 64, u = 1 - t
264
+ append(CGPoint(x: u*u*u*from.x + 3*u*u*t*c1.x + 3*u*t*t*c2.x + t*t*t*to.x,
265
+ y: u*u*u*from.y + 3*u*u*t*c1.y + 3*u*t*t*c2.y + t*t*t*to.y))
266
+ }
267
+ }
268
+ }
269
+
270
+ struct OrbitStroke: Shape {
271
+ let frame: OrbitMotionFrame
272
+ let angle: Double
273
+ var gap: CGFloat = 28
274
+ func path(in rect: CGRect) -> Path {
275
+ let route = OrbitBrushRoute(failed: frame.failed, angle: angle, gap:gap)
276
+ let canonical=OrbitBrushRoute(failed:frame.failed,angle:angle)
277
+ let source=[CGFloat(0),canonical.departure,canonical.sourceExit,canonical.arrival,canonical.resultDrawn,canonical.returned,canonical.total]
278
+ let dest=[CGFloat(0),route.departure,route.sourceExit,route.arrival,route.resultDrawn,route.returned,route.total]
279
+ func map(_ d:CGFloat)->CGFloat {
280
+ for i in 1..<source.count where d <= source[i] {
281
+ let w=(d-source[i-1])/max(0.00001,source[i]-source[i-1])
282
+ return dest[i-1]+w*(dest[i]-dest[i-1])
283
+ }
284
+ return route.total
285
+ }
286
+ let range=canonical.range(for:frame)
287
+ return route.path(in: map(range.lowerBound)...map(range.upperBound)).offsetBy(dx: rect.midX, dy: rect.midY)
288
+ }
289
+ }
290
+
291
+ struct DecisionSpin {
292
+ let began:Date
293
+ let angle:Double
294
+ let initialVelocity:Double
295
+ let finalVelocity:Double
296
+ var delay:Double = 0
297
+ static let duration=0.62
298
+ static let runningVelocity=2 * Double.pi/3
299
+ func velocity(at now:Date)->Double {
300
+ let u=min(1,max(0,(now.timeIntervalSince(began)-delay)/Self.duration))
301
+ return initialVelocity+(finalVelocity-initialVelocity)*(3*u*u-2*u*u*u)
302
+ }
303
+ func position(at now:Date)->Double {
304
+ let elapsed=max(0,now.timeIntervalSince(began)-delay),t=min(elapsed,Self.duration),u=t/Self.duration
305
+ return angle+initialVelocity*t+(finalVelocity-initialVelocity)*Self.duration*(u*u*u-0.5*u*u*u*u)+finalVelocity*max(0,elapsed-Self.duration)
306
+ }
307
+ }
308
+ struct DecisionMorph {
309
+ let amount:Double
310
+ private var q:Double { min(1,max(0,amount)) }
311
+ private var p:Double { 1-q }
312
+ static let resumeDuration=0.98
313
+ private static let clearFraction=0.28/resumeDuration
314
+ var draw:Double {
315
+ let u=min(1,max(0,(p-Self.clearFraction)/(1-Self.clearFraction)))
316
+ let terminal=DecisionSpin.runningVelocity*(Self.resumeDuration-0.28)/(2 * Double.pi*0.70)
317
+ // Integrate a monotone smoothstep velocity: acceleration also reaches zero at handoff.
318
+ return (2-terminal)*u+(2*terminal-2)*(u*u*u-0.5*u*u*u*u)
319
+ }
320
+ var fill:Double { 1-IdleInterpolation.smooth((p-0.02)/(Self.clearFraction-0.02)) }
321
+ var flip:Double { 1-IdleInterpolation.smooth(p/Self.clearFraction) }
322
+ var trim:Double { 0.70*draw }
323
+ }
324
+
325
+ struct DecisionClosing {
326
+ static let duration=0.82
327
+ let amount:Double
328
+ var trim:Double { 0.70+0.30*IdleInterpolation.smooth(amount/0.5) }
329
+ var fill:Double { IdleInterpolation.smooth((amount-0.50)/0.38) }
330
+ var tint:Double { IdleInterpolation.smooth((amount-0.24)/0.55) }
331
+ var flip:Double { IdleInterpolation.smooth((amount-0.62)/0.38) }
332
+ }
333
+
334
+ /// Center visible ink, excluding font side bearings and baseline line-box padding.
335
+ @MainActor enum CenteredStatusGlyph {
336
+ private static var cache:[String:Path]=[:]
337
+ static func path(_ text:String)->Path {
338
+ if let cached=cache[text] { return cached }
339
+ let base=NSFont.systemFont(ofSize:10,weight:.bold)
340
+ let font=NSFont(descriptor:base.fontDescriptor.withDesign(.rounded) ?? base.fontDescriptor,size:10) ?? base
341
+ let ct=CTFontCreateWithName(font.fontName as CFString,10,nil)
342
+ let chars=Array(text.utf16)
343
+ var glyphs=[CGGlyph](repeating:0,count:chars.count)
344
+ CTFontGetGlyphsForCharacters(ct,chars,&glyphs,chars.count)
345
+ var advances=[CGSize](repeating:.zero,count:glyphs.count)
346
+ CTFontGetAdvancesForGlyphs(ct,.horizontal,glyphs,&advances,glyphs.count)
347
+ let combined=CGMutablePath();var x=0.0
348
+ for (i,glyph) in glyphs.enumerated() {
349
+ if let outline=CTFontCreatePathForGlyph(ct,glyph,nil) {
350
+ combined.addPath(outline,transform:CGAffineTransform(translationX:x,y:0))
351
+ }
352
+ x+=advances[i].width
353
+ }
354
+ let bounds=combined.boundingBoxOfPath
355
+ guard !bounds.isEmpty else { return Path() }
356
+ let scale=min(1,12/bounds.width)
357
+ let transform=CGAffineTransform(a:scale,b:0,c:0,d:-scale,tx:-bounds.midX*scale,ty:bounds.midY*scale)
358
+ var result=Path();result.addPath(Path(combined),transform:transform)
359
+ if text == "!" {
360
+ // The long stem carries more ink than the dot: align optical mass, not just bounds.
361
+ let ink=result.boundingRect,step=0.04
362
+ var sumY=0.0,count=0.0
363
+ for y in stride(from:ink.minY+step/2,to:ink.maxY,by:step) {
364
+ for x in stride(from:ink.minX+step/2,to:ink.maxX,by:step) {
365
+ if result.contains(CGPoint(x:x,y:y)) { sumY+=y;count+=1 }
366
+ }
367
+ }
368
+ if count > 0 { result=result.applying(CGAffineTransform(translationX:0,y:-sumY/count)) }
369
+ }
370
+ cache[text]=result;return result
371
+ }
372
+ }
373
+
374
+ /// Two halves hinge at the glyph's equator, like a split-flap calendar.
375
+ struct DecisionFlipGlyph:View {
376
+ let number:Int
377
+ let progress:Double
378
+ let color:Color
379
+ var body:some View {
380
+ let numeral=CenteredStatusGlyph.path("\(number)")
381
+ let mark=CenteredStatusGlyph.path("!")
382
+ Canvas { context,size in
383
+ func half(_ text:String,top:Bool,scale:Double,exposed:Double? = nil) {
384
+ guard scale > 0.001 else { return }
385
+ var c=context
386
+ c.translateBy(x:size.width/2,y:size.height/2)
387
+ if let exposed {
388
+ let y=top ? -size.height/2:exposed
389
+ let height=top ? size.height/2-exposed:size.height/2-exposed
390
+ c.clip(to:Path(CGRect(x:-size.width/2,y:y,width:size.width,height:max(0,height))))
391
+ }
392
+ c.scaleBy(x:1,y:scale)
393
+ c.clip(to:Path(CGRect(x:-size.width/2,y:top ? -size.height/2:0,width:size.width,height:size.height/2)))
394
+ c.fill(text == "!" ? mark:numeral,with:.color(color))
395
+ }
396
+ if progress < 0.5 {
397
+ let scale=max(0,cos(.pi*progress))
398
+ half("!",top:true,scale:1,exposed:size.height/2*scale)
399
+ half("\(number)",top:false,scale:1)
400
+ half("\(number)",top:true,scale:scale)
401
+ } else {
402
+ let scale=max(0,cos(.pi*(1-progress)))
403
+ half("!",top:true,scale:1)
404
+ half("\(number)",top:false,scale:1,exposed:size.height/2*scale)
405
+ half("!",top:false,scale:scale)
406
+ }
407
+ }.frame(width:19,height:19)
408
+ }
409
+ }
410
+
411
+ struct DecisionDissolveGlyph:View {
412
+ let number:Int
413
+ let progress:Double
414
+ let color:Color
415
+ var body:some View {
416
+ let numeral=CenteredStatusGlyph.path("\(number)"),mark=CenteredStatusGlyph.path("!")
417
+ let t=min(1,max(0,progress))
418
+ Canvas { context,size in
419
+ context.translateBy(x:size.width/2,y:size.height/2)
420
+ var old=context;old.opacity *= 1-t;old.fill(numeral,with:.color(color))
421
+ var new=context;new.opacity *= t;new.fill(mark,with:.color(color))
422
+ }.frame(width:19,height:19)
423
+ }
424
+ }
425
+
426
+ /// Disappear before the two 19-point disks (plus stroke allowance) can touch.
427
+ struct StatusSeparation {
428
+ static func opacity(weight:Double,distance:Double)->Double {
429
+ weight*IdleInterpolation.smooth((distance-20)/8)
430
+ }
431
+ }
432
+
433
+ struct WorkingDecisionGlyph:View {
434
+ let amount:Double
435
+ let number:Int
436
+ let angle:Double
437
+ var closing:Bool = false
438
+ var body:some View {
439
+ let m=DecisionMorph(amount:amount)
440
+ let closed=DecisionClosing(amount:amount)
441
+ let blue=NotchTokens.deepSeekBlue
442
+ ZStack {
443
+ if closing {
444
+ let ink=Color(red:0.302+(0.949-0.302)*closed.tint,green:0.420+0.580*closed.tint,blue:0.996+(0.078-0.996)*closed.tint)
445
+ Circle().fill(ink).scaleEffect(closed.fill)
446
+ Circle().trim(from:0,to:closed.trim).stroke(ink,style:StrokeStyle(lineWidth:1.5,lineCap:.round)).rotationEffect(.radians(angle))
447
+ DecisionDissolveGlyph(number:number,progress:closed.flip,color:Color(red:(0.302+(0.949-0.302)*closed.tint)*(1-closed.fill),green:(0.420+0.580*closed.tint)*(1-closed.fill),blue:(0.996+(0.078-0.996)*closed.tint)*(1-closed.fill)))
448
+ } else {
449
+ Circle().fill(NotchTokens.amber).opacity(m.fill)
450
+ Circle().stroke(NotchTokens.amber,lineWidth:1.5).opacity(m.fill)
451
+ Circle().trim(from:0,to:m.trim)
452
+ .stroke(blue,style:StrokeStyle(lineWidth:1.5,lineCap:.round))
453
+ .opacity(min(1,m.draw/0.03)).rotationEffect(.radians(angle))
454
+ DecisionDissolveGlyph(number:number,progress:m.flip,color:Color(red:0.302*(1-m.fill),green:0.420*(1-m.fill),blue:0.996*(1-m.fill)))
455
+ }
456
+ }.frame(width:19,height:19)
457
+ }
458
+ }
459
+
460
+ /// The robot's final point travels to the rim, becomes the pen, then resolves text.
461
+ struct StatusBirth {
462
+ let progress:Double
463
+ static let blueDrawDuration=(RobotDeparture.duration-0.666)*0.76
464
+ static let bluePenVelocity=2 * Double.pi*0.70/blueDrawDuration
465
+ var blueDraw:Double { min(1,max(0,(progress-0.24)/0.76)) }
466
+ var travel:Double { IdleInterpolation.smooth(progress/0.24) }
467
+ var draw:Double { IdleInterpolation.smooth((progress-0.24)/0.48) }
468
+ var fill:Double { IdleInterpolation.smooth((progress-0.72)/0.18) }
469
+ var text:Double { IdleInterpolation.smooth((progress-0.86)/0.14) }
470
+ }
471
+ struct StatusBirthGlyph:View {
472
+ let progress:Double
473
+ let decision:Bool
474
+ let number:Int
475
+ let angle:Double
476
+ var body:some View {
477
+ let m=StatusBirth(progress:progress)
478
+ let ink=decision ? NotchTokens.amber:NotchTokens.deepSeekBlue
479
+ let drawn=decision ? m.draw:m.blueDraw
480
+ let length=(decision ? 1.0:0.70)*drawn
481
+ ZStack {
482
+ if decision { Circle().fill(ink).scaleEffect(m.fill) }
483
+ Circle().trim(from:0,to:length)
484
+ .stroke(ink,style:StrokeStyle(lineWidth:1.5,lineCap:.round))
485
+ .rotationEffect(.radians(angle)).opacity(min(1,drawn/0.02))
486
+ Circle().fill(ink).frame(width:1.5,height:1.5)
487
+ .offset(x:cos(angle)*9.5*m.travel,y:sin(angle)*9.5*m.travel)
488
+ .opacity(1-min(1,drawn/0.02))
489
+ DecisionFlipGlyph(number:number,progress:decision ? 1:0,color:decision ? Color.black:ink)
490
+ .opacity(decision ? m.text:m.blueDraw)
491
+ }.frame(width:19,height:19)
492
+ }
493
+ }
494
+
495
+ struct StatusOrbitView: View {
496
+ @ObservedObject var model: BoardModel
497
+ @Environment(\.accessibilityReduceMotion) private var systemReduceMotion
498
+ var reduceMotionOverride: Bool? = nil
499
+ /// Fixed presentation time for deterministic native frame capture.
500
+ var renderDate: Date? = nil
501
+ var workReveal:Double = 1
502
+ private var reduceMotion: Bool { reduceMotionOverride ?? systemReduceMotion }
503
+
504
+ private var layout: OrbitLayout { model.orbitLayout }
505
+ private var showTop: Bool { layout.top > 0.0001 }
506
+ private var showBottom: Bool { layout.bottom > 0.0001 }
507
+ private var showMiddle: Bool { layout.middle > 0.0001 }
508
+ private var totalHeight: CGFloat { layout.height }
509
+ private var originY: CGFloat { layout.middleY }
510
+ private var bottomY: CGFloat { layout.bottomY }
511
+
512
+ private func clearance(_ weight:Double,_ y:Double,_ present:Bool)->Double {
513
+ guard !present else { return weight }
514
+ var neighbors:[Double]=[]
515
+ if model.busyCount > 0 { neighbors.append(layout.middleY) }
516
+ if model.completedUnreadCount > 0 { neighbors.append(10+28*layout.decision) }
517
+ if !model.failedRows.isEmpty { neighbors.append(layout.bottomY) }
518
+ if model.needsAction { neighbors.append(10) }
519
+ guard let distance=neighbors.map({abs($0-y)}).min() else { return weight }
520
+ return StatusSeparation.opacity(weight:weight,distance:distance)
521
+ }
522
+
523
+ var body: some View {
524
+ TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: reduceMotion || (model.busyCount == 0 && model.statusFlight == nil && !model.decisionSpinActive))) { context in
525
+ let now = renderDate ?? context.date
526
+ let flight = reduceMotion ? nil : model.statusFlight
527
+ let progress = flight.map { now.timeIntervalSince($0.startedAt) / StatusFlight.duration } ?? 1
528
+ let motion = flight.map { OrbitMotionFrame(progress: progress, failed: $0.failed, returns: $0.returnsToRunning, angle: $0.angle) }
529
+ let angle = model.decisionAngle(at:now)
530
+ let reply = reduceMotion ? nil:model.decisionReturn
531
+ let replyProgress = reply?.progress(at:now) ?? 1
532
+ let replyFrame = reply.map { DecisionReturnFrame(progress:replyProgress,angle:$0.angle) }
533
+ let localReply = reply.map { !$0.travelling } ?? false
534
+ let isolated = layout.top < 0.0001 && layout.bottom < 0.0001
535
+ let morphing = localReply || (reply == nil && flight == nil && isolated && abs(layout.middle+layout.decision-1) < 0.001)
536
+ ZStack(alignment: .topLeading) {
537
+ if morphing {
538
+ Group {
539
+ if workReveal < 1 {
540
+ StatusBirthGlyph(progress:workReveal,decision:model.needsAction,number:max(1,model.busyCount),angle:reduceMotion ? 0:angle)
541
+ } else {
542
+ WorkingDecisionGlyph(amount:localReply ? 1-replyProgress:layout.decision,number:max(1,max(model.busyCount,model.retainedBusyCount)),angle:reduceMotion ? 0:angle,closing:reply == nil && model.closingDecision)
543
+ }
544
+ }.position(x:15,y:10)
545
+ }
546
+ if layout.decision > 0.0001 && !morphing {
547
+ ZStack {
548
+ Circle().fill(NotchTokens.amber)
549
+ DecisionFlipGlyph(number:1,progress:1,color:.black)
550
+ }.frame(width:19,height:19)
551
+ .scaleEffect(workReveal)
552
+ .opacity(flight?.decision == true && flight?.destinationBefore == 0 ? (motion?.resultOpacity ?? 1):1)
553
+ .opacity(reply?.travelling == true ? (replyFrame?.fill ?? 1):clearance(layout.decision,10,model.needsAction))
554
+ .position(x:15,y:10)
555
+ }
556
+ if showTop {
557
+ let count = flight?.outcome == .success && motion?.arrived == false ? flight!.destinationBefore : max(model.completedUnreadCount,model.retainedSuccessCount)
558
+ statusDisk(count: count, color: NotchTokens.greenComplete, opacity: flight?.outcome == .success && flight?.destinationBefore == 0 ? (motion?.resultOpacity ?? 1) : 1)
559
+ .position(x: 15, y: 10+28*layout.decision)
560
+ .opacity(clearance(layout.top,10+28*layout.decision,model.completedUnreadCount > 0))
561
+ }
562
+ if showMiddle && !morphing {
563
+ let count = flight != nil && motion?.returned == false ? flight!.busyBefore : model.orbitBusyCount
564
+ ZStack {
565
+ Circle().fill(Color.black).frame(width: 15, height: 15)
566
+ .opacity(flight?.returnsToRunning == false ? (motion?.sourceOpacity ?? 1):1)
567
+ if flight == nil && model.busyCount > 0 && (reply == nil || reply!.busyBefore > 0) {
568
+ Circle().trim(from: 0, to: 0.70)
569
+ .stroke(NotchTokens.deepSeekBlue, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
570
+ .rotationEffect(.radians(reduceMotion ? 0 : angle))
571
+ .opacity(replyFrame?.baseRingOpacity ?? 1)
572
+ }
573
+ if let reply, let replyFrame {
574
+ DecisionFlipGlyph(number:reply.busyBefore,progress:0,color:NotchTokens.deepSeekBlue)
575
+ .opacity(reply.busyBefore > 0 ? 1-replyFrame.countMix:0)
576
+ DecisionFlipGlyph(number:model.busyCount,progress:0,color:NotchTokens.deepSeekBlue)
577
+ .opacity(replyFrame.countMix)
578
+ } else {
579
+ DecisionFlipGlyph(number:count,progress:0,color:NotchTokens.deepSeekBlue)
580
+ .opacity(count > 0 ? (motion?.sourceOpacity ?? 1) : 0)
581
+ }
582
+ }
583
+ .frame(width: 19, height: 19)
584
+ .scaleEffect(workReveal).opacity(layout.middle)
585
+ .position(x: 15, y: originY)
586
+ }
587
+ if showBottom {
588
+ let count = flight?.failed == true && motion?.arrived == false ? flight!.destinationBefore : max(model.failedRows.count,model.retainedFailureCount)
589
+ statusDisk(count: count, color: NotchTokens.redFail, opacity: flight?.failed == true && flight?.destinationBefore == 0 ? (motion?.resultOpacity ?? 1) : 1)
590
+ .position(x: 15, y: bottomY)
591
+ .opacity(clearance(layout.bottom,layout.bottomY,!model.failedRows.isEmpty))
592
+ }
593
+ if let reply, let frame=replyFrame, reply.travelling {
594
+ let yellow=StatusOutcome.decision.rgb
595
+ let ink=Color(red:0.302+(yellow.0-0.302)*frame.tint,
596
+ green:0.420+(yellow.1-0.420)*frame.tint,
597
+ blue:0.996+(yellow.2-0.996)*frame.tint)
598
+ DecisionReturnStroke(frame:frame,angle:reply.angle,gap:max(0,originY-10))
599
+ .stroke(ink,style:StrokeStyle(lineWidth:1.5,lineCap:.round))
600
+ .frame(width:30,height:20).position(x:15,y:originY)
601
+ .opacity(frame.strokeOpacity).allowsHitTesting(false).zIndex(-1)
602
+ }
603
+ if let flight, let motion {
604
+ let target = flight.outcome.rgb
605
+ let color = Color(red: 0.302 + (target.0 - 0.302) * motion.tint,
606
+ green: 0.420 + (target.1 - 0.420) * motion.tint,
607
+ blue: 0.996 + (target.2 - 0.996) * motion.tint)
608
+ // Colour belongs to the visible trail: its tail stays the result colour
609
+ // as the head returns towards the source rim, even behind the solid disk.
610
+ let resultColor = flight.outcome.color
611
+ let direction: CGFloat = flight.failed ? 1 : -1
612
+ let returnInk = LinearGradient(stops: [
613
+ .init(color: resultColor, location: 0),
614
+ .init(color: resultColor, location: 0.52),
615
+ .init(color: NotchTokens.deepSeekBlue, location: 1),
616
+ ], startPoint: UnitPoint(x: 0.5, y: 0.5 + direction * 28 / 20),
617
+ endPoint: UnitPoint(x: 0.5, y: 0.5 + direction * 9.5 / 20))
618
+ let ink = motion.returning ? AnyShapeStyle(returnInk) : AnyShapeStyle(color)
619
+ OrbitStroke(frame: motion, angle: flight.angle, gap:flight.decision ? max(0,originY-10):28*(flight.failed ? layout.middle : layout.top))
620
+ .stroke(ink, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
621
+ .frame(width: 30, height: 20)
622
+ .position(x: 15, y: originY)
623
+ .allowsHitTesting(false)
624
+ .zIndex(-1)
625
+ }
626
+ }
627
+ .frame(width: 30, height: totalHeight)
628
+ }
629
+
630
+ .accessibilityElement(children: .ignore)
631
+ .accessibilityLabel("运行中 \(model.busyCount),完成 \(model.completedUnreadCount),失败 \(model.failedRows.count),等待决定 \(model.needsAction ? 1:0)")
632
+ }
633
+
634
+ private func statusDisk(count: Int, color: Color, opacity: Double) -> some View {
635
+ ZStack {
636
+ Circle().fill(color)
637
+ DecisionFlipGlyph(number:count,progress:0,color:color == NotchTokens.greenComplete ? Color.black:Color.white)
638
+ }
639
+ .frame(width: 19, height: 19)
640
+ .opacity(count > 0 ? opacity : 0)
641
+ }
642
+ }