@arach/lattices 0.1.0 → 0.2.0

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 (39) hide show
  1. package/README.md +28 -28
  2. package/app/Sources/ActionRow.swift +61 -0
  3. package/app/Sources/App.swift +1 -40
  4. package/app/Sources/AppDelegate.swift +154 -24
  5. package/app/Sources/CheatSheetHUD.swift +1 -0
  6. package/app/Sources/CommandModeState.swift +40 -19
  7. package/app/Sources/CommandModeView.swift +27 -2
  8. package/app/Sources/DaemonServer.swift +8 -0
  9. package/app/Sources/DiagnosticLog.swift +19 -1
  10. package/app/Sources/EventBus.swift +1 -0
  11. package/app/Sources/HotkeyManager.swift +1 -0
  12. package/app/Sources/HotkeyStore.swift +9 -1
  13. package/app/Sources/LatticesApi.swift +210 -0
  14. package/app/Sources/MainView.swift +46 -86
  15. package/app/Sources/MainWindow.swift +13 -0
  16. package/app/Sources/OcrModel.swift +309 -0
  17. package/app/Sources/OcrStore.swift +295 -0
  18. package/app/Sources/OmniSearchState.swift +283 -0
  19. package/app/Sources/OmniSearchView.swift +288 -0
  20. package/app/Sources/OmniSearchWindow.swift +105 -0
  21. package/app/Sources/PaletteCommand.swift +11 -1
  22. package/app/Sources/PermissionChecker.swift +12 -2
  23. package/app/Sources/Preferences.swift +44 -0
  24. package/app/Sources/ScreenMapState.swift +7 -17
  25. package/app/Sources/ScreenMapView.swift +3 -0
  26. package/app/Sources/SettingsView.swift +534 -122
  27. package/app/Sources/Theme.swift +39 -0
  28. package/app/Sources/WindowTiler.swift +59 -56
  29. package/bin/lattices-app.js +23 -7
  30. package/bin/lattices.js +123 -0
  31. package/docs/api.md +390 -249
  32. package/docs/app.md +75 -28
  33. package/docs/concepts.md +45 -136
  34. package/docs/config.md +8 -7
  35. package/docs/layers.md +16 -18
  36. package/docs/ocr.md +185 -0
  37. package/docs/overview.md +39 -34
  38. package/docs/quickstart.md +34 -35
  39. package/package.json +6 -2
@@ -29,29 +29,33 @@ struct SettingsContentView: View {
29
29
 
30
30
  // MARK: - Back Bar
31
31
 
32
+ private var currentTabLabel: String {
33
+ switch selectedTab {
34
+ case "general": return "General"
35
+ case "search": return "Search & OCR"
36
+ case "shortcuts": return "Shortcuts"
37
+ default: return page.label
38
+ }
39
+ }
40
+
32
41
  private var backBar: some View {
33
42
  VStack(spacing: 0) {
34
- HStack(spacing: 6) {
43
+ HStack(spacing: 8) {
35
44
  Button {
36
45
  onBack?()
37
46
  } label: {
38
- HStack(spacing: 4) {
39
- Image(systemName: "chevron.left")
40
- .font(.system(size: 9, weight: .semibold))
41
- Text("Screen Map")
42
- .font(Typo.heading(11))
43
- }
44
- .foregroundColor(Palette.textDim)
47
+ Image(systemName: "chevron.left")
48
+ .font(.system(size: 10, weight: .semibold))
49
+ .foregroundColor(Palette.textMuted)
45
50
  }
46
51
  .buttonStyle(.plain)
47
52
  .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
48
53
 
49
- Spacer()
54
+ Text(page == .docs ? "Docs" : currentTabLabel)
55
+ .font(Typo.heading(13))
56
+ .foregroundColor(Palette.text)
50
57
 
51
- Text(page.label.uppercased())
52
- .font(Typo.pixel(11))
53
- .foregroundColor(Palette.textMuted)
54
- .tracking(1)
58
+ Spacer()
55
59
  }
56
60
  .padding(.horizontal, 16)
57
61
  .padding(.vertical, 8)
@@ -64,38 +68,53 @@ struct SettingsContentView: View {
64
68
 
65
69
  private var settingsBody: some View {
66
70
  VStack(spacing: 0) {
67
- // Internal tab bar
68
- HStack(spacing: 0) {
71
+ // Tab bar
72
+ HStack(spacing: 2) {
69
73
  settingsTab(label: "General", id: "general")
74
+ settingsTab(label: "Search & OCR", id: "search")
70
75
  settingsTab(label: "Shortcuts", id: "shortcuts")
71
76
  Spacer()
72
77
  }
73
- .padding(.horizontal, 16)
74
- .padding(.top, 4)
75
- .padding(.bottom, 4)
78
+ .padding(.horizontal, 14)
79
+ .padding(.vertical, 6)
76
80
 
77
81
  Rectangle().fill(Palette.border).frame(height: 0.5)
78
82
 
79
83
  // Tab content
80
84
  switch selectedTab {
81
85
  case "shortcuts": shortcutsContent
86
+ case "search": searchOcrContent
82
87
  default: generalContent
83
88
  }
84
89
  }
85
90
  }
86
91
 
87
92
  private func settingsTab(label: String, id: String) -> some View {
88
- Button {
93
+ let active = selectedTab == id
94
+ return Button {
89
95
  selectedTab = id
90
96
  } label: {
91
97
  Text(label)
92
- .font(Typo.heading(11))
93
- .foregroundColor(selectedTab == id ? Palette.text : Palette.textDim)
94
- .padding(.horizontal, 12)
95
- .padding(.vertical, 6)
98
+ .font(Typo.mono(11))
99
+ .foregroundColor(active ? Palette.text : Palette.textMuted)
100
+ .padding(.horizontal, 10)
101
+ .padding(.vertical, 5)
96
102
  .background(
97
- RoundedRectangle(cornerRadius: 4)
98
- .fill(selectedTab == id ? Color.white.opacity(0.06) : Color.clear)
103
+ ZStack {
104
+ if active {
105
+ RoundedRectangle(cornerRadius: 6)
106
+ .fill(Color.white.opacity(0.06))
107
+ RoundedRectangle(cornerRadius: 6)
108
+ .strokeBorder(
109
+ LinearGradient(
110
+ colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
111
+ startPoint: .top,
112
+ endPoint: .bottom
113
+ ),
114
+ lineWidth: 0.5
115
+ )
116
+ }
117
+ }
99
118
  )
100
119
  }
101
120
  .buttonStyle(.plain)
@@ -125,127 +144,517 @@ struct SettingsContentView: View {
125
144
  // MARK: - General
126
145
 
127
146
  private var generalContent: some View {
128
- VStack(alignment: .leading, spacing: 0) {
129
- ScrollView {
130
- LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
131
- Section(header: stickyHeader("General")) {
132
- VStack(alignment: .leading, spacing: 20) {
133
- settingsRow("Terminal") {
134
- Picker("", selection: $prefs.terminal) {
135
- ForEach(Terminal.installed) { t in
136
- Text(t.rawValue).tag(t)
137
- }
138
- }
139
- .pickerStyle(.radioGroup)
140
- .labelsHidden()
147
+ ScrollView {
148
+ VStack(alignment: .leading, spacing: 12) {
149
+ // ── Terminal ──
150
+ settingsCard {
151
+ VStack(alignment: .leading, spacing: 8) {
152
+ Text("Terminal")
153
+ .font(Typo.mono(11))
154
+ .foregroundColor(Palette.text)
155
+
156
+ Picker("", selection: $prefs.terminal) {
157
+ ForEach(Terminal.installed) { t in
158
+ Text(t.rawValue).tag(t)
141
159
  }
160
+ }
161
+ .pickerStyle(.segmented)
162
+ .labelsHidden()
142
163
 
143
- separator
164
+ Text("Used for attaching to tmux sessions")
165
+ .font(Typo.caption(10))
166
+ .foregroundColor(Palette.textMuted)
167
+ }
168
+ }
144
169
 
145
- settingsRow("Mode") {
146
- VStack(alignment: .leading, spacing: 6) {
147
- Picker("", selection: $prefs.mode) {
148
- Text("Learning").tag(InteractionMode.learning)
149
- Text("Auto").tag(InteractionMode.auto)
150
- }
151
- .pickerStyle(.radioGroup)
152
- .labelsHidden()
170
+ // ── tmux ──
171
+ settingsCard {
172
+ VStack(alignment: .leading, spacing: 10) {
173
+ Text("tmux")
174
+ .font(Typo.mono(11))
175
+ .foregroundColor(Palette.text)
176
+
177
+ // Mode
178
+ HStack {
179
+ Text("Detach mode")
180
+ .font(Typo.mono(10))
181
+ .foregroundColor(Palette.textDim)
182
+ Spacer()
183
+ Picker("", selection: $prefs.mode) {
184
+ Text("Learning").tag(InteractionMode.learning)
185
+ Text("Auto").tag(InteractionMode.auto)
186
+ }
187
+ .pickerStyle(.segmented)
188
+ .labelsHidden()
189
+ .frame(width: 160)
190
+ }
153
191
 
154
- Text(prefs.mode == .learning
155
- ? "Shows tmux keybinding hints on detach"
156
- : "Detaches sessions automatically")
157
- .font(Typo.caption(10))
158
- .foregroundColor(Palette.textMuted)
192
+ Text(prefs.mode == .learning
193
+ ? "Shows keybinding hints on detach"
194
+ : "Detaches sessions silently")
195
+ .font(Typo.caption(9))
196
+ .foregroundColor(Palette.textMuted.opacity(0.7))
197
+
198
+ cardDivider
199
+
200
+ // Project scan root
201
+ Text("Project scan root")
202
+ .font(Typo.mono(10))
203
+ .foregroundColor(Palette.textDim)
204
+
205
+ HStack(spacing: 6) {
206
+ TextField("~/dev", text: $prefs.scanRoot)
207
+ .textFieldStyle(.plain)
208
+ .font(Typo.mono(11))
209
+ .foregroundColor(Palette.text)
210
+ .padding(.horizontal, 8)
211
+ .padding(.vertical, 5)
212
+ .background(
213
+ RoundedRectangle(cornerRadius: 5)
214
+ .fill(Color.white.opacity(0.06))
215
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
216
+ )
217
+
218
+ Button {
219
+ let panel = NSOpenPanel()
220
+ panel.canChooseDirectories = true
221
+ panel.canChooseFiles = false
222
+ panel.allowsMultipleSelection = false
223
+ if !prefs.scanRoot.isEmpty {
224
+ panel.directoryURL = URL(fileURLWithPath: prefs.scanRoot)
225
+ }
226
+ if panel.runModal() == .OK, let url = panel.url {
227
+ prefs.scanRoot = url.path
159
228
  }
229
+ } label: {
230
+ Image(systemName: "folder")
231
+ .font(.system(size: 11))
232
+ .foregroundColor(Palette.textDim)
233
+ .padding(6)
234
+ .background(
235
+ RoundedRectangle(cornerRadius: 5)
236
+ .fill(Color.white.opacity(0.06))
237
+ .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
238
+ )
160
239
  }
240
+ .buttonStyle(.plain)
241
+ }
161
242
 
162
- separator
163
-
164
- settingsRow("Scan root") {
165
- VStack(alignment: .leading, spacing: 6) {
166
- HStack(spacing: 6) {
167
- TextField("~/dev", text: $prefs.scanRoot)
168
- .textFieldStyle(.plain)
169
- .font(Typo.mono(12))
170
- .foregroundColor(Palette.text)
171
- .padding(.horizontal, 8)
172
- .padding(.vertical, 5)
173
- .background(
174
- RoundedRectangle(cornerRadius: 3)
175
- .fill(Color.black.opacity(0.3))
176
- .overlay(
177
- RoundedRectangle(cornerRadius: 3)
178
- .strokeBorder(Palette.border, lineWidth: 0.5)
179
- )
180
- )
181
-
182
- Button {
183
- let panel = NSOpenPanel()
184
- panel.canChooseDirectories = true
185
- panel.canChooseFiles = false
186
- panel.allowsMultipleSelection = false
187
- if !prefs.scanRoot.isEmpty {
188
- panel.directoryURL = URL(fileURLWithPath: prefs.scanRoot)
189
- }
190
- if panel.runModal() == .OK, let url = panel.url {
191
- prefs.scanRoot = url.path
192
- }
193
- } label: {
194
- Text("Browse")
195
- .font(Typo.caption(11))
196
- .foregroundColor(Palette.textDim)
197
- .padding(.horizontal, 10)
198
- .padding(.vertical, 5)
199
- .background(
200
- RoundedRectangle(cornerRadius: 3)
201
- .fill(Palette.surface)
202
- .overlay(
203
- RoundedRectangle(cornerRadius: 3)
204
- .strokeBorder(Palette.border, lineWidth: 0.5)
205
- )
206
- )
207
- }
208
- .buttonStyle(.plain)
209
- }
210
-
211
- Text("Scans for .lattices.json configs")
243
+ HStack {
244
+ Text("Scans for .lattices.json project configs")
245
+ .font(Typo.caption(9))
246
+ .foregroundColor(Palette.textMuted.opacity(0.7))
247
+ Spacer()
248
+ Button {
249
+ scanner.updateRoot(prefs.scanRoot)
250
+ scanner.scan()
251
+ } label: {
252
+ Text("Rescan")
253
+ .font(Typo.monoBold(10))
254
+ .foregroundColor(Palette.text)
255
+ .padding(.horizontal, 12)
256
+ .padding(.vertical, 4)
257
+ .background(
258
+ RoundedRectangle(cornerRadius: 4)
259
+ .fill(Palette.surfaceHov)
260
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
261
+ )
262
+ }
263
+ .buttonStyle(.plain)
264
+ }
265
+ }
266
+ }
267
+ }
268
+ .padding(16)
269
+ }
270
+ }
271
+
272
+ // MARK: - Search & OCR
273
+
274
+ private func ocrNumField(_ value: Binding<Double>, width: CGFloat = 50) -> some View {
275
+ TextField("", value: value, formatter: NumberFormatter())
276
+ .textFieldStyle(.plain)
277
+ .font(Typo.monoBold(11))
278
+ .foregroundColor(Palette.text)
279
+ .multilineTextAlignment(.center)
280
+ .frame(width: width)
281
+ .padding(.horizontal, 4)
282
+ .padding(.vertical, 3)
283
+ .background(
284
+ RoundedRectangle(cornerRadius: 5)
285
+ .fill(Color.white.opacity(0.06))
286
+ .overlay(
287
+ RoundedRectangle(cornerRadius: 5)
288
+ .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
289
+ )
290
+ )
291
+ }
292
+
293
+ private func ocrIntField(_ value: Binding<Int>, width: CGFloat = 36) -> some View {
294
+ TextField("", value: value, formatter: NumberFormatter())
295
+ .textFieldStyle(.plain)
296
+ .font(Typo.monoBold(11))
297
+ .foregroundColor(Palette.text)
298
+ .multilineTextAlignment(.center)
299
+ .frame(width: width)
300
+ .padding(.horizontal, 4)
301
+ .padding(.vertical, 3)
302
+ .background(
303
+ RoundedRectangle(cornerRadius: 5)
304
+ .fill(Color.white.opacity(0.06))
305
+ .overlay(
306
+ RoundedRectangle(cornerRadius: 5)
307
+ .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
308
+ )
309
+ )
310
+ }
311
+
312
+ private func ocrSectionLabel(_ text: String) -> some View {
313
+ Text(text)
314
+ .font(Typo.monoBold(10))
315
+ .foregroundColor(Palette.textDim)
316
+ .tracking(0.5)
317
+ }
318
+
319
+ private var searchOcrContent: some View {
320
+ ScrollView {
321
+ VStack(spacing: 12) {
322
+ // ── Screen Text Recognition Card ──
323
+ settingsCard {
324
+ VStack(alignment: .leading, spacing: 10) {
325
+ // Header row: label + toggle
326
+ HStack {
327
+ HStack(spacing: 8) {
328
+ RoundedRectangle(cornerRadius: 4)
329
+ .fill(prefs.ocrEnabled ? Palette.running.opacity(0.15) : Palette.surface)
330
+ .overlay(
331
+ Image(systemName: "text.viewfinder")
332
+ .font(.system(size: 11, weight: .medium))
333
+ .foregroundColor(prefs.ocrEnabled ? Palette.running : Palette.textMuted)
334
+ )
335
+ .frame(width: 24, height: 24)
336
+
337
+ VStack(alignment: .leading, spacing: 1) {
338
+ Text("Screen text recognition")
339
+ .font(Typo.mono(12))
340
+ .foregroundColor(Palette.text)
341
+ Text("Vision OCR on visible windows")
212
342
  .font(Typo.caption(10))
213
343
  .foregroundColor(Palette.textMuted)
214
344
  }
215
345
  }
346
+ Spacer()
347
+ Toggle("", isOn: Binding(
348
+ get: { prefs.ocrEnabled },
349
+ set: { OcrModel.shared.setEnabled($0) }
350
+ ))
351
+ .toggleStyle(.switch)
352
+ .controlSize(.small)
353
+ .labelsHidden()
354
+ }
355
+
356
+ // Accuracy
357
+ HStack(spacing: 8) {
358
+ Text("Accuracy")
359
+ .font(Typo.mono(10))
360
+ .foregroundColor(Palette.textDim)
361
+ Picker("", selection: $prefs.ocrAccuracy) {
362
+ Text("Accurate").tag("accurate")
363
+ Text("Fast").tag("fast")
364
+ }
365
+ .pickerStyle(.segmented)
366
+ .labelsHidden()
367
+ .frame(width: 140)
368
+ Spacer()
369
+ }
370
+ .padding(.leading, 32)
371
+ }
372
+ }
373
+
374
+ // ── Scan Schedule Card ──
375
+ settingsCard {
376
+ VStack(alignment: .leading, spacing: 10) {
377
+ ocrSectionLabel("Schedule")
378
+
379
+ // Quick scan sentence
380
+ HStack(spacing: 0) {
381
+ Text("Quick scan top ")
382
+ .font(Typo.mono(11))
383
+ .foregroundColor(Palette.textDim)
384
+ ocrIntField($prefs.ocrQuickLimit, width: 32)
385
+ Text(" windows every ")
386
+ .font(Typo.mono(11))
387
+ .foregroundColor(Palette.textDim)
388
+ ocrNumField($prefs.ocrQuickInterval, width: 42)
389
+ Text("s")
390
+ .font(Typo.mono(11))
391
+ .foregroundColor(Palette.textDim)
392
+ Spacer()
393
+ }
394
+
395
+ cardDivider
396
+
397
+ // Deep scan sentence
398
+ HStack(spacing: 0) {
399
+ Text("Deep scan up to ")
400
+ .font(Typo.mono(11))
401
+ .foregroundColor(Palette.textDim)
402
+ ocrIntField($prefs.ocrDeepLimit, width: 32)
403
+ Text(" windows every ")
404
+ .font(Typo.mono(11))
405
+ .foregroundColor(Palette.textDim)
406
+ ocrNumField($prefs.ocrDeepInterval, width: 52)
407
+ Text("s")
408
+ .font(Typo.mono(11))
409
+ .foregroundColor(Palette.textDim)
410
+ Spacer()
411
+ }
412
+
413
+ // Human-readable deep interval
414
+ let h = Int(prefs.ocrDeepInterval / 3600)
415
+ let m = Int(prefs.ocrDeepInterval.truncatingRemainder(dividingBy: 3600) / 60)
416
+ if h > 0 || m > 0 {
417
+ Text("≈ \(h > 0 ? "\(h)h" : "")\(m > 0 ? " \(m)m" : "")")
418
+ .font(Typo.caption(9))
419
+ .foregroundColor(Palette.textMuted.opacity(0.6))
420
+ .padding(.leading, 2)
216
421
  }
217
- .padding(.horizontal, 20)
218
- .padding(.vertical, 12)
219
422
  }
220
423
  }
424
+
425
+ // ── Status Card ──
426
+ settingsCard {
427
+ HStack(spacing: 8) {
428
+ let ocrResults = OcrModel.shared.results
429
+ let isScanning = OcrModel.shared.isScanning
430
+
431
+ Circle()
432
+ .fill(isScanning ? Palette.detach : (prefs.ocrEnabled ? Palette.running : Palette.textMuted))
433
+ .frame(width: 6, height: 6)
434
+
435
+ Text(isScanning ? "Scanning..." : (prefs.ocrEnabled ? "\(ocrResults.count) windows cached" : "Disabled"))
436
+ .font(Typo.mono(10))
437
+ .foregroundColor(Palette.textMuted)
438
+
439
+ Spacer()
440
+
441
+ Button {
442
+ OcrModel.shared.scan()
443
+ } label: {
444
+ HStack(spacing: 4) {
445
+ Image(systemName: "arrow.clockwise")
446
+ .font(.system(size: 9, weight: .semibold))
447
+ Text("Scan Now")
448
+ .font(Typo.monoBold(10))
449
+ }
450
+ .foregroundColor(prefs.ocrEnabled ? Palette.text : Palette.textMuted)
451
+ .padding(.horizontal, 10)
452
+ .padding(.vertical, 4)
453
+ .background(
454
+ RoundedRectangle(cornerRadius: 4)
455
+ .fill(prefs.ocrEnabled ? Palette.surfaceHov : Palette.surface)
456
+ .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
457
+ )
458
+ }
459
+ .buttonStyle(.plain)
460
+ .disabled(!prefs.ocrEnabled)
461
+ }
462
+ }
463
+
464
+ // ── Recent Captures ──
465
+ recentCapturesSection
221
466
  }
467
+ .padding(16)
468
+ }
469
+ }
222
470
 
223
- Spacer(minLength: 0)
471
+ // MARK: - Recent Captures Browser
224
472
 
225
- separator
473
+ private var recentCapturesSection: some View {
474
+ let ocrResults = OcrModel.shared.results
475
+ let grouped = Dictionary(grouping: ocrResults.values, by: \.app)
476
+ .sorted { $0.value.count > $1.value.count }
226
477
 
227
- HStack {
228
- Spacer()
229
- Button {
230
- scanner.updateRoot(prefs.scanRoot)
231
- scanner.scan()
232
- } label: {
233
- Text("Save")
478
+ return Group {
479
+ if !grouped.isEmpty {
480
+ settingsCard {
481
+ VStack(alignment: .leading, spacing: 8) {
482
+ ocrSectionLabel("Recent Captures")
483
+
484
+ ForEach(grouped, id: \.key) { app, windows in
485
+ ocrAppGroup(app: app, windows: windows.sorted { $0.timestamp > $1.timestamp })
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ private func ocrAppGroup(app: String, windows: [OcrWindowResult]) -> some View {
494
+ let isCollapsed = collapsedOcrApps.contains(app)
495
+
496
+ return VStack(alignment: .leading, spacing: 0) {
497
+ // App header
498
+ Button {
499
+ withAnimation(.easeInOut(duration: 0.15)) {
500
+ if isCollapsed {
501
+ collapsedOcrApps.remove(app)
502
+ } else {
503
+ collapsedOcrApps.insert(app)
504
+ }
505
+ }
506
+ } label: {
507
+ HStack(spacing: 6) {
508
+ Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
509
+ .font(.system(size: 8, weight: .semibold))
510
+ .foregroundColor(Palette.textMuted)
511
+ .frame(width: 10)
512
+
513
+ Text(app)
234
514
  .font(Typo.monoBold(11))
235
- .foregroundColor(Palette.bg)
236
- .padding(.horizontal, 24)
237
- .padding(.vertical, 5)
238
- .background(
239
- RoundedRectangle(cornerRadius: 3).fill(Palette.text)
240
- )
515
+ .foregroundColor(Palette.text)
516
+
517
+ Text("(\(windows.count))")
518
+ .font(Typo.mono(10))
519
+ .foregroundColor(Palette.textMuted)
520
+
521
+ Spacer()
241
522
  }
242
- .buttonStyle(.plain)
523
+ .padding(.vertical, 4)
524
+ .contentShape(Rectangle())
243
525
  }
244
- .padding(.horizontal, 20)
245
- .padding(.vertical, 10)
526
+ .buttonStyle(.plain)
527
+
528
+ if !isCollapsed {
529
+ VStack(alignment: .leading, spacing: 2) {
530
+ ForEach(windows, id: \.wid) { win in
531
+ ocrWindowRow(win)
532
+ }
533
+ }
534
+ .padding(.leading, 16)
535
+ }
536
+ }
537
+ }
538
+
539
+ private func ocrWindowRow(_ win: OcrWindowResult) -> some View {
540
+ let isExpanded = expandedOcrWindow == win.wid
541
+ let preview = String(win.fullText.prefix(80)).replacingOccurrences(of: "\n", with: " ")
542
+
543
+ return VStack(alignment: .leading, spacing: 0) {
544
+ Button {
545
+ withAnimation(.easeInOut(duration: 0.15)) {
546
+ expandedOcrWindow = isExpanded ? nil : win.wid
547
+ }
548
+ } label: {
549
+ HStack(spacing: 6) {
550
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
551
+ .font(.system(size: 7, weight: .semibold))
552
+ .foregroundColor(Palette.textMuted)
553
+ .frame(width: 8)
554
+
555
+ VStack(alignment: .leading, spacing: 2) {
556
+ HStack(spacing: 6) {
557
+ Text(win.title.isEmpty ? "Untitled" : win.title)
558
+ .font(Typo.mono(10))
559
+ .foregroundColor(Palette.text)
560
+ .lineLimit(1)
561
+
562
+ Spacer()
563
+
564
+ Text(ocrRelativeTime(win.timestamp))
565
+ .font(Typo.caption(9))
566
+ .foregroundColor(Palette.textMuted)
567
+ }
568
+
569
+ if !isExpanded && !preview.isEmpty {
570
+ Text(preview)
571
+ .font(Typo.caption(9))
572
+ .foregroundColor(Palette.textMuted.opacity(0.7))
573
+ .lineLimit(1)
574
+ }
575
+ }
576
+ }
577
+ .padding(.vertical, 4)
578
+ .contentShape(Rectangle())
579
+ }
580
+ .buttonStyle(.plain)
581
+
582
+ if isExpanded {
583
+ ocrExpandedDetail(win)
584
+ .padding(.leading, 14)
585
+ .padding(.vertical, 4)
586
+ }
587
+ }
588
+ }
589
+
590
+ private func ocrExpandedDetail(_ win: OcrWindowResult) -> some View {
591
+ VStack(alignment: .leading, spacing: 6) {
592
+ // Metadata row
593
+ HStack(spacing: 10) {
594
+ let avgConfidence = win.texts.isEmpty ? 0 : win.texts.map(\.confidence).reduce(0, +) / Float(win.texts.count)
595
+ Text("\(win.texts.count) blocks")
596
+ .font(Typo.caption(9))
597
+ .foregroundColor(Palette.textMuted)
598
+ Text("confidence: \(String(format: "%.0f%%", avgConfidence * 100))")
599
+ .font(Typo.caption(9))
600
+ .foregroundColor(Palette.textMuted)
601
+ Spacer()
602
+ }
603
+
604
+ // Full text in scrollable monospaced area
605
+ ScrollView {
606
+ Text(win.fullText)
607
+ .font(.system(size: 10, design: .monospaced))
608
+ .foregroundColor(Palette.textDim)
609
+ .textSelection(.enabled)
610
+ .frame(maxWidth: .infinity, alignment: .leading)
611
+ .padding(8)
612
+ }
613
+ .frame(maxHeight: 150)
614
+ .background(
615
+ RoundedRectangle(cornerRadius: 4)
616
+ .fill(Color.black.opacity(0.2))
617
+ .overlay(
618
+ RoundedRectangle(cornerRadius: 4)
619
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
620
+ )
621
+ )
246
622
  }
247
623
  }
248
624
 
625
+ private func ocrRelativeTime(_ date: Date) -> String {
626
+ let seconds = Int(-date.timeIntervalSinceNow)
627
+ if seconds < 60 { return "just now" }
628
+ let minutes = seconds / 60
629
+ if minutes < 60 { return "\(minutes)m ago" }
630
+ let hours = minutes / 60
631
+ if hours < 24 { return "\(hours)h ago" }
632
+ return "\(hours / 24)d ago"
633
+ }
634
+
635
+ // MARK: - Settings Card
636
+
637
+ private func settingsCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
638
+ content()
639
+ .padding(.horizontal, 14)
640
+ .padding(.vertical, 12)
641
+ .frame(maxWidth: .infinity, alignment: .leading)
642
+ .liquidGlass()
643
+ }
644
+
645
+ private var cardDivider: some View {
646
+ Rectangle()
647
+ .fill(
648
+ LinearGradient(
649
+ colors: [Color.white.opacity(0.03), Color.white.opacity(0.08), Color.white.opacity(0.03)],
650
+ startPoint: .leading,
651
+ endPoint: .trailing
652
+ )
653
+ )
654
+ .frame(height: 0.5)
655
+ .padding(.vertical, 3)
656
+ }
657
+
249
658
  // MARK: - Shortcuts (Spatial Layout)
250
659
 
251
660
  private var shortcutsContent: some View {
@@ -449,6 +858,9 @@ struct SettingsContentView: View {
449
858
  }
450
859
  }
451
860
 
861
+ @State private var expandedOcrWindow: UInt32?
862
+ @State private var collapsedOcrApps: Set<String> = []
863
+
452
864
  @State private var activeTilePopover: HotkeyAction?
453
865
 
454
866
  private func tileCellPopoverBinding(for action: HotkeyAction) -> Binding<Bool> {