@arach/lattices 0.1.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 (64) hide show
  1. package/README.md +157 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/App.swift +49 -0
  5. package/app/Sources/AppDelegate.swift +104 -0
  6. package/app/Sources/AppShellView.swift +62 -0
  7. package/app/Sources/AppTypeClassifier.swift +70 -0
  8. package/app/Sources/AppWindowShell.swift +63 -0
  9. package/app/Sources/CheatSheetHUD.swift +331 -0
  10. package/app/Sources/CommandModeState.swift +1341 -0
  11. package/app/Sources/CommandModeView.swift +1380 -0
  12. package/app/Sources/CommandModeWindow.swift +192 -0
  13. package/app/Sources/CommandPaletteView.swift +307 -0
  14. package/app/Sources/CommandPaletteWindow.swift +134 -0
  15. package/app/Sources/DaemonProtocol.swift +101 -0
  16. package/app/Sources/DaemonServer.swift +406 -0
  17. package/app/Sources/DesktopModel.swift +121 -0
  18. package/app/Sources/DesktopModelTypes.swift +71 -0
  19. package/app/Sources/DiagnosticLog.swift +253 -0
  20. package/app/Sources/EventBus.swift +29 -0
  21. package/app/Sources/HotkeyManager.swift +249 -0
  22. package/app/Sources/HotkeyStore.swift +330 -0
  23. package/app/Sources/InventoryManager.swift +35 -0
  24. package/app/Sources/InventoryPath.swift +43 -0
  25. package/app/Sources/KeyRecorderView.swift +210 -0
  26. package/app/Sources/LatticesApi.swift +915 -0
  27. package/app/Sources/MainView.swift +507 -0
  28. package/app/Sources/MainWindow.swift +70 -0
  29. package/app/Sources/OrphanRow.swift +129 -0
  30. package/app/Sources/PaletteCommand.swift +409 -0
  31. package/app/Sources/PermissionChecker.swift +115 -0
  32. package/app/Sources/Preferences.swift +48 -0
  33. package/app/Sources/ProcessModel.swift +199 -0
  34. package/app/Sources/ProcessQuery.swift +151 -0
  35. package/app/Sources/Project.swift +28 -0
  36. package/app/Sources/ProjectRow.swift +368 -0
  37. package/app/Sources/ProjectScanner.swift +121 -0
  38. package/app/Sources/ScreenMapState.swift +2397 -0
  39. package/app/Sources/ScreenMapView.swift +2817 -0
  40. package/app/Sources/ScreenMapWindowController.swift +89 -0
  41. package/app/Sources/SessionManager.swift +72 -0
  42. package/app/Sources/SettingsView.swift +641 -0
  43. package/app/Sources/SettingsWindow.swift +20 -0
  44. package/app/Sources/TabGroupRow.swift +178 -0
  45. package/app/Sources/Terminal.swift +259 -0
  46. package/app/Sources/TerminalQuery.swift +156 -0
  47. package/app/Sources/TerminalSynthesizer.swift +200 -0
  48. package/app/Sources/Theme.swift +124 -0
  49. package/app/Sources/TilePickerView.swift +209 -0
  50. package/app/Sources/TmuxModel.swift +53 -0
  51. package/app/Sources/TmuxQuery.swift +81 -0
  52. package/app/Sources/WindowTiler.swift +1752 -0
  53. package/app/Sources/WorkspaceManager.swift +434 -0
  54. package/bin/daemon-client.js +187 -0
  55. package/bin/lattices-app.js +205 -0
  56. package/bin/lattices.js +1295 -0
  57. package/docs/api.md +707 -0
  58. package/docs/app.md +250 -0
  59. package/docs/concepts.md +225 -0
  60. package/docs/config.md +234 -0
  61. package/docs/layers.md +317 -0
  62. package/docs/overview.md +74 -0
  63. package/docs/quickstart.md +82 -0
  64. package/package.json +38 -0
@@ -0,0 +1,641 @@
1
+ import SwiftUI
2
+
3
+ /// Settings content with internal General / Shortcuts tabs.
4
+ /// Can also render the Docs page when `page == .docs`.
5
+ struct SettingsContentView: View {
6
+ var page: AppPage = .settings
7
+ @ObservedObject var prefs: Preferences
8
+ @ObservedObject var scanner: ProjectScanner
9
+ @ObservedObject var hotkeyStore: HotkeyStore = .shared
10
+ var onBack: (() -> Void)? = nil
11
+
12
+ @State private var selectedTab = "shortcuts"
13
+
14
+ var body: some View {
15
+ VStack(spacing: 0) {
16
+ // Back bar
17
+ backBar
18
+
19
+ if page == .docs {
20
+ docsContent
21
+ } else {
22
+ settingsBody
23
+ }
24
+ }
25
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
26
+ .clipped()
27
+ .background(PanelBackground())
28
+ }
29
+
30
+ // MARK: - Back Bar
31
+
32
+ private var backBar: some View {
33
+ VStack(spacing: 0) {
34
+ HStack(spacing: 6) {
35
+ Button {
36
+ onBack?()
37
+ } 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)
45
+ }
46
+ .buttonStyle(.plain)
47
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
48
+
49
+ Spacer()
50
+
51
+ Text(page.label.uppercased())
52
+ .font(Typo.pixel(11))
53
+ .foregroundColor(Palette.textMuted)
54
+ .tracking(1)
55
+ }
56
+ .padding(.horizontal, 16)
57
+ .padding(.vertical, 8)
58
+
59
+ Rectangle().fill(Palette.border).frame(height: 0.5)
60
+ }
61
+ }
62
+
63
+ // MARK: - Settings Body (General + Shortcuts tabs)
64
+
65
+ private var settingsBody: some View {
66
+ VStack(spacing: 0) {
67
+ // Internal tab bar
68
+ HStack(spacing: 0) {
69
+ settingsTab(label: "General", id: "general")
70
+ settingsTab(label: "Shortcuts", id: "shortcuts")
71
+ Spacer()
72
+ }
73
+ .padding(.horizontal, 16)
74
+ .padding(.top, 4)
75
+ .padding(.bottom, 4)
76
+
77
+ Rectangle().fill(Palette.border).frame(height: 0.5)
78
+
79
+ // Tab content
80
+ switch selectedTab {
81
+ case "shortcuts": shortcutsContent
82
+ default: generalContent
83
+ }
84
+ }
85
+ }
86
+
87
+ private func settingsTab(label: String, id: String) -> some View {
88
+ Button {
89
+ selectedTab = id
90
+ } label: {
91
+ Text(label)
92
+ .font(Typo.heading(11))
93
+ .foregroundColor(selectedTab == id ? Palette.text : Palette.textDim)
94
+ .padding(.horizontal, 12)
95
+ .padding(.vertical, 6)
96
+ .background(
97
+ RoundedRectangle(cornerRadius: 4)
98
+ .fill(selectedTab == id ? Color.white.opacity(0.06) : Color.clear)
99
+ )
100
+ }
101
+ .buttonStyle(.plain)
102
+ }
103
+
104
+ // MARK: - Sticky section header
105
+
106
+ private func stickyHeader(_ title: String) -> some View {
107
+ VStack(spacing: 0) {
108
+ HStack {
109
+ Text(title.uppercased())
110
+ .font(Typo.pixel(14))
111
+ .foregroundColor(Palette.textDim)
112
+ .tracking(1)
113
+ Spacer()
114
+ }
115
+ .padding(.horizontal, 20)
116
+ .padding(.vertical, 8)
117
+ .background(Palette.bg)
118
+
119
+ Rectangle()
120
+ .fill(Palette.border)
121
+ .frame(height: 0.5)
122
+ }
123
+ }
124
+
125
+ // MARK: - General
126
+
127
+ 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()
141
+ }
142
+
143
+ separator
144
+
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()
153
+
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)
159
+ }
160
+ }
161
+
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")
212
+ .font(Typo.caption(10))
213
+ .foregroundColor(Palette.textMuted)
214
+ }
215
+ }
216
+ }
217
+ .padding(.horizontal, 20)
218
+ .padding(.vertical, 12)
219
+ }
220
+ }
221
+ }
222
+
223
+ Spacer(minLength: 0)
224
+
225
+ separator
226
+
227
+ HStack {
228
+ Spacer()
229
+ Button {
230
+ scanner.updateRoot(prefs.scanRoot)
231
+ scanner.scan()
232
+ } label: {
233
+ Text("Save")
234
+ .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
+ )
241
+ }
242
+ .buttonStyle(.plain)
243
+ }
244
+ .padding(.horizontal, 20)
245
+ .padding(.vertical, 10)
246
+ }
247
+ }
248
+
249
+ // MARK: - Shortcuts (Spatial Layout)
250
+
251
+ private var shortcutsContent: some View {
252
+ VStack(spacing: 0) {
253
+ GeometryReader { geo in
254
+ let spacing: CGFloat = 16
255
+ let pad: CGFloat = 20
256
+ let total = geo.size.width - pad * 2 - spacing * 2
257
+ let leftW = total * 0.35
258
+ let centerW = total * 0.35
259
+ let rightW = total * 0.30
260
+
261
+ ScrollView {
262
+ VStack(alignment: .leading, spacing: 0) {
263
+ HStack(alignment: .top, spacing: spacing) {
264
+ shortcutsLeftColumn
265
+ .frame(width: leftW, alignment: .leading)
266
+ .clipped()
267
+ shortcutsCenterColumn
268
+ .frame(width: centerW, alignment: .leading)
269
+ .clipped()
270
+ shortcutsRightColumn
271
+ .frame(width: rightW, alignment: .leading)
272
+ .clipped()
273
+ }
274
+ .padding(.horizontal, pad)
275
+ .padding(.vertical, 16)
276
+ }
277
+ }
278
+ }
279
+
280
+ Spacer(minLength: 0)
281
+
282
+ separator
283
+
284
+ HStack {
285
+ Spacer()
286
+ Button {
287
+ hotkeyStore.resetAll()
288
+ } label: {
289
+ Text("Reset All to Defaults")
290
+ .font(Typo.caption(11))
291
+ .foregroundColor(Palette.textDim)
292
+ .padding(.horizontal, 12)
293
+ .padding(.vertical, 5)
294
+ .background(
295
+ RoundedRectangle(cornerRadius: 3)
296
+ .fill(Palette.surface)
297
+ .overlay(
298
+ RoundedRectangle(cornerRadius: 3)
299
+ .strokeBorder(Palette.border, lineWidth: 0.5)
300
+ )
301
+ )
302
+ }
303
+ .buttonStyle(.plain)
304
+ }
305
+ .padding(.horizontal, 20)
306
+ .padding(.vertical, 10)
307
+ }
308
+ }
309
+
310
+ // MARK: - Shortcuts: Left Column (App + Layers)
311
+
312
+ private var shortcutsLeftColumn: some View {
313
+ VStack(alignment: .leading, spacing: 12) {
314
+ columnHeader("App & Layers")
315
+
316
+ VStack(alignment: .leading, spacing: 2) {
317
+ ForEach(HotkeyAction.allCases.filter { $0.group == .app }, id: \.rawValue) { action in
318
+ compactKeyRecorder(action: action)
319
+ }
320
+ }
321
+
322
+ Rectangle().fill(Palette.border).frame(height: 0.5)
323
+
324
+ VStack(alignment: .leading, spacing: 2) {
325
+ ForEach(HotkeyAction.layerActions, id: \.rawValue) { action in
326
+ compactKeyRecorder(action: action)
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ // MARK: - Shortcuts: Center Column (Tiling)
333
+
334
+ private var shortcutsCenterColumn: some View {
335
+ VStack(alignment: .leading, spacing: 12) {
336
+ columnHeader("Tiling")
337
+
338
+ // Monitor visualization — 3x3 grid
339
+ VStack(spacing: 2) {
340
+ HStack(spacing: 2) {
341
+ tileCell(action: .tileTopLeft, label: "TL")
342
+ tileCell(action: .tileTop, label: "Top")
343
+ tileCell(action: .tileTopRight, label: "TR")
344
+ }
345
+ HStack(spacing: 2) {
346
+ tileCell(action: .tileLeft, label: "Left")
347
+ tileCell(action: .tileMaximize, label: "Max")
348
+ tileCell(action: .tileRight, label: "Right")
349
+ }
350
+ HStack(spacing: 2) {
351
+ tileCell(action: .tileBottomLeft, label: "BL")
352
+ tileCell(action: .tileBottom, label: "Bottom")
353
+ tileCell(action: .tileBottomRight, label: "BR")
354
+ }
355
+ }
356
+ .padding(6)
357
+ .background(
358
+ RoundedRectangle(cornerRadius: 6)
359
+ .fill(Color.black.opacity(0.25))
360
+ .overlay(
361
+ RoundedRectangle(cornerRadius: 6)
362
+ .strokeBorder(Palette.border, lineWidth: 0.5)
363
+ )
364
+ )
365
+
366
+ // Thirds row
367
+ HStack(spacing: 2) {
368
+ tileCell(action: .tileLeftThird, label: "\u{2153}L")
369
+ tileCell(action: .tileCenterThird, label: "\u{2153}C")
370
+ tileCell(action: .tileRightThird, label: "\u{2153}R")
371
+ }
372
+ .padding(6)
373
+ .background(
374
+ RoundedRectangle(cornerRadius: 6)
375
+ .fill(Color.black.opacity(0.25))
376
+ .overlay(
377
+ RoundedRectangle(cornerRadius: 6)
378
+ .strokeBorder(Palette.border, lineWidth: 0.5)
379
+ )
380
+ )
381
+
382
+ // Center + Distribute
383
+ HStack(spacing: 4) {
384
+ compactKeyRecorder(action: .tileCenter)
385
+ compactKeyRecorder(action: .tileDistribute)
386
+ }
387
+ }
388
+ }
389
+
390
+ // MARK: - Shortcuts: Right Column (tmux)
391
+
392
+ private var shortcutsRightColumn: some View {
393
+ VStack(alignment: .leading, spacing: 12) {
394
+ columnHeader("Inside tmux")
395
+
396
+ VStack(alignment: .leading, spacing: 6) {
397
+ shortcutRow("Detach", keys: ["Ctrl+B", "D"])
398
+ shortcutRow("Kill pane", keys: ["Ctrl+B", "X"])
399
+ shortcutRow("Pane left", keys: ["Ctrl+B", "\u{2190}"])
400
+ shortcutRow("Pane right", keys: ["Ctrl+B", "\u{2192}"])
401
+ shortcutRow("Zoom toggle", keys: ["Ctrl+B", "Z"])
402
+ shortcutRow("Scroll mode", keys: ["Ctrl+B", "["])
403
+ }
404
+ }
405
+ }
406
+
407
+ // MARK: - Column header
408
+
409
+ private func columnHeader(_ title: String) -> some View {
410
+ Text(title.uppercased())
411
+ .font(Typo.pixel(12))
412
+ .foregroundColor(Palette.textDim)
413
+ .tracking(1)
414
+ }
415
+
416
+ // MARK: - Tile cell (spatial grid item)
417
+
418
+ private func tileCell(action: HotkeyAction, label: String) -> some View {
419
+ let binding = hotkeyStore.bindings[action]
420
+ let badgeText = binding?.displayParts.last ?? ""
421
+
422
+ return Button {
423
+ // Open inline key recorder for this action
424
+ } label: {
425
+ VStack(spacing: 3) {
426
+ Text(label)
427
+ .font(Typo.caption(9))
428
+ .foregroundColor(Palette.textDim)
429
+ Text(badgeText)
430
+ .font(Typo.geistMonoBold(9))
431
+ .foregroundColor(Palette.text)
432
+ }
433
+ .frame(maxWidth: .infinity)
434
+ .frame(height: 42)
435
+ .background(
436
+ RoundedRectangle(cornerRadius: 4)
437
+ .fill(Palette.surface)
438
+ .overlay(
439
+ RoundedRectangle(cornerRadius: 4)
440
+ .strokeBorder(Palette.border, lineWidth: 0.5)
441
+ )
442
+ )
443
+ }
444
+ .buttonStyle(.plain)
445
+ .popover(isPresented: tileCellPopoverBinding(for: action)) {
446
+ KeyRecorderView(action: action, store: hotkeyStore)
447
+ .padding(12)
448
+ .frame(width: 300)
449
+ }
450
+ }
451
+
452
+ @State private var activeTilePopover: HotkeyAction?
453
+
454
+ private func tileCellPopoverBinding(for action: HotkeyAction) -> Binding<Bool> {
455
+ Binding(
456
+ get: { activeTilePopover == action },
457
+ set: { if !$0 { activeTilePopover = nil } }
458
+ )
459
+ }
460
+
461
+ // MARK: - Compact key recorder
462
+
463
+ private func compactKeyRecorder(action: HotkeyAction) -> some View {
464
+ KeyRecorderView(action: action, store: hotkeyStore)
465
+ }
466
+
467
+ // MARK: - Shortcut row (read-only, for tmux)
468
+
469
+ private func shortcutRow(_ label: String, keys: [String]) -> some View {
470
+ HStack {
471
+ Text(label)
472
+ .font(Typo.caption(11))
473
+ .foregroundColor(Palette.textDim)
474
+ .frame(width: 80, alignment: .trailing)
475
+
476
+ HStack(spacing: 4) {
477
+ ForEach(keys, id: \.self) { key in
478
+ keyBadge(key)
479
+ }
480
+ }
481
+ .padding(.leading, 8)
482
+
483
+ Spacer()
484
+ }
485
+ }
486
+
487
+ // MARK: - Docs
488
+
489
+ private var docsContent: some View {
490
+ ScrollView {
491
+ LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
492
+ Section(header: stickyHeader("What is lattices?")) {
493
+ Text("A developer workspace launcher. It creates pre-configured terminal layouts for your projects using tmux \u{2014} go from \u{201C}I want to work on X\u{201D} to a full environment in one click.")
494
+ .font(Typo.caption(11))
495
+ .foregroundColor(Palette.textDim)
496
+ .lineSpacing(3)
497
+ .padding(.horizontal, 20)
498
+ .padding(.vertical, 12)
499
+ }
500
+
501
+ Section(header: stickyHeader("Glossary")) {
502
+ VStack(alignment: .leading, spacing: 12) {
503
+ glossaryItem("Session",
504
+ "A persistent workspace that lives in the background. Survives terminal crashes, disconnects, even closing your laptop.")
505
+ glossaryItem("Pane",
506
+ "A single terminal view inside a session. A typical setup has two panes \u{2014} Claude Code on the left, dev server on the right.")
507
+ glossaryItem("Attach",
508
+ "Connect your terminal window to an existing session. The session was already running \u{2014} you\u{2019}re just viewing it.")
509
+ glossaryItem("Detach",
510
+ "Disconnect your terminal but keep the session alive. Your dev server keeps running, Claude keeps thinking.")
511
+ glossaryItem("tmux",
512
+ "Terminal multiplexer \u{2014} the engine behind lattices. It manages sessions, panes, and layouts. lattices configures it so you don\u{2019}t have to.")
513
+ }
514
+ .padding(.horizontal, 20)
515
+ .padding(.vertical, 12)
516
+ }
517
+
518
+ Section(header: stickyHeader("How it works")) {
519
+ VStack(alignment: .leading, spacing: 8) {
520
+ flowStep("1", "Create a .lattices.json in your project root")
521
+ flowStep("2", "lattices reads the config and builds a tmux session")
522
+ flowStep("3", "Each pane gets its command (claude, dev server, etc.)")
523
+ flowStep("4", "Session persists in the background until you kill it")
524
+ flowStep("5", "Attach and detach from any terminal, any time")
525
+ }
526
+ .padding(.horizontal, 20)
527
+ .padding(.vertical, 12)
528
+ }
529
+
530
+ Section(header: stickyHeader("Reference")) {
531
+ HStack(spacing: 8) {
532
+ docsLinkButton(icon: "doc.text", label: "Config format", file: "config.md")
533
+ docsLinkButton(icon: "book", label: "Full concepts", file: "concepts.md")
534
+ }
535
+ .padding(.horizontal, 20)
536
+ .padding(.vertical, 12)
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ // MARK: - Docs helpers
543
+
544
+ private func glossaryItem(_ term: String, _ definition: String) -> some View {
545
+ VStack(alignment: .leading, spacing: 3) {
546
+ Text(term)
547
+ .font(Typo.monoBold(11))
548
+ .foregroundColor(Palette.text)
549
+ Text(definition)
550
+ .font(Typo.caption(10.5))
551
+ .foregroundColor(Palette.textMuted)
552
+ .lineSpacing(2)
553
+ }
554
+ }
555
+
556
+ private func flowStep(_ number: String, _ text: String) -> some View {
557
+ HStack(alignment: .top, spacing: 8) {
558
+ Text(number)
559
+ .font(Typo.monoBold(10))
560
+ .foregroundColor(Palette.running)
561
+ .frame(width: 14)
562
+ Text(text)
563
+ .font(Typo.caption(11))
564
+ .foregroundColor(Palette.textDim)
565
+ }
566
+ }
567
+
568
+ private func docsLinkButton(icon: String, label: String, file: String) -> some View {
569
+ Button {
570
+ let path = resolveDocsFile(file)
571
+ NSWorkspace.shared.open(URL(fileURLWithPath: path))
572
+ } label: {
573
+ HStack(spacing: 6) {
574
+ Image(systemName: icon)
575
+ .font(.system(size: 10))
576
+ Text(label)
577
+ .font(Typo.caption(11))
578
+ }
579
+ .foregroundColor(Palette.textDim)
580
+ .padding(.horizontal, 12)
581
+ .padding(.vertical, 6)
582
+ .background(
583
+ RoundedRectangle(cornerRadius: 3)
584
+ .fill(Palette.surface)
585
+ .overlay(
586
+ RoundedRectangle(cornerRadius: 3)
587
+ .strokeBorder(Palette.border, lineWidth: 0.5)
588
+ )
589
+ )
590
+ }
591
+ .buttonStyle(.plain)
592
+ }
593
+
594
+ private func resolveDocsFile(_ file: String) -> String {
595
+ let devPath = "/Users/arach/dev/lattice/docs/\(file)"
596
+ if FileManager.default.fileExists(atPath: devPath) { return devPath }
597
+ let bundle = Bundle.main.bundlePath
598
+ let appDir = (bundle as NSString).deletingLastPathComponent
599
+ let docsPath = ((appDir as NSString).appendingPathComponent("../docs/\(file)") as NSString).standardizingPath
600
+ if FileManager.default.fileExists(atPath: docsPath) { return docsPath }
601
+ return devPath
602
+ }
603
+
604
+ // MARK: - Shared helpers
605
+
606
+ private var separator: some View {
607
+ Rectangle()
608
+ .fill(Palette.border)
609
+ .frame(height: 0.5)
610
+ }
611
+
612
+ private func settingsRow<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
613
+ HStack(alignment: .top, spacing: 0) {
614
+ Text(label)
615
+ .font(Typo.caption(11))
616
+ .foregroundColor(Palette.textDim)
617
+ .frame(width: 100, alignment: .trailing)
618
+ .padding(.top, 2)
619
+
620
+ content()
621
+ .padding(.leading, 16)
622
+ .frame(maxWidth: .infinity, alignment: .leading)
623
+ }
624
+ }
625
+
626
+ private func keyBadge(_ key: String) -> some View {
627
+ Text(key)
628
+ .font(Typo.geistMonoBold(10))
629
+ .foregroundColor(Palette.text)
630
+ .padding(.horizontal, 6)
631
+ .padding(.vertical, 3)
632
+ .background(
633
+ RoundedRectangle(cornerRadius: 3)
634
+ .fill(Palette.surface)
635
+ .overlay(
636
+ RoundedRectangle(cornerRadius: 3)
637
+ .strokeBorder(Palette.border, lineWidth: 0.5)
638
+ )
639
+ )
640
+ }
641
+ }
@@ -0,0 +1,20 @@
1
+ import AppKit
2
+
3
+ /// Thin redirect — Settings is now a page inside the unified app window.
4
+ final class SettingsWindowController {
5
+ static let shared = SettingsWindowController()
6
+
7
+ var isVisible: Bool { ScreenMapWindowController.shared.isVisible }
8
+
9
+ func toggle() {
10
+ if isVisible { close() } else { show() }
11
+ }
12
+
13
+ func show() {
14
+ ScreenMapWindowController.shared.showPage(.settings)
15
+ }
16
+
17
+ func close() {
18
+ ScreenMapWindowController.shared.close()
19
+ }
20
+ }