@hasna/recordings 0.2.10 → 0.2.13

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 (167) hide show
  1. package/README.md +350 -35
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1922 -77
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1990 -29
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -0,0 +1,102 @@
1
+ @preconcurrency import Cocoa
2
+ import SwiftUI
3
+ import RecordingsLib
4
+
5
+ struct MenuBarStatusLabel: View {
6
+ @ObservedObject var store: RecordingsStore
7
+
8
+ var body: some View {
9
+ Image(systemName: presentation.iconName)
10
+ .accessibilityLabel(presentation.accessibilityLabel)
11
+ }
12
+
13
+ private var presentation: MenuBarPresentation {
14
+ MenuBarPresentation(
15
+ isRecording: store.engine.isRecording,
16
+ canStartRecording: store.engine.canStartRecording,
17
+ statusMessage: store.engine.statusMessage
18
+ )
19
+ }
20
+ }
21
+
22
+ struct MenuBarStatusView: View {
23
+ @ObservedObject var store: RecordingsStore
24
+ let openRecordings: () -> Void
25
+
26
+ var body: some View {
27
+ VStack(alignment: .leading, spacing: 12) {
28
+ HStack(spacing: 8) {
29
+ Image(systemName: presentation.iconName)
30
+ .foregroundStyle(statusColor)
31
+ VStack(alignment: .leading, spacing: 2) {
32
+ Text("Recordings")
33
+ .font(.headline)
34
+ Text(presentation.statusText)
35
+ .font(.caption)
36
+ .foregroundStyle(.secondary)
37
+ }
38
+ Spacer()
39
+ }
40
+
41
+ Button(action: toggleRecording) {
42
+ Label(recordButtonTitle, systemImage: recordButtonIcon)
43
+ .frame(maxWidth: .infinity)
44
+ }
45
+ .buttonStyle(.borderedProminent)
46
+ .tint(store.engine.isRecording ? .red : .accentColor)
47
+ .disabled(!presentation.primaryActionEnabled)
48
+
49
+ Divider()
50
+
51
+ Button(action: openRecordings) {
52
+ Label("Open Recordings", systemImage: "macwindow")
53
+ .frame(maxWidth: .infinity, alignment: .leading)
54
+ }
55
+ .buttonStyle(.plain)
56
+
57
+ SettingsLink {
58
+ Label("Settings", systemImage: "gearshape")
59
+ .frame(maxWidth: .infinity, alignment: .leading)
60
+ }
61
+ .buttonStyle(.plain)
62
+
63
+ Button {
64
+ NSApplication.shared.terminate(nil)
65
+ } label: {
66
+ Label("Quit Recordings", systemImage: "power")
67
+ .frame(maxWidth: .infinity, alignment: .leading)
68
+ }
69
+ .buttonStyle(.plain)
70
+ }
71
+ .padding(14)
72
+ .frame(width: 260)
73
+ }
74
+
75
+ private var statusColor: Color {
76
+ store.engine.isRecording ? .red : .accentColor
77
+ }
78
+
79
+ private var presentation: MenuBarPresentation {
80
+ MenuBarPresentation(
81
+ isRecording: store.engine.isRecording,
82
+ canStartRecording: store.engine.canStartRecording,
83
+ statusMessage: store.engine.statusMessage
84
+ )
85
+ }
86
+
87
+ private var recordButtonTitle: String {
88
+ store.engine.isRecording ? "Stop and Transcribe" : "Start Recording"
89
+ }
90
+
91
+ private var recordButtonIcon: String {
92
+ store.engine.isRecording ? "stop.fill" : "mic.fill"
93
+ }
94
+
95
+ private func toggleRecording() {
96
+ if store.engine.isRecording {
97
+ store.engine.stopAndTranscribe()
98
+ } else {
99
+ store.engine.startRecording()
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,468 @@
1
+ import SwiftUI
2
+ import RecordingsLib
3
+ @preconcurrency import KeyboardShortcuts
4
+
5
+ /// The Recordings workspace — the app's first screen. One Liquid-Glass hero drives the whole
6
+ /// flow: speak, and the app types it, answers a question, or edits the selection. There is no
7
+ /// mode selector; the phase (idle → listening → finalizing → processing → ready/error) is the
8
+ /// only state the page renders.
9
+ struct RecordWorkspaceView: View {
10
+ @ObservedObject var store: RecordingsStore
11
+ @Environment(\.colorScheme) private var colorScheme
12
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
13
+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
14
+ @Namespace private var glass
15
+
16
+ private var engine: RecordingEngine { store.engine }
17
+ private var phase: RecordingFlowPhase { engine.flowPhase }
18
+
19
+ var body: some View {
20
+ ScrollView {
21
+ VStack(spacing: 22) {
22
+ GlassEffectContainer(spacing: 12) {
23
+ hero
24
+ .frame(maxWidth: 560)
25
+ }
26
+ .padding(.top, 28)
27
+
28
+ if let reply = engine.conversationReply {
29
+ replyCard(reply)
30
+ }
31
+
32
+ activeProjectRow
33
+
34
+ if let synchronizationError = store.projectStore.synchronizationError {
35
+ projectSynchronizationWarning(synchronizationError)
36
+ }
37
+
38
+ if !engine.recentTranscriptions.isEmpty {
39
+ recentStrip
40
+ }
41
+
42
+ Spacer(minLength: 12)
43
+ }
44
+ .frame(maxWidth: .infinity)
45
+ .padding(.horizontal, 28)
46
+ .padding(.bottom, 24)
47
+ }
48
+ .animation(reduceMotion ? nil : .smooth(duration: 0.28), value: phase)
49
+ .onChange(of: engine.isTranscribing) { wasTranscribing, isTranscribing in
50
+ // Refresh promptly when the foreground path finishes. RecordingsStore also
51
+ // observes confirmed persistence so async saves still refresh after this view
52
+ // has been unmounted, without treating failed saves as completed recordings.
53
+ if wasTranscribing && !isTranscribing { store.loadLibrary() }
54
+ }
55
+ }
56
+
57
+ // MARK: - Hero (single morphing glass surface)
58
+
59
+ @ViewBuilder
60
+ private var hero: some View {
61
+ // The hidden template is the tallest phase layout (listening, with the full
62
+ // six-line live-text reservation). Every phase renders inside that fixed envelope,
63
+ // so streaming live text and phase transitions can never shift the content below
64
+ // the hero. The template scales with Dynamic Type because it uses the real fonts.
65
+ let content = ZStack {
66
+ heroSizingTemplate
67
+ .hidden()
68
+ .accessibilityHidden(true)
69
+ VStack(spacing: 14) { heroContent }
70
+ }
71
+ .frame(maxWidth: .infinity, minHeight: 208)
72
+ .padding(26)
73
+ switch ChromeSurface.forReducedTransparency(reduceTransparency) {
74
+ case .opaque:
75
+ // Reduce Transparency: a fully opaque system background — never a material,
76
+ // which still composites the desktop through the window.
77
+ content
78
+ .background(
79
+ Color(NSColor.windowBackgroundColor),
80
+ in: .rect(cornerRadius: Theme.cornerLarge)
81
+ )
82
+ .overlay(
83
+ RoundedRectangle(cornerRadius: Theme.cornerLarge, style: .continuous)
84
+ .strokeBorder(.separator, lineWidth: 1)
85
+ )
86
+ case .liquidGlass:
87
+ content
88
+ .glassEffect(heroGlass, in: .rect(cornerRadius: Theme.cornerLarge))
89
+ .glassEffectID("record-hero", in: glass)
90
+ }
91
+ }
92
+
93
+ private var heroGlass: Glass {
94
+ switch phase {
95
+ case .idle: return .regular.interactive()
96
+ case .listening: return .regular.tint(Theme.recordRed.opacity(0.18))
97
+ case .finalizing, .processing: return .regular.tint(.orange.opacity(0.12))
98
+ case .ready: return .regular.tint(.green.opacity(0.10))
99
+ case .failed: return .regular.tint(.orange.opacity(0.16))
100
+ }
101
+ }
102
+
103
+ @ViewBuilder
104
+ private var heroContent: some View {
105
+ switch phase {
106
+ case .idle:
107
+ idleContent
108
+ case .listening:
109
+ listeningContent
110
+ case .finalizing:
111
+ busyContent(label: "Finishing up…", detail: "Capturing the last words", showsLiveText: true)
112
+ case .processing(let label):
113
+ busyContent(
114
+ label: label,
115
+ detail: nil,
116
+ showsLiveText: false,
117
+ showsCancel: engine.canCancelIntentDelivery
118
+ )
119
+ case .ready(let summary):
120
+ readyContent(summary: summary)
121
+ case .failed(let message):
122
+ failedContent(message: message)
123
+ }
124
+ }
125
+
126
+ /// Invisible layout twin of the listening phase; see `hero`. Uses the same controls and
127
+ /// fonts so its height tracks the real layout at every Dynamic Type size.
128
+ private var heroSizingTemplate: some View {
129
+ VStack(spacing: 14) {
130
+ HStack(spacing: 12) {
131
+ Image(systemName: "waveform").font(.largeTitle)
132
+ Text("0:00:00")
133
+ .font(.system(size: 34, weight: .semibold, design: .rounded).monospacedDigit())
134
+ }
135
+ liveTextReservation
136
+ HStack(spacing: 12) {
137
+ Button {} label: { Label("Discard", systemImage: "xmark") }
138
+ .buttonStyle(.glass)
139
+ Button {} label: { Label("Stop & Transcribe", systemImage: "stop.fill") }
140
+ .buttonStyle(.glassProminent)
141
+ }
142
+ .controlSize(.large)
143
+ .disabled(true)
144
+ }
145
+ }
146
+
147
+ /// Reserves exactly six lines at the live-text font so the region cannot grow as words
148
+ /// stream in.
149
+ private var liveTextReservation: some View {
150
+ Text(String(repeating: "M\n", count: 5) + "M")
151
+ .font(.system(.title3, design: .rounded))
152
+ .lineLimit(6)
153
+ .frame(maxWidth: .infinity, alignment: .leading)
154
+ }
155
+
156
+ // MARK: Idle
157
+
158
+ private var idleContent: some View {
159
+ let presentation = RecordingStartControlPresentation(
160
+ kind: .record,
161
+ canStartRecording: engine.canStartRecording
162
+ )
163
+ return VStack(spacing: 14) {
164
+ Button {
165
+ engine.startRecording()
166
+ } label: {
167
+ VStack(spacing: 10) {
168
+ Image(systemName: "mic.fill").font(.system(size: 44, weight: .semibold)).foregroundStyle(.tint)
169
+ Text(presentation.title).font(.system(.title, design: .rounded).weight(.semibold)).foregroundStyle(.primary)
170
+ Text(idleHint).font(.callout).foregroundStyle(.secondary)
171
+ }
172
+ .frame(maxWidth: .infinity).padding(.vertical, 14).contentShape(.rect)
173
+ }
174
+ .buttonStyle(.plain)
175
+ .keyboardShortcut(.defaultAction)
176
+ .disabled(!presentation.isEnabled)
177
+ .accessibilityLabel(presentation.accessibilityLabel)
178
+
179
+ Text("Speak — Recordings types what you say, answers questions, and edits selected text.")
180
+ .font(.caption)
181
+ .foregroundStyle(.tertiary)
182
+ .multilineTextAlignment(.center)
183
+
184
+ if engine.statusMessage != "Ready" {
185
+ Text(engine.statusMessage)
186
+ .font(.caption)
187
+ .foregroundStyle(Color.orange)
188
+ .multilineTextAlignment(.center)
189
+ }
190
+ }
191
+ .tint(Theme.accent)
192
+ }
193
+
194
+ private var idleHint: String {
195
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
196
+ return "Click, or hold \(shortcut.description)"
197
+ }
198
+ return "Click to start"
199
+ }
200
+
201
+ // MARK: Listening
202
+
203
+ private var listeningContent: some View {
204
+ VStack(spacing: 14) {
205
+ HStack(spacing: 12) {
206
+ Image(systemName: "waveform")
207
+ .symbolEffect(.variableColor.iterative, isActive: !reduceMotion)
208
+ .foregroundStyle(Theme.recordRed).font(.largeTitle)
209
+ .accessibilityHidden(true)
210
+ Text(fmt(engine.recordingDuration))
211
+ .font(.system(size: 34, weight: .semibold, design: .rounded).monospacedDigit())
212
+ .contentTransition(reduceMotion ? .identity : .numericText())
213
+ .accessibilityLabel("Recording, \(fmt(engine.recordingDuration))")
214
+ }
215
+ liveText(placeholder: "Listening…")
216
+ HStack(spacing: 12) {
217
+ Button(role: .cancel) { engine.cancelRecording() } label: {
218
+ Label("Discard", systemImage: "xmark")
219
+ }
220
+ .buttonStyle(.glass)
221
+ .keyboardShortcut(.cancelAction)
222
+ .accessibilityLabel("Discard recording")
223
+ Button { engine.stopAndTranscribe() } label: {
224
+ Label("Stop & Transcribe", systemImage: "stop.fill")
225
+ }
226
+ .buttonStyle(.glassProminent).tint(Theme.recordRed)
227
+ .keyboardShortcut(.defaultAction)
228
+ .accessibilityLabel("Stop and transcribe")
229
+ }
230
+ .controlSize(.large)
231
+ }
232
+ }
233
+
234
+ // MARK: Finalizing / Processing
235
+
236
+ private func busyContent(
237
+ label: String,
238
+ detail: String?,
239
+ showsLiveText: Bool,
240
+ showsCancel: Bool = false
241
+ ) -> some View {
242
+ VStack(spacing: 14) {
243
+ HStack(spacing: 10) {
244
+ ProgressView().controlSize(.large)
245
+ Text(label).font(.system(.title2, design: .rounded)).foregroundStyle(.secondary)
246
+ }
247
+ .accessibilityElement(children: .combine)
248
+ if let detail {
249
+ Text(detail).font(.caption).foregroundStyle(.tertiary)
250
+ }
251
+ if showsLiveText {
252
+ liveText(placeholder: "Finishing up…")
253
+ }
254
+ if showsCancel {
255
+ Button(role: .cancel) {
256
+ engine.cancelIntentProcessing()
257
+ } label: {
258
+ Label("Cancel", systemImage: "xmark")
259
+ }
260
+ .buttonStyle(.glass)
261
+ .controlSize(.large)
262
+ .keyboardShortcut(.cancelAction)
263
+ .help("Stop waiting — the transcript stays in Recent")
264
+ .accessibilityLabel("Cancel and keep the transcript")
265
+ }
266
+ }
267
+ }
268
+
269
+ // MARK: Ready
270
+
271
+ private func readyContent(summary: String) -> some View {
272
+ let presentation = RecordingStartControlPresentation(
273
+ kind: .recordAgain,
274
+ canStartRecording: engine.canStartRecording
275
+ )
276
+ return VStack(spacing: 14) {
277
+ Image(systemName: "checkmark.circle.fill")
278
+ .font(.system(size: 40, weight: .semibold))
279
+ .foregroundStyle(.green)
280
+ .accessibilityHidden(true)
281
+ Text(summary)
282
+ .font(.system(.title3, design: .rounded))
283
+ .multilineTextAlignment(.center)
284
+ Button {
285
+ engine.startRecording()
286
+ } label: {
287
+ Label(presentation.title, systemImage: "mic.fill")
288
+ }
289
+ .buttonStyle(.glass)
290
+ .controlSize(.large)
291
+ .keyboardShortcut(.defaultAction)
292
+ .disabled(!presentation.isEnabled)
293
+ .accessibilityLabel(presentation.accessibilityLabel)
294
+ }
295
+ .accessibilityElement(children: .contain)
296
+ }
297
+
298
+ // MARK: Error
299
+
300
+ private func failedContent(message: String) -> some View {
301
+ let presentation = RecordingStartControlPresentation(
302
+ kind: .tryAgain,
303
+ canStartRecording: engine.canStartRecording
304
+ )
305
+ return VStack(spacing: 14) {
306
+ Image(systemName: "exclamationmark.triangle.fill")
307
+ .font(.system(size: 36, weight: .semibold))
308
+ .foregroundStyle(.orange)
309
+ .accessibilityHidden(true)
310
+ Text(message)
311
+ .font(.system(.title3, design: .rounded))
312
+ .multilineTextAlignment(.center)
313
+ Button {
314
+ engine.startRecording()
315
+ } label: {
316
+ Label(presentation.title, systemImage: "mic.fill")
317
+ }
318
+ .buttonStyle(.glass)
319
+ .controlSize(.large)
320
+ .keyboardShortcut(.defaultAction)
321
+ .disabled(!presentation.isEnabled)
322
+ .accessibilityLabel(presentation.accessibilityLabel)
323
+ }
324
+ .accessibilityElement(children: .contain)
325
+ }
326
+
327
+ /// Live text renders inside a fixed six-line reservation: the region's size never
328
+ /// depends on how much has been transcribed, so nothing below it can shift.
329
+ private func liveText(placeholder: String) -> some View {
330
+ ZStack(alignment: .topLeading) {
331
+ liveTextReservation
332
+ .hidden()
333
+ .accessibilityHidden(true)
334
+ if !engine.liveTranscriptionText.isEmpty {
335
+ Text(engine.liveTranscriptionText)
336
+ .font(.system(.title3, design: .rounded))
337
+ .frame(maxWidth: .infinity, alignment: .leading)
338
+ .lineLimit(6).contentTransition(.opacity)
339
+ } else {
340
+ Text(placeholder).font(.callout).foregroundStyle(.secondary)
341
+ .frame(maxWidth: .infinity, alignment: .leading)
342
+ }
343
+ }
344
+ }
345
+
346
+ // MARK: - Conversation reply
347
+
348
+ /// Content surface, deliberately not glass: answers must stay highly readable.
349
+ private func replyCard(_ reply: ConversationReply) -> some View {
350
+ VStack(alignment: .leading, spacing: 8) {
351
+ HStack {
352
+ Label("You asked", systemImage: "questionmark.bubble")
353
+ .font(.caption.weight(.semibold)).foregroundStyle(.secondary)
354
+ Spacer()
355
+ Button {
356
+ engine.copyToClipboard(reply.answer)
357
+ } label: {
358
+ Label("Copy", systemImage: "doc.on.doc")
359
+ }
360
+ .buttonStyle(.borderless)
361
+ .help("Copy the answer")
362
+ .accessibilityLabel("Copy answer")
363
+ }
364
+ Text(reply.question)
365
+ .font(.callout).foregroundStyle(.secondary)
366
+ .frame(maxWidth: .infinity, alignment: .leading)
367
+ Divider().opacity(0.4)
368
+ Text(reply.answer)
369
+ .font(.body)
370
+ .textSelection(.enabled)
371
+ .frame(maxWidth: .infinity, alignment: .leading)
372
+ }
373
+ .padding(14)
374
+ .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: Theme.cornerMedium))
375
+ .frame(maxWidth: 560)
376
+ .accessibilityElement(children: .contain)
377
+ .accessibilityLabel("Answer: \(reply.answer)")
378
+ }
379
+
380
+ @ViewBuilder
381
+ private var activeProjectRow: some View {
382
+ if !store.projects.isEmpty {
383
+ HStack(spacing: 8) {
384
+ Image(systemName: "folder.fill")
385
+ .foregroundStyle(store.projectStore.activeProject != nil ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
386
+ Menu {
387
+ Button("None") { selectProject(nil) }
388
+ Divider()
389
+ ForEach(store.projects) { project in
390
+ Button {
391
+ selectProject(project.id)
392
+ } label: {
393
+ if project.id == store.projectStore.settings.activeProjectId {
394
+ Label(project.name, systemImage: "checkmark")
395
+ } else { Text(project.name) }
396
+ }
397
+ }
398
+ } label: {
399
+ Text(store.projectStore.activeProject?.name ?? "No project")
400
+ }
401
+ .menuStyle(.borderlessButton).fixedSize()
402
+ .disabled(!store.projectStore.canMutateProjects)
403
+ Text("· transcripts are tagged to this project").font(.caption).foregroundStyle(.tertiary)
404
+ Spacer()
405
+ }
406
+ .font(.system(.callout, design: .rounded))
407
+ .tint(Theme.accent)
408
+ .frame(maxWidth: 560)
409
+ }
410
+ }
411
+
412
+ private func selectProject(_ id: String?) {
413
+ do {
414
+ try store.projectStore.setActive(id)
415
+ } catch {
416
+ store.operationError = store.projectStore.persistenceError ?? error.localizedDescription
417
+ }
418
+ }
419
+
420
+ private func projectSynchronizationWarning(_ message: String) -> some View {
421
+ HStack(spacing: 8) {
422
+ Image(systemName: "exclamationmark.triangle.fill")
423
+ .foregroundStyle(.orange)
424
+ Text(message)
425
+ .font(.caption)
426
+ .foregroundStyle(.secondary)
427
+ .lineLimit(2)
428
+ Spacer()
429
+ Button("Retry") { store.reconcileProjects() }
430
+ .buttonStyle(.borderless)
431
+ .disabled(store.projectStore.isSynchronizingProjects)
432
+ }
433
+ .frame(maxWidth: 560)
434
+ .accessibilityElement(children: .combine)
435
+ }
436
+
437
+ // MARK: - Recent strip
438
+
439
+ private var recentStrip: some View {
440
+ let items = Array(engine.recentTranscriptions.prefix(3))
441
+ return VStack(alignment: .leading, spacing: 6) {
442
+ Text("JUST NOW").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
443
+ ForEach(Array(items.enumerated()), id: \.element.id) { i, item in
444
+ HStack(alignment: .top, spacing: 8) {
445
+ Image(systemName: "text.alignleft").font(.caption).foregroundStyle(.tertiary).padding(.top, 2)
446
+ Text(item.displayText).font(.callout).lineLimit(2)
447
+ .frame(maxWidth: .infinity, alignment: .leading)
448
+ Button {
449
+ engine.pasteIntoFrontApp(item.displayText)
450
+ } label: { Image(systemName: "arrow.up.right.square") }
451
+ .buttonStyle(.plain).foregroundStyle(.secondary).help("Paste into front app")
452
+ .accessibilityLabel("Paste transcript into front app")
453
+ }
454
+ .padding(.vertical, 4)
455
+ if i < items.count - 1 { Divider().opacity(0.3) }
456
+ }
457
+ }
458
+ .frame(maxWidth: 560)
459
+ .padding(14)
460
+ .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: Theme.cornerMedium))
461
+ }
462
+
463
+ private func fmt(_ t: TimeInterval) -> String {
464
+ let total = Int(t)
465
+ let h = total / 3600, m = (total % 3600) / 60, s = total % 60
466
+ return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s)
467
+ }
468
+ }