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,106 @@
1
+ import AppKit
2
+ import SwiftUI
3
+ @main enum MotionProbe {
4
+ @MainActor static func main() {
5
+ let app=NSApplication.shared
6
+ app.setActivationPolicy(.accessory)
7
+ let output = URL(fileURLWithPath: ProcessInfo.processInfo.environment["NOTCH_MOTION_OUTPUT"] ?? NSTemporaryDirectory()).appendingPathComponent("notch-motion-proof")
8
+ try! FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
9
+ var failures=0
10
+ func check(_ value:Bool,_ label:String) { if !value { failures += 1; print("FAIL \(label)") } }
11
+ func snapshot(_ busy:Int,_ done:Int,_ failed:Int) -> NotchSnapshot {
12
+ let rows=(0..<busy).map { NotchRow(id:"busy-\($0)",title:"running",child:false,busy:true,unread:false) }
13
+ + (0..<done).map { NotchRow(id:"done-\($0)",title:"done",child:false,busy:false,unread:true) }
14
+ + (0..<failed).map { NotchRow(id:"failed-\($0)",title:"failed",child:false,busy:false,unread:true,lastTurn:NotchLastTurn(at:0,kind:"error",failed:true)) }
15
+ return NotchSnapshot(ok:true,generatedAt:0,origin:"local-test",rows:rows)
16
+ }
17
+ for failed in [false,true] {
18
+ for angle in stride(from: 0.0, to: 2 * Double.pi, by: Double.pi / 4) {
19
+ let route = OrbitBrushRoute(failed: failed, angle: angle)
20
+ check(route.points.allSatisfy { abs($0.x) + 0.75 <= 15 }, "brush stays inside shell")
21
+ for returns in [false,true] {
22
+ var previous = OrbitMotionFrame(progress:0,failed:failed,returns:returns,angle:angle)
23
+ check(previous.tint==0 && previous.resultOpacity==0,"blue start")
24
+ for i in 1...160 {
25
+ let frame = OrbitMotionFrame(progress:Double(i)/160,failed:failed,returns:returns,angle:angle)
26
+ check(frame.distance >= previous.distance,"brush head never reverses")
27
+ check(abs(frame.distance-previous.distance)<5,"continuous brush travel")
28
+ check(abs(frame.tint-previous.tint)<0.15,"continuous colour")
29
+ let range = route.range(for:frame)
30
+ check(range.lowerBound >= 0 && range.upperBound <= route.total + 0.01,"trail range")
31
+ previous=frame
32
+ }
33
+ check(previous.resultOpacity==1,"solid result appears")
34
+ check(returns ? previous.tint==0 : previous.tint==1,"final colour")
35
+ check(returns ? previous.tailLength>35 : previous.tailLength<0.01,"returning arc or settled dot")
36
+ }
37
+ }
38
+ }
39
+ let model=BoardModel()
40
+ var snap=snapshot(2,0,0);model.applySnapshot(snap)
41
+ check(model.statusFlight==nil,"cold snapshot has no flight")
42
+ snap.rows[0].busy=false;snap.rows[0].unread=true;model.applySnapshot(snap)
43
+ check(model.statusFlight?.failed==false,"successful completion moves upward")
44
+ let first=model.statusFlight!.id
45
+ model.applySnapshot(snap);check(model.statusFlight?.id==first,"polling does not replay")
46
+ snap.rows[1].busy=false;snap.rows[1].unread=true;snap.rows[1].lastTurn=NotchLastTurn(at:0,kind:"error",failed:true);model.applySnapshot(snap)
47
+ check(model.statusFlight?.id==first,"second outcome does not interrupt flight")
48
+ model.finishStatusFlight(id:first)
49
+ check(model.statusFlight?.failed==true,"failure queued next")
50
+ model.finishStatusFlight(id:first)
51
+ check(model.statusFlight?.failed==true,"stale completion cannot clear new flight")
52
+ model.finishStatusFlight(id:model.statusFlight!.id)
53
+ check(model.statusFlight==nil && model.busyCount==0 && model.completedUnreadCount==1 && model.failedRows.count==1,"final counts")
54
+ let panel=NotchPanel(size:NSSize(width:60,height:110))
55
+ let host=NotchHostingView(rootView:StatusOrbitView(model:model, reduceMotionOverride:false).frame(width:60,height:110).background(Color.black))
56
+ host.sizingOptions=[];panel.contentView=host
57
+ panel.setFrame(NSRect(x:-2000,y:-2000,width:60,height:110),display:true)
58
+ panel.orderFrontRegardless()
59
+ Task { @MainActor in
60
+ for failed in [false,true] {
61
+ model.rows=snapshot(1,failed ? 0:1,failed ? 1:0).rows
62
+ for (i,phase) in [0.0,0.12,0.24,0.38,0.50,0.62,0.76,0.90,0.99].enumerated() {
63
+ model.statusFlight=StatusFlight(failed:failed,startedAt:Date().addingTimeInterval(-phase*StatusFlight.duration),busyBefore:2,destinationBefore:0)
64
+ try? await Task.sleep(for:.milliseconds(16))
65
+ host.layoutSubtreeIfNeeded()
66
+ if let rep=host.bitmapImageRepForCachingDisplay(in:host.bounds) {
67
+ host.cacheDisplay(in:host.bounds,to:rep)
68
+ try! rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"\(output.path)/\(failed ? "failure":"success")-\(i).png"))
69
+ }
70
+ }
71
+ }
72
+ for failed in [false,true] {
73
+ model.statusFlight=nil
74
+ var replay=snapshot(2,0,0)
75
+ model.applySnapshot(replay)
76
+ replay.rows[0].busy=false;replay.rows[0].unread=true
77
+ if failed { replay.rows[0].lastTurn=NotchLastTurn(at:0,kind:"error",failed:true) }
78
+ model.applySnapshot(replay)
79
+ for i in 0..<100 {
80
+ try? await Task.sleep(for:.milliseconds(16))
81
+ host.layoutSubtreeIfNeeded()
82
+ if let rep=host.bitmapImageRepForCachingDisplay(in:host.bounds) {
83
+ host.cacheDisplay(in:host.bounds,to:rep)
84
+ let path="\(output.path)/replay-\(failed ? "failure":"success")-\(String(format:"%03d",i)).png"
85
+ try! rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:path))
86
+ }
87
+ }
88
+ }
89
+ model.statusFlight=nil;model.updateOrbitLayout();model.tickOrbitLayout(at:Date().addingTimeInterval(2))
90
+ host.rootView = StatusOrbitView(model:model, reduceMotionOverride:true).frame(width:60,height:110).background(Color.black)
91
+ try? await Task.sleep(for:.milliseconds(60))
92
+ @MainActor func pixels() -> Data {
93
+ host.layoutSubtreeIfNeeded()
94
+ let rep=host.bitmapImageRepForCachingDisplay(in:host.bounds)!
95
+ host.cacheDisplay(in:host.bounds,to:rep)
96
+ return rep.representation(using:.png,properties:[:])!
97
+ }
98
+ let firstPixels=pixels()
99
+ try? await Task.sleep(for:.milliseconds(160))
100
+ check(firstPixels==pixels(),"reduced motion stays still")
101
+ try! firstPixels.write(to:URL(fileURLWithPath:"\(output.path)/reduced-motion.png"))
102
+ print("FAILURES=\(failures)");exit(failures==0 ? 0:1)
103
+ }
104
+ app.run()
105
+ }
106
+ }
@@ -0,0 +1,133 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ @main enum OutcomeProbe {
5
+ @MainActor static func main() {
6
+ NSApplication.shared.setActivationPolicy(.accessory)
7
+ var failures=0
8
+ func check(_ value:Bool,_ label:String) { if !value { failures += 1; print("FAIL \(label)") } }
9
+ for count in [1,2] { for kind in ["approval","success","failure"] {
10
+ let model=BoardModel();model.previewMode=true
11
+ var rows=(0..<count).map { NotchRow(id:"fixture-\($0)",title:"Local fixture",child:false,busy:true,unread:false) }
12
+ func snap()->NotchSnapshot {NotchSnapshot(ok:true,generatedAt:0,origin:"offline",rows:rows)}
13
+ model.applySnapshot(snap());model.tickOrbitLayout(at:Date().addingTimeInterval(2))
14
+ check(model.statusFlight == nil,"cold state has no result replay")
15
+ if kind == "approval" {rows[0].approval=NotchApproval(id:"local-approval",toolName:"offline")}
16
+ else {rows[0].busy=false;rows[0].unread=true;rows[0].lastTurn=NotchLastTurn(at:1,kind:kind,failed:kind == "failure")}
17
+ model.applySnapshot(snap())
18
+ guard let flight=model.statusFlight else {check(false,"\(count) task \(kind): real snapshot must start travelling brush");continue}
19
+ check(flight.outcome == (kind == "approval" ? .decision:(kind == "failure" ? .failure:.success)),"\(kind): correct destination colour")
20
+ check(flight.returnsToRunning == (count > 1),"\(count) task \(kind): return only if work remains")
21
+ check(flight.busyBefore==count,"source count retained until departure")
22
+ let id=flight.id;model.applySnapshot(snap());check(model.statusFlight?.id==id,"polling does not restart brush")
23
+ let start=OrbitMotionFrame(progress:0,failed:flight.failed,returns:flight.returnsToRunning,angle:flight.angle)
24
+ check(start.tint==0 && start.resultOpacity==0 && start.tailLength>20,"\(kind): starts as visible blue arc")
25
+ let draw=OrbitMotionFrame(progress:0.72,failed:flight.failed,returns:flight.returnsToRunning,angle:flight.angle)
26
+ check(draw.tailLength>0,"\(kind): drawing phase contains ink")
27
+ let end=OrbitMotionFrame(progress:1,failed:flight.failed,returns:flight.returnsToRunning,angle:flight.angle)
28
+ check(end.resultOpacity==1,"\(kind): result is solid at end")
29
+ check(count > 1 ? end.tint==0 : end.tailLength==0,"\(kind): returning blue or settled result")
30
+ model.finishStatusFlight(id:id)
31
+ check(model.statusFlight==nil,"\(kind): flight ends")
32
+ check(model.busyCount==count-1,"\(kind): exact remaining work")
33
+ check(kind == "approval" ? model.needsAction : (kind == "failure" ? model.failedRows.count==1 : model.completedUnreadCount==1),"\(kind): final result retained")
34
+ print("CHECKED \(count) task \(kind)")
35
+ }}
36
+ func busyRows(_ count:Int)->[NotchRow] {(0..<count).map {NotchRow(id:"action-\($0)",title:"Local",child:false,busy:true,unread:false)}}
37
+ func snapshot(_ rows:[NotchRow])->NotchSnapshot {NotchSnapshot(ok:true,generatedAt:0,origin:"offline",rows:rows)}
38
+ for cancel in [false,true] {
39
+ let m=BoardModel();var rows=busyRows(1)
40
+ m.applySnapshot(snapshot(rows));m.tickOrbitLayout(at:Date().addingTimeInterval(2))
41
+ rows[0].approval=NotchApproval(id:"approval",toolName:"offline")
42
+ m.applySnapshot(snapshot(rows));let id=m.statusFlight!.id
43
+ check(!m.expanded && !m.showingExpanded,"automatic panel must preserve travelling stroke")
44
+ if cancel {rows[0].approval=nil;m.applySnapshot(snapshot(rows))}
45
+ m.finishStatusFlight(id:id)
46
+ check(m.expanded == !cancel,"only current approval may open after ink arrives")
47
+ check(m.needsAction == !cancel,"cancelled question cannot reappear")
48
+ }
49
+ // A result queued before an approval must retain its own identity and order.
50
+ let queued=BoardModel();queued.previewMode=true;var rows=busyRows(2)
51
+ queued.applySnapshot(snapshot(rows));queued.tickOrbitLayout(at:Date().addingTimeInterval(2))
52
+ rows[0].busy=false;rows[0].unread=true
53
+ queued.applySnapshot(snapshot(rows));let first=queued.statusFlight!.id
54
+ rows[1].approval=NotchApproval(id:"queued-approval",toolName:"offline")
55
+ queued.applySnapshot(snapshot(rows));check(queued.statusFlight?.id==first,"decision does not replace moving success stroke")
56
+ queued.finishStatusFlight(id:first);let second=queued.statusFlight!.id
57
+ check(queued.statusFlight?.decision == true,"decision uses next brush slot")
58
+ queued.finishStatusFlight(id:first);check(queued.statusFlight?.id==second,"old callback cannot clear current stroke")
59
+ queued.finishStatusFlight(id:second)
60
+ check(queued.completedUnreadCount==1 && queued.needsAction,"green result survives yellow arrival")
61
+ // A decision reply resumes the same task; never increment the visible blue
62
+ // count before the yellow lamp has returned to it.
63
+ for count in [1,2] {
64
+ let m=BoardModel();m.previewMode=true;var rows=busyRows(count)
65
+ m.applySnapshot(snapshot(rows));m.tickOrbitLayout(at:Date().addingTimeInterval(2))
66
+ rows[0].approval=NotchApproval(id:"roundtrip",toolName:"offline")
67
+ m.applySnapshot(snapshot(rows));m.finishStatusFlight(id:m.statusFlight!.id)
68
+ m.tickOrbitLayout(at:Date().addingTimeInterval(2))
69
+ check(m.busyCount==count-1 && m.needsAction,"awaiting preserves remaining task count")
70
+ rows[0].approval=nil;m.applySnapshot(snapshot(rows))
71
+ if count==2 {check(m.retainedBusyCount==1,"yellow reply must not show blue 2 before merging")}
72
+ guard let reply=m.decisionReturn else {check(false,"reply must own a continuous return timeline");continue}
73
+ check(reply.travelling == (count==2),"reply draws locally or travels to remaining blue")
74
+ m.applySnapshot(snapshot(rows));check(m.decisionReturn?.id==reply.id,"polling does not restart reply")
75
+ m.tickOrbitLayout(at:reply.startedAt.addingTimeInterval(reply.duration*0.1))
76
+ if count==2 {check(m.orbitLayout.decision==1 && m.retainedBusyCount==1,"yellow clears before shell closes and count merges")}
77
+ let finalAngle=m.decisionAngle(at:reply.startedAt.addingTimeInterval(reply.duration))
78
+ m.finishDecisionReturn(id:reply.id)
79
+ check(m.decisionReturn==nil,"reply releases presentation state")
80
+ check(abs(m.decisionAngle(at:reply.startedAt.addingTimeInterval(reply.duration))-finalAngle)<0.00001,"spin phase survives reply handoff")
81
+ m.tickOrbitLayout(at:Date().addingTimeInterval(2))
82
+ check(m.busyCount==count && !m.needsAction,"reply returns original task count")
83
+ check(m.orbitLayout==OrbitLayout(middle:1),"reply leaves one blue lamp without yellow slot")
84
+ }
85
+ for count in [1,2] {
86
+ let m=BoardModel();m.previewMode=true;var rows=busyRows(count)
87
+ m.applySnapshot(snapshot(rows));m.tickOrbitLayout(at:Date().addingTimeInterval(2))
88
+ rows[0].approval=NotchApproval(id:"fast",toolName:"offline")
89
+ m.applySnapshot(snapshot(rows));let outward=m.statusFlight!.id
90
+ rows[0].approval=nil;m.applySnapshot(snapshot(rows))
91
+ check(m.statusFlight?.id==outward && m.decisionReturn==nil,"fast reply finishes its outgoing ink first")
92
+ check(m.orbitBusyCount==count-1,"fast reply cannot prematurely change outgoing blue count")
93
+ m.finishStatusFlight(id:outward)
94
+ guard let reply=m.decisionReturn else {check(false,"fast reply starts return after arrival");continue}
95
+ m.finishStatusFlight(id:outward);check(m.decisionReturn?.id==reply.id,"old outbound callback cannot erase reply")
96
+ m.finishDecisionReturn(id:reply.id)
97
+ check(m.orbitLayout==OrbitLayout(middle:1) && m.busyCount==count,"fast reply ends on original blue count")
98
+ }
99
+ // A folded panel must reveal the beginning of the return, not its last frame.
100
+ let panel=BoardModel();var panelRows=busyRows(2)
101
+ panel.applySnapshot(snapshot(panelRows));panel.tickOrbitLayout(at:Date().addingTimeInterval(2))
102
+ panelRows[0].approval=NotchApproval(id:"panel",toolName:"offline")
103
+ panel.applySnapshot(snapshot(panelRows));panel.finishStatusFlight(id:panel.statusFlight!.id)
104
+ check(panel.expanded && panel.showingExpanded,"approval expanded after arrival")
105
+ panelRows[0].approval=nil;let replyTime=Date();panel.applySnapshot(snapshot(panelRows))
106
+ check(!panel.expanded && panel.showingExpanded,"reply folds panel")
107
+ check((panel.decisionReturn?.startedAt.timeIntervalSince(replyTime) ?? 0)>=NotchGeometryAnimation.duration-0.02,"return waits for folded lamp surface")
108
+ // Cancelling a question without resuming that row must not increment unrelated work.
109
+ let cancel=BoardModel();cancel.previewMode=true;var cancelRows=busyRows(2)
110
+ cancelRows[0].approval=NotchApproval(id:"cancel",toolName:"offline")
111
+ cancel.applySnapshot(snapshot(cancelRows));cancel.tickOrbitLayout(at:Date().addingTimeInterval(2))
112
+ cancelRows[0].approval=nil;cancelRows[0].busy=false;cancel.applySnapshot(snapshot(cancelRows))
113
+ check(cancel.decisionReturn==nil && cancel.busyCount==1,"cancel is not a task resume")
114
+ for angle in stride(from:0.0,to:2 * Double.pi,by:Double.pi/4) {
115
+ let route=OrbitBrushRoute(failed:false,angle:angle)
116
+ var previous=DecisionReturnFrame(progress:0,angle:angle)
117
+ check(previous.fill==1 && previous.countMix==0,"return starts as existing yellow and existing count")
118
+ for i in 1...240 {
119
+ let frame=DecisionReturnFrame(progress:Double(i)/240,angle:angle)
120
+ check(frame.distance>=previous.distance && frame.distance-previous.distance<3,"return brush advances continuously")
121
+ check(frame.tint<=previous.tint+0.00001 && abs(frame.tint-previous.tint)<0.08,"return colour blends into blue")
122
+ check(frame.collapse==0 || frame.fill==0,"yellow solid clears before lamps converge")
123
+ check(frame.countMix==0 || frame.distance>=route.returned,"count changes only after ink enters blue ring")
124
+ previous=frame
125
+ }
126
+ check(previous.fill==0 && previous.tint==0 && previous.countMix==1,"return settles on blue new count")
127
+ let near=DecisionReturnFrame(progress:0.9999,angle:angle)
128
+ let velocity=(previous.distance-near.distance)/(StatusFlight.duration*0.0001)
129
+ check(abs(velocity-9.5*DecisionSpin.runningVelocity)<0.05,"pen speed matches final rotating ring")
130
+ }
131
+ print("FAILURES=\(failures)");exit(failures==0 ? 0:1)
132
+ }
133
+ }
@@ -0,0 +1,91 @@
1
+ import AppKit
2
+ import SwiftUI
3
+ import Combine
4
+
5
+ @main enum ScrollbarProbe {
6
+ @MainActor static func main() {
7
+ let app = NSApplication.shared
8
+ app.setActivationPolicy(.accessory)
9
+ let model = BoardModel()
10
+ model.previewMode = true
11
+ model.maximumExpandedHeight = 800
12
+ let panel = NotchPanel(size:NSSize(width:38,height:44))
13
+ let host = NotchHostingView(rootView:RootView(model:model,panelSize:CGSize(width:480,height:800),restSize:CGSize(width:38,height:44)))
14
+ host.sizingOptions = []
15
+ panel.embedHost(host)
16
+ panel.setFrame(NSRect(x:-2000,y:-2000,width:38,height:44),display:true)
17
+ panel.orderFrontRegardless()
18
+ var subscriptions = Set<AnyCancellable>()
19
+ Publishers.CombineLatest(model.$currentIslandWidth,model.$currentIslandHeight)
20
+ .receive(on:DispatchQueue.main).sink {w,h in panel.resizeAnchored(to:NSSize(width:w,height:h))}
21
+ .store(in:&subscriptions)
22
+ @MainActor func scrollViews(_ view:NSView)->[NSScrollView] {
23
+ ((view as? NSScrollView).map {[$0]} ?? []) + view.subviews.flatMap(scrollViews)
24
+ }
25
+ Task { @MainActor in
26
+ let rows=(0..<5).map {NotchRow(id:"fixture-\($0)",title:"Running task \($0+1)",child:false,busy:true,unread:false)}
27
+ model.applySnapshot(NotchSnapshot(ok:true,generatedAt:0,origin:"offline",rows:rows))
28
+ try? await Task.sleep(for:.milliseconds(800))
29
+ var failures=0, samples=0, flashFrames=0
30
+ @MainActor func check(_ condition:Bool,_ message:String) {
31
+ if !condition {failures += 1;print("FAIL \(message)")}
32
+ }
33
+ @MainActor func inspectShortExpansion(_ scenario:String) async {
34
+ model.expanded=true
35
+ for frame in 0..<45 {
36
+ try? await Task.sleep(for:.milliseconds(16))
37
+ host.layoutSubtreeIfNeeded()
38
+ let scrolls=scrollViews(host)
39
+ for (i,scroll) in scrolls.enumerated() {
40
+ guard let bar=scroll.verticalScroller else {continue}
41
+ let visible=scroll.hasVerticalScroller && !bar.isHiddenOrHasHiddenAncestor && bar.alphaValue>0 && bar.bounds.height>0 && bar.knobProportion<0.999
42
+ if visible {failures += 1;flashFrames += 1}
43
+ if frame<4 || visible || frame==44 {
44
+ print("SCENARIO=\(scenario) FRAME=\(frame) SCROLL=\(i) PANEL=\(Int(panel.frame.height)) DOCUMENT=\(Int(scroll.documentView?.frame.height ?? 0)) VIEWPORT=\(Int(scroll.contentView.bounds.height)) HAS_BAR=\(scroll.hasVerticalScroller) HIDDEN=\(bar.isHiddenOrHasHiddenAncestor) ALPHA=\(bar.alphaValue) KNOB=\(bar.knobProportion) VISIBLE=\(visible)")
45
+ }
46
+ }
47
+ samples += scrolls.count
48
+ }
49
+ }
50
+ await inspectShortExpansion("five-tasks")
51
+ model.expanded=false
52
+ try? await Task.sleep(for:.milliseconds(600))
53
+ await inspectShortExpansion("reopen")
54
+ model.expanded=false
55
+ try? await Task.sleep(for:.milliseconds(70))
56
+ await inspectShortExpansion("reverse-collapse")
57
+ check(samples>0,"no native scroll views reached")
58
+
59
+ // A real overflow must keep both its indicator and a movable viewport.
60
+ model.maximumExpandedHeight=400
61
+ let question=NotchQuestion(id:"long",question:"Long question",options:[NotchOption(label:"Option",description:String(repeating:"Long offline content. ",count:150))])
62
+ let row=NotchRow(id:"question",title:"Local fixture",child:false,busy:true,unread:false,ask:NotchAsk(id:"ask",questions:[question]))
63
+ model.applySnapshot(NotchSnapshot(ok:true,generatedAt:0,origin:"offline",rows:[row]))
64
+ try? await Task.sleep(for:.milliseconds(850))
65
+ host.layoutSubtreeIfNeeded()
66
+ check(abs(panel.frame.height-400)<2,"long content reaches the screen cap")
67
+ if let scroll=scrollViews(host).first,let document=scroll.documentView {
68
+ let overflow=document.frame.height-scroll.contentView.bounds.height
69
+ check(overflow>20,"long fixture actually overflows")
70
+ check(scroll.hasVerticalScroller,"real overflow retains its indicator")
71
+ let before=scroll.contentView.bounds.origin.y
72
+ scroll.contentView.scroll(to:NSPoint(x:0,y:overflow))
73
+ scroll.reflectScrolledClipView(scroll.contentView)
74
+ check(scroll.contentView.bounds.origin.y>before+20,"long content remains scrollable")
75
+ print("LONG_OVERFLOW=\(Int(overflow)) SCROLLED=\(Int(scroll.contentView.bounds.origin.y)) INDICATOR=\(scroll.hasVerticalScroller)")
76
+ } else {check(false,"long fixture has a native scroll view")}
77
+ model.maximumExpandedHeight=800
78
+ model.applySnapshot(NotchSnapshot(ok:true,generatedAt:0,origin:"offline",rows:rows))
79
+ try? await Task.sleep(for:.milliseconds(850))
80
+ check(panel.frame.height<400,"short content shrinks after long content")
81
+ model.expanded=false
82
+ try? await Task.sleep(for:.milliseconds(600))
83
+ await inspectShortExpansion("after-long-content")
84
+ print("SCROLLBAR_FLASH_FRAMES=\(flashFrames)")
85
+ print("FAILURES=\(failures)")
86
+ exit(failures==0 ? 0:1)
87
+ }
88
+ app.run()
89
+ withExtendedLifetime(subscriptions) {}
90
+ }
91
+ }
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
4
+ work="$base/.build/expanded-height-probe"
5
+ mkdir -p "$work/Sources"
6
+ cp -R "$base/Sources/Resources" "$work/Sources/"
7
+ cp "$base/Package.swift" "$work/Package.swift"
8
+ for file in IdleRobot.swift NotchMarkdown.swift RootView.swift Panel.swift Client.swift StatusOrbit.swift; do
9
+ cp "$base/Sources/$file" "$work/Sources/$file"
10
+ done
11
+ cp "$base/Tests/ExpandedHeightProbe.swift" "$work/Sources/main.swift"
12
+ swift build --package-path "$work" -c release
13
+ "$work/.build/release/dsh-notch"
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
4
+ work=$(mktemp -d "${TMPDIR:-/tmp}/notch-geometry.XXXXXX")
5
+ trap 'rm -rf "$work"' EXIT
6
+ mkdir "$work/Sources"
7
+ cp -R "$base/Sources/Resources" "$work/Sources/Resources"
8
+ cp "$base/Sources/IdleRobot.swift" "$work/Sources/"
9
+ cp "$base/Sources/NotchMarkdown.swift" "$work/Sources/"
10
+ cp "$base/Package.swift" "$work/Package.swift"
11
+ cp "$base/Sources/RootView.swift" "$base/Sources/Panel.swift" "$base/Sources/Client.swift" "$base/Sources/StatusOrbit.swift" "$work/Sources/"
12
+ cp "$base/Tests/GeometryProbe.swift" "$work/Sources/main.swift"
13
+ swift build --package-path "$work" -c release
14
+ "$work/.build/release/dsh-notch"
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
4
+ work=$(mktemp -d "${TMPDIR:-/tmp}/notch-idle.XXXXXX")
5
+ trap 'rm -rf "$work"' EXIT
6
+ mkdir "$work/Sources"
7
+ cp -R "$base/Sources/Resources" "$work/Sources/Resources"
8
+ cp "$base/Sources/IdleRobot.swift" "$work/Sources/"
9
+ cp "$base/Sources/NotchMarkdown.swift" "$work/Sources/"
10
+ cp "$base/Package.swift" "$work/Package.swift"
11
+ cp "$base/Sources/RootView.swift" "$base/Sources/Panel.swift" "$base/Sources/Client.swift" "$base/Sources/StatusOrbit.swift" "$work/Sources/"
12
+ cp "$base/Tests/IdleProbe.swift" "$work/Sources/main.swift"
13
+ swift build --package-path "$work" -c release
14
+ "$work/.build/release/dsh-notch"
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
4
+ work=$(mktemp -d "${TMPDIR:-/tmp}/notch-motion.XXXXXX")
5
+ trap 'rm -rf "$work"' EXIT
6
+ mkdir "$work/Sources"
7
+ cp -R "$base/Sources/Resources" "$work/Sources/Resources"
8
+ cp "$base/Sources/IdleRobot.swift" "$work/Sources/"
9
+ cp "$base/Sources/NotchMarkdown.swift" "$work/Sources/"
10
+ cp "$base/Package.swift" "$work/Package.swift"
11
+ cp "$base/Sources/RootView.swift" "$base/Sources/Panel.swift" "$base/Sources/Client.swift" "$base/Sources/StatusOrbit.swift" "$work/Sources/"
12
+ cp "$base/Tests/MotionProbe.swift" "$work/Sources/main.swift"
13
+ swift build --package-path "$work" -c release
14
+ "$work/.build/release/dsh-notch"
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
4
+ work=$(mktemp -d "${TMPDIR:-/tmp}/notch-outcome.XXXXXX")
5
+ trap 'rm -rf "$work"' EXIT
6
+ mkdir "$work/Sources"
7
+ cp -R "$base/Sources/Resources" "$work/Sources/Resources"
8
+ cp "$base/Sources/IdleRobot.swift" "$work/Sources/"
9
+ cp "$base/Sources/NotchMarkdown.swift" "$work/Sources/"
10
+ cp "$base/Package.swift" "$work/Package.swift"
11
+ cp "$base/Sources/RootView.swift" "$base/Sources/Panel.swift" "$base/Sources/Client.swift" "$base/Sources/StatusOrbit.swift" "$work/Sources/"
12
+ cp "$base/Tests/OutcomeProbe.swift" "$work/Sources/main.swift"
13
+ swift build --package-path "$work" -c release
14
+ "$work/.build/release/dsh-notch"
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
4
+ work="$base/.build/scrollbar-probe"
5
+ mkdir -p "$work/Sources"
6
+ cp -R "$base/Sources/Resources" "$work/Sources/"
7
+ cp "$base/Package.swift" "$work/Package.swift"
8
+ for file in IdleRobot.swift NotchMarkdown.swift RootView.swift Panel.swift Client.swift StatusOrbit.swift; do
9
+ cp "$base/Sources/$file" "$work/Sources/$file"
10
+ done
11
+ cp "$base/Tests/ScrollbarProbe.swift" "$work/Sources/main.swift"
12
+ swift build --package-path "$work" -c release
13
+ "$work/.build/release/dsh-notch"
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "dsh-notch",
3
+ "version": "0.3.1",
4
+ "type": "module",
5
+ "main": "src/dsh-notch.ts",
6
+ "description": "Native macOS notch for live DSH sessions, approvals, and model questions.",
7
+ "scripts": {
8
+ "test": "node --import tsx --test tests/*.test.mjs",
9
+ "test:motion": "sh macos/Tests/motion.sh",
10
+ "test:geometry": "sh macos/Tests/geometry.sh",
11
+ "build:macos": "swift build --package-path macos -c release",
12
+ "test:outcome": "sh macos/Tests/outcome.sh",
13
+ "test:idle": "sh macos/Tests/idle.sh",
14
+ "build:demo": "sh tools/recording/build.sh",
15
+ "test:scrollbar": "sh macos/Tests/scrollbar.sh",
16
+ "test:expanded-height": "sh macos/Tests/expanded-height.sh"
17
+ },
18
+ "devDependencies": {
19
+ "tsx": "4.22.4"
20
+ },
21
+ "peerDependencies": {
22
+ "@deepseek-ai/dsh": ">=0.1.7-rc.1 <0.1.8",
23
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.1.8",
24
+ "@deepseek-ai/dsh-session": ">=0.1.7-rc.1 <0.1.8",
25
+ "@deepseek-ai/dsh-user-approval": ">=0.1.7-rc.1 <0.1.8",
26
+ "@deepseek-ai/dsh-user-questions": ">=0.1.7-rc.1 <0.1.8"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@deepseek-ai/dsh": {
30
+ "optional": true
31
+ },
32
+ "@deepseek-ai/dsh-host-webserver": {
33
+ "optional": true
34
+ },
35
+ "@deepseek-ai/dsh-session": {
36
+ "optional": true
37
+ },
38
+ "@deepseek-ai/dsh-user-approval": {
39
+ "optional": true
40
+ },
41
+ "@deepseek-ai/dsh-user-questions": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "license": "MIT"
46
+ }