@arach/lattices 0.2.0 → 0.6.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -86
  3. package/apps/mac/Info.plist +43 -0
  4. package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
  5. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  6. package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  7. package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
  8. package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
  9. package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
  10. package/apps/mac/Lattices.entitlements +21 -0
  11. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  12. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  13. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  14. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  15. package/apps/mac/Resources/tap.wav +0 -0
  16. package/assets/AppIcon.icns +0 -0
  17. package/bin/assistant-intelligence.ts +912 -0
  18. package/bin/cli/capture.ts +252 -0
  19. package/bin/cli/daemon.ts +22 -0
  20. package/bin/cli/helpers.ts +105 -0
  21. package/bin/cli/layer.ts +178 -0
  22. package/bin/cli/runs.ts +43 -0
  23. package/bin/cli/search.ts +141 -0
  24. package/bin/cli/session.ts +32 -0
  25. package/bin/client.ts +17 -0
  26. package/bin/cua.ts +26 -0
  27. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  28. package/bin/handsoff-infer.ts +96 -0
  29. package/bin/handsoff-worker.ts +531 -0
  30. package/bin/infer.ts +424 -0
  31. package/bin/keychain.ts +75 -0
  32. package/bin/lattices-app.ts +655 -0
  33. package/bin/lattices-build +125 -0
  34. package/bin/lattices-build-env.ts +77 -0
  35. package/bin/lattices-dev +362 -0
  36. package/bin/lattices.ts +3260 -0
  37. package/bin/project-twin.ts +645 -0
  38. package/docs/agent-execution-plan.md +562 -0
  39. package/docs/agent-layer-guide.md +207 -0
  40. package/docs/agents.md +233 -0
  41. package/docs/ai-chat-ux-review.md +416 -0
  42. package/docs/api.md +1041 -47
  43. package/docs/app.md +96 -13
  44. package/docs/assistant-knowledge.md +130 -0
  45. package/docs/companion-deck.md +209 -0
  46. package/docs/component-extraction-roadmap.md +392 -0
  47. package/docs/concepts.md +13 -12
  48. package/docs/config.md +83 -10
  49. package/docs/gesture-customization-proposal.md +520 -0
  50. package/docs/handsoff-test-scenarios.md +84 -0
  51. package/docs/hyperspace-grid-snappiness.md +210 -0
  52. package/docs/layers.md +176 -28
  53. package/docs/mouse-gestures.md +244 -0
  54. package/docs/ocr.md +21 -9
  55. package/docs/overview.md +42 -23
  56. package/docs/presentation-execution-review.md +491 -0
  57. package/docs/prompts/hands-off-system.md +382 -0
  58. package/docs/prompts/hands-off-turn.md +30 -0
  59. package/docs/prompts/voice-advisor.md +31 -0
  60. package/docs/prompts/voice-fallback.md +23 -0
  61. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  62. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  63. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  64. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  65. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  66. package/docs/proposals/LAT-006-followup-gaps.md +103 -0
  67. package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
  68. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  69. package/docs/quickstart.md +8 -12
  70. package/docs/reference/dewey.config.ts +74 -0
  71. package/docs/reference/install-agent.md +79 -0
  72. package/docs/release.md +172 -0
  73. package/docs/repo-structure.md +100 -0
  74. package/docs/terminal-kit.md +87 -0
  75. package/docs/tiling-reference.md +224 -0
  76. package/docs/twins.md +138 -0
  77. package/docs/voice-command-protocol.md +278 -0
  78. package/docs/voice-error-model.md +73 -0
  79. package/docs/voice.md +221 -0
  80. package/package.json +69 -16
  81. package/packages/npm/sdk/cua.d.mts +1 -0
  82. package/packages/npm/sdk/cua.d.ts +188 -0
  83. package/packages/npm/sdk/cua.mjs +376 -0
  84. package/app/Lattices.app/Contents/Info.plist +0 -24
  85. package/app/Package.swift +0 -13
  86. package/app/Sources/ActionRow.swift +0 -61
  87. package/app/Sources/App.swift +0 -10
  88. package/app/Sources/AppDelegate.swift +0 -234
  89. package/app/Sources/AppShellView.swift +0 -62
  90. package/app/Sources/AppTypeClassifier.swift +0 -70
  91. package/app/Sources/AppWindowShell.swift +0 -63
  92. package/app/Sources/CheatSheetHUD.swift +0 -332
  93. package/app/Sources/CommandModeState.swift +0 -1362
  94. package/app/Sources/CommandModeView.swift +0 -1405
  95. package/app/Sources/CommandModeWindow.swift +0 -192
  96. package/app/Sources/CommandPaletteView.swift +0 -307
  97. package/app/Sources/CommandPaletteWindow.swift +0 -134
  98. package/app/Sources/DaemonProtocol.swift +0 -101
  99. package/app/Sources/DaemonServer.swift +0 -414
  100. package/app/Sources/DesktopModel.swift +0 -121
  101. package/app/Sources/DesktopModelTypes.swift +0 -71
  102. package/app/Sources/DiagnosticLog.swift +0 -271
  103. package/app/Sources/EventBus.swift +0 -30
  104. package/app/Sources/HotkeyManager.swift +0 -250
  105. package/app/Sources/HotkeyStore.swift +0 -338
  106. package/app/Sources/InventoryManager.swift +0 -35
  107. package/app/Sources/InventoryPath.swift +0 -43
  108. package/app/Sources/KeyRecorderView.swift +0 -210
  109. package/app/Sources/LatticesApi.swift +0 -1125
  110. package/app/Sources/MainView.swift +0 -467
  111. package/app/Sources/MainWindow.swift +0 -83
  112. package/app/Sources/OcrModel.swift +0 -309
  113. package/app/Sources/OcrStore.swift +0 -295
  114. package/app/Sources/OmniSearchState.swift +0 -283
  115. package/app/Sources/OmniSearchView.swift +0 -288
  116. package/app/Sources/OmniSearchWindow.swift +0 -105
  117. package/app/Sources/OrphanRow.swift +0 -129
  118. package/app/Sources/PaletteCommand.swift +0 -419
  119. package/app/Sources/PermissionChecker.swift +0 -125
  120. package/app/Sources/Preferences.swift +0 -92
  121. package/app/Sources/ProcessModel.swift +0 -199
  122. package/app/Sources/ProcessQuery.swift +0 -151
  123. package/app/Sources/Project.swift +0 -28
  124. package/app/Sources/ProjectRow.swift +0 -368
  125. package/app/Sources/ProjectScanner.swift +0 -121
  126. package/app/Sources/ScreenMapState.swift +0 -2387
  127. package/app/Sources/ScreenMapView.swift +0 -2820
  128. package/app/Sources/ScreenMapWindowController.swift +0 -89
  129. package/app/Sources/SessionManager.swift +0 -72
  130. package/app/Sources/SettingsView.swift +0 -1053
  131. package/app/Sources/SettingsWindow.swift +0 -20
  132. package/app/Sources/TabGroupRow.swift +0 -178
  133. package/app/Sources/Terminal.swift +0 -259
  134. package/app/Sources/TerminalQuery.swift +0 -156
  135. package/app/Sources/TerminalSynthesizer.swift +0 -200
  136. package/app/Sources/Theme.swift +0 -163
  137. package/app/Sources/TilePickerView.swift +0 -209
  138. package/app/Sources/TmuxModel.swift +0 -53
  139. package/app/Sources/TmuxQuery.swift +0 -81
  140. package/app/Sources/WindowTiler.swift +0 -1755
  141. package/app/Sources/WorkspaceManager.swift +0 -434
  142. package/bin/lattices-app.js +0 -221
  143. package/bin/lattices.js +0 -1418
@@ -0,0 +1,210 @@
1
+ # Hyper+G in-place grid — snappiness & satisfaction brief
2
+
3
+ Context: `relayoutGroup()` already switched from sequential `RealWindowAnimator.setFrameRobust`
4
+ to `WindowTiler.batchMoveAndRaiseWindows` (SLS freeze + one AX pass/app), with UI refresh
5
+ deferred and a tap on commit. This is the "make it even better" pass.
6
+
7
+ Code anchors:
8
+ - Grid path: `WindowMotionMode.swift:1100 relayoutGroup()` → `:1138 distributeGroup()` (G = keycode 5, `:833`)
9
+ - Batch move: `WindowTiler.swift:1753 batchMoveAndRaiseWindows` (freeze `:1767`, AX enum `:1776`, activate `:1818`, unfreeze `:1825`)
10
+ - Sound: `DiagnosticLog.swift:162 AppFeedback.playTap`
11
+ - Optional anim: `RealWindowAnimator.swift` (Timer 60fps, 0.28s — NOT on the batch path)
12
+ - Chrome flash: `WindowTiler.swift:54 WindowHighlight.flash` (single-window only)
13
+ - No haptics anywhere in the codebase yet → green field.
14
+
15
+ ---
16
+
17
+ ## The governing principle
18
+
19
+ Perceived latency is bound to the **earliest** feedback the brain receives, not the moment
20
+ pixels finish moving. The AX move is 40–120ms of work we can't fully erase. So the play is:
21
+ **acknowledge on key-down (haptic + sound + overlay), move under cover, let the windows catch
22
+ up.** Every quick win below is a variant of "fire feedback before the slow thing finishes."
23
+
24
+ ---
25
+
26
+ ## Quick wins (ship this week, low risk)
27
+
28
+ ### Q1 — Haptic on key-down (biggest bang, ~10 lines)
29
+ There is zero haptic feedback today. `NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)`
30
+ is *exactly* the Loop/Magnet "snap" feel on trackpad/Force Touch. Fire it the instant `G` is
31
+ matched in `keyDown` (`:833`), **before** `distributeGroup()` runs. Costs nothing, lands on the
32
+ keypress, and the windows snapping ~80ms later reads as "instant + tactile."
33
+
34
+ ### Q2 — Decouple + pre-warm the tap sound
35
+ `playTap` does `DispatchQueue.main.async { stop(); play() }`. Three problems:
36
+ - the async hop delays the sound ~1 runloop turn past the keypress,
37
+ - `stop()` then `play()` adds a tiny dead gap,
38
+ - NSSound's *first* play in a session stutters (codec spin-up).
39
+
40
+ Fixes: (a) prime the sound once on motion-mode entry by playing `tap.wav` at volume 0; (b) fire
41
+ it synchronously on key-down (same site as the haptic), not after the move; (c) consider a
42
+ prepared `AVAudioPlayer` (`prepareToPlay()`) or `AudioServicesPlaySystemSound` for sub-frame
43
+ latency. Net: the *thunk* and the *tap* (haptic) coincide with the keypress, not the landing.
44
+
45
+ ### Q3 — Shrink the SLS freeze window
46
+ `batchMoveAndRaiseWindows` does the slow `kAXWindowsAttribute` enumeration **inside** the
47
+ `SLSDisableUpdate` freeze (`:1776`). The screen is frozen while we do AX queries. Resolve the
48
+ AX elements *before* the freeze, freeze only around the set-frame writes, unfreeze immediately.
49
+ Bonus: `relayoutGroup` already resolves each element via `recordOriginal`'s `ax(for: m)` (`:1113`)
50
+ and then the batch path re-resolves the whole window list — pass the resolved elements in and
51
+ skip the second AX pass entirely. Shorter freeze = less black-flash, snappier reveal.
52
+
53
+ ### Q4 — Stop activating every app
54
+ `batchMoveAndRaiseWindows` calls `app.activate()` once per pid (`:1818`). With windows from N
55
+ apps that's N activations → focus churn, Space flicker, and the overlay can lose key. AX
56
+ `kAXRaiseAction` (already issued at `:1806`) reorders without activating. Activate **only** the
57
+ app that should end up frontmost (the last-picked / aimed window), once. Raise the rest.
58
+
59
+ ### Q5 — Overlay "snap-pop" on the selection chrome
60
+ The real move is a teleport, so add the *sense* of motion in the cheap overlay layer (no AX
61
+ cost). When the grid lands, give each selection border a tiny scale overshoot (1.0→1.04→1.0,
62
+ ~120ms, Core Animation) + a one-shot green edge flash converging on the slot. This is the
63
+ Arc/Raycast "it clicked into place" pop. Overlay-only, runs off the AX path, can't add input
64
+ latency.
65
+
66
+ ---
67
+
68
+ ## Bigger bets (more design, higher ceiling)
69
+
70
+ ### B1 — Ghost-slot anticipation (kills perceived latency outright)
71
+ On `G`, **instantly** paint the target grid as outlined ghost slots (you already compute
72
+ `balancedGrid` rects → `tileFrame`). Animate the slots filling (or the picked windows' chrome
73
+ sliding toward their slots) over ~140ms while the real `batchMove` runs underneath. The user
74
+ sees motion begin on the same frame as the keypress; the real windows arrive under the
75
+ animation. This fully decouples felt-speed from AX latency and is the single highest-ceiling
76
+ change. The ghost overlay is also the natural home for Q5's pop.
77
+
78
+ ### B2 — Precompute on selection, commit = pure writes
79
+ Every pluck/unpluck currently recomputes `balancedGrid` + `tileFrame` and re-resolves AX. Cache
80
+ (a) the AX element per picked wid and (b) the target frame map, updated incrementally as the
81
+ selection changes. By the time `G` fires, commit is nothing but the frozen set-frame loop —
82
+ nothing to compute, nothing to resolve, minimal freeze.
83
+
84
+ ### B3 — Staggered cascade fill (the "deliberate fast" feel)
85
+ Real windows must move simultaneously (per-window AX animation = jank — see "avoid"). But the
86
+ *overlay* tiles/borders can land on a micro-stagger in reading order (~10–14ms/cell). The eye
87
+ reads a fast left-to-right cascade as more intentional and premium than a flat simultaneous
88
+ snap, while the actual work stays a single batch. Pure overlay timing.
89
+
90
+ ---
91
+
92
+ ## What to avoid (these ADD latency or jank)
93
+
94
+ - **Don't animate many real windows via per-tick AX writes.** `RealWindowAnimator`'s 0.28s
95
+ Timer loop is fine for one window; across a group it's 60fps × N AX position+size writes —
96
+ AX is slow and serializes, so it stutters and feels *slower* than a clean teleport. Keep the
97
+ batch teleport; animate the overlay, not the windows.
98
+ - **Don't do AX enumeration / `DesktopModel.poll()` inside the freeze or on the commit path.**
99
+ Poll/rebuild are already deferred (`refreshAfterGridMove`, `:1125`) — keep it that way; don't
100
+ let new chrome work creep back onto the hot path.
101
+ - **Don't async-dispatch or `stop()`-then-`play()` the commit sound** (see Q2) — both push the
102
+ thunk past the keypress.
103
+ - **Don't `app.activate()` per app** (Q4) — multi-app activation is the main source of flicker
104
+ and overlay focus loss.
105
+ - **Avoid Timer-driven overlay animation.** Use Core Animation / `CADisplayLink`; `Timer` at
106
+ 1/60 drifts and can hitch under load.
107
+ - **Don't add dwell.** `WindowHighlight.flash` defaults to a 0.9s dwell + 0.3s fade — fine for a
108
+ one-shot locate, wrong for a snap pop. Snap feedback wants ~120–180ms total. Long fades read
109
+ as lag, not polish.
110
+
111
+ ---
112
+
113
+ ## Recommended ship order
114
+
115
+ 1. **Q1 + Q2** together — one small change at the `G` key site: haptic + synchronous pre-warmed
116
+ sound on key-down. Instant tactile upgrade, ~20 lines, no risk.
117
+ 2. **Q3 + Q4** — tighten `batchMoveAndRaiseWindows` (pre-resolve AX, shorter freeze, single
118
+ activate). Real latency reduction.
119
+ 3. **Q5** — overlay snap-pop on the chrome. First "ooh" moment.
120
+ 4. **B1** — ghost-slot anticipation once Q5's overlay exists to build on. This is the headliner.
121
+
122
+ ---
123
+
124
+ ## Implementation sketches
125
+
126
+ ### Sketch 1 — Haptic + tactile commit on key-down (Q1+Q2)
127
+ At `WindowMotionMode.swift:833`, before dispatching the grid:
128
+ ```swift
129
+ case 5: // G
130
+ if exposed { gatherInPlace() }
131
+ else {
132
+ AppFeedback.shared.commitTactile() // haptic + sound, synchronous, NOW
133
+ distributeGroup() // remove the playTapSound() from relayoutGroup
134
+ }
135
+ return
136
+ ```
137
+ New in `AppFeedback`:
138
+ ```swift
139
+ func warmUp() { // call on motion-mode entry
140
+ tapSound?.volume = 0; tapSound?.play(); tapSound?.stop(); tapSound?.volume = 1
141
+ }
142
+ func commitTactile() { // called on the keypress, on main
143
+ NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
144
+ tapSound?.currentTime = 0
145
+ tapSound?.play() // no async hop, no stop() gap
146
+ }
147
+ ```
148
+ Remove `AppFeedback.shared.playTapSound()` from `relayoutGroup()` (`:1120`) so the feedback is
149
+ owned by the keypress, not the move completion.
150
+
151
+ ### Sketch 2 — Pre-resolved, minimal-freeze batch (Q3+Q4)
152
+ New overload that trusts caller-resolved elements and freezes only the writes:
153
+ ```swift
154
+ static func batchSetFrames(_ moves: [(el: AXUIElement, pid: Int32, frame: CGRect, raise: Bool)],
155
+ frontmostPid: Int32?) {
156
+ // app -> enhanced-UI off, BEFORE freeze
157
+ let pids = Set(moves.map(\.pid))
158
+ let appRefs = Dictionary(uniqueKeysWithValues: pids.map { ($0, AXUIElementCreateApplication($0)) })
159
+ appRefs.values.forEach { AXUIElementSetAttributeValue($0, "AXEnhancedUserInterface" as CFString, false as CFTypeRef) }
160
+
161
+ let cid = _SLSMainConnectionID?()
162
+ if let cid { _ = _SLSDisableUpdate?(cid) } // freeze ONLY the writes
163
+ for m in moves {
164
+ setFrameTriplet(m.el, m.frame) // size→pos→size
165
+ if m.raise { AXUIElementPerformAction(m.el, kAXRaiseAction as CFString) }
166
+ }
167
+ if let cid { _ = _SLSReenableUpdate?(cid) } // unfreeze immediately
168
+
169
+ appRefs.values.forEach { AXUIElementSetAttributeValue($0, "AXEnhancedUserInterface" as CFString, true as CFTypeRef) }
170
+ if let frontmostPid { NSRunningApplication(processIdentifier: frontmostPid)?.activate() } // ONE activate
171
+ }
172
+ ```
173
+ `relayoutGroup` already has `el` in hand from `recordOriginal` — collect it into `moves` and
174
+ call this; no second `kAXWindows` enumeration, freeze shrinks to just the set-frame loop.
175
+
176
+ ### Sketch 3 — Overlay snap-pop on selection chrome (Q5)
177
+ On grid landing, per slot, run a layer animation on the existing selection border view (overlay,
178
+ not the window):
179
+ ```swift
180
+ let pop = CAKeyframeAnimation(keyPath: "transform.scale")
181
+ pop.values = [1.0, 1.045, 1.0]; pop.keyTimes = [0, 0.45, 1]
182
+ pop.duration = 0.13; pop.timingFunction = CAMediaTimingFunction(name: .easeOut)
183
+ borderLayer.add(pop, forKey: "snapPop")
184
+ // + a one-shot edge tint that fades over the same 0.13s
185
+ ```
186
+ No dwell, no AX. Fire it from `refreshAfterGridMove` (`:1125`) so it rides the deferred turn.
187
+
188
+ ### Sketch 4 — Ghost-slot anticipation (B1)
189
+ On `G`, before the move, draw target slots in the motion overlay and animate the picked windows'
190
+ *chrome* (or ghost rects) from current → slot, while `batchSetFrames` runs underneath:
191
+ ```swift
192
+ func previewGrid(_ rects: [CGRect]) { // rects already from balancedGrid → tileFrame
193
+ for (i, r) in rects.enumerated() {
194
+ let slot = ghostLayer(at: currentChromeFrame(i))
195
+ slot.frame = currentChromeFrame(i)
196
+ CATransaction.begin()
197
+ CATransaction.setAnimationDuration(0.14)
198
+ slot.frame = r // slides to target as the real window teleports under it
199
+ CATransaction.commit()
200
+ }
201
+ }
202
+ ```
203
+ Sequence: keypress → `commitTactile()` (Sketch 1) + `previewGrid()` same frame → `batchSetFrames()`
204
+ → ghosts fade as real windows land → `refreshAfterGridMove`. Felt latency ≈ 0; the move hides
205
+ under 140ms of overlay motion.
206
+
207
+ ### Sketch 5 — Staggered cascade (B3, optional flourish)
208
+ In `previewGrid`/snap-pop, offset each cell's animation `beginTime` by `index * 0.012` in
209
+ row-major order. Overlay-only; the real batch stays simultaneous. Toggle behind a setting if you
210
+ want a "calm" vs "playful" feel.
package/docs/layers.md CHANGED
@@ -28,13 +28,13 @@ Add `groups` to `~/.lattices/workspace.json`:
28
28
  "name": "my-setup",
29
29
  "groups": [
30
30
  {
31
- "id": "talkie",
32
- "label": "Talkie",
31
+ "id": "vox",
32
+ "label": "Vox",
33
33
  "tabs": [
34
- { "path": "/Users/you/dev/talkie-ios", "label": "iOS" },
35
- { "path": "/Users/you/dev/talkie-macos", "label": "macOS" },
36
- { "path": "/Users/you/dev/talkie-web", "label": "Website" },
37
- { "path": "/Users/you/dev/talkie-api", "label": "API" }
34
+ { "path": "/Users/you/dev/vox-ios", "label": "iOS" },
35
+ { "path": "/Users/you/dev/vox-macos", "label": "macOS" },
36
+ { "path": "/Users/you/dev/vox-web", "label": "Website" },
37
+ { "path": "/Users/you/dev/vox-api", "label": "API" }
38
38
  ]
39
39
  }
40
40
  ]
@@ -46,10 +46,10 @@ to per-project configs.
46
46
 
47
47
  ### How it works
48
48
 
49
- - Session name follows the pattern `lattices-group-<id>` (e.g. `lattices-group-talkie`)
49
+ - Session name follows the pattern `lattices-group-<id>` (e.g. `lattices-group-vox`)
50
50
  - 1 group = 1 tmux session. Each tab is a tmux window, and each window
51
51
  gets its own panes from that project's `.lattices.json`
52
- - You can still launch projects independently: `cd talkie-ios && lattices`
52
+ - You can still launch projects independently: `cd vox-ios && lattices start`
53
53
  creates its own standalone session as before
54
54
 
55
55
  ### Tab group fields
@@ -73,9 +73,9 @@ lattices tab <group> [tab] # Switch tab by label or index
73
73
  Examples:
74
74
 
75
75
  ```bash
76
- lattices group talkie # Launch all Talkie tabs
77
- lattices tab talkie iOS # Switch to the iOS tab
78
- lattices tab talkie 0 # Switch to first tab (by index)
76
+ lattices group vox # Launch all Vox tabs
77
+ lattices tab vox iOS # Switch to the iOS tab
78
+ lattices tab vox 0 # Switch to first tab (by index)
79
79
  ```
80
80
 
81
81
  ### Menu bar app
@@ -136,6 +136,42 @@ Add `layers` to `~/.lattices/workspace.json`:
136
136
  }
137
137
  ```
138
138
 
139
+ ### App windows in layers
140
+
141
+ Layers aren't limited to terminal sessions. You can include any
142
+ application window by using the `app`, `title`, `url`, and `launch`
143
+ fields instead of `path`:
144
+
145
+ ```json
146
+ {
147
+ "name": "hudson",
148
+ "layers": [
149
+ {
150
+ "id": "main",
151
+ "label": "Main",
152
+ "projects": [
153
+ { "app": "Google Chrome", "title": "GitHub", "tile": "left" },
154
+ { "app": "Vox", "tile": "top-right", "launch": "open -a Vox" },
155
+ { "path": "/Users/you/dev/frontend", "tile": "bottom-right" }
156
+ ]
157
+ },
158
+ {
159
+ "id": "docs",
160
+ "label": "Docs",
161
+ "projects": [
162
+ { "app": "Google Chrome", "url": "https://docs.example.com", "tile": "left" },
163
+ { "app": "Notes", "title": "Sprint Notes", "tile": "right" }
164
+ ]
165
+ }
166
+ ]
167
+ }
168
+ ```
169
+
170
+ When switching to a layer, lattices matches windows by `app` name and
171
+ optionally filters by `title` substring or `url` prefix. If `launch`
172
+ is provided and no matching window is found, the command is executed
173
+ to open the app.
174
+
139
175
  ### Using groups in layers
140
176
 
141
177
  Layer projects can reference a tab group instead of a single path.
@@ -146,11 +182,11 @@ This lets you tile a whole group into a screen position:
146
182
  "name": "my-setup",
147
183
  "groups": [
148
184
  {
149
- "id": "talkie",
150
- "label": "Talkie",
185
+ "id": "vox",
186
+ "label": "Vox",
151
187
  "tabs": [
152
- { "path": "/Users/you/dev/talkie-ios", "label": "iOS" },
153
- { "path": "/Users/you/dev/talkie-web", "label": "Website" }
188
+ { "path": "/Users/you/dev/vox-ios", "label": "iOS" },
189
+ { "path": "/Users/you/dev/vox-web", "label": "Website" }
154
190
  ]
155
191
  }
156
192
  ],
@@ -159,7 +195,7 @@ This lets you tile a whole group into a screen position:
159
195
  "id": "main",
160
196
  "label": "Main",
161
197
  "projects": [
162
- { "group": "talkie", "tile": "top-left" },
198
+ { "group": "vox", "tile": "top-left" },
163
199
  { "path": "/Users/you/dev/design-system", "tile": "right" }
164
200
  ]
165
201
  }
@@ -168,7 +204,7 @@ This lets you tile a whole group into a screen position:
168
204
  ```
169
205
 
170
206
  When switching to this layer, lattices launches (or focuses) the
171
- "talkie" group session and tiles it to the top-left quarter, alongside
207
+ "vox" group session and tiles it to the top-left quarter, alongside
172
208
  the design-system project on the right.
173
209
 
174
210
  ### Layer fields
@@ -182,9 +218,13 @@ the design-system project on the right.
182
218
  | `layers[].projects` | array | Projects in this layer |
183
219
  | `projects[].path` | string? | Absolute path to project directory |
184
220
  | `projects[].group`| string? | Group ID (alternative to `path`) |
221
+ | `projects[].app` | string? | Application name (for non-terminal windows) |
222
+ | `projects[].title`| string? | Window title substring to match |
223
+ | `projects[].url` | string? | URL prefix to match (browser windows) |
224
+ | `projects[].launch`| string? | Shell command to launch the app if not found |
185
225
  | `projects[].tile` | string? | Tile position (optional, see below) |
186
226
 
187
- Each project entry must have either `path` or `group`, not both.
227
+ Each project entry must have either `path`, `group`, or `app` pick one.
188
228
 
189
229
  ### Tile values
190
230
 
@@ -195,29 +235,68 @@ works: `left`, `right`, `top`, `bottom`, `top-left`, `top-right`,
195
235
 
196
236
  ### Switching layers
197
237
 
198
- Three ways to switch:
238
+ Four ways to switch:
199
239
 
200
240
  | Method | How |
201
241
  |----------------------|------------------------------------------|
202
242
  | **Hotkey** | Cmd+Option+1, Cmd+Option+2, Cmd+Option+3... |
203
243
  | **Layer bar** | Click a layer pill in the menu bar panel |
204
244
  | **Command palette** | Search "Switch to Layer" in Cmd+Shift+M |
245
+ | **CLI** | `lattices layer <name\|index>` |
205
246
 
206
247
  When you switch to a layer:
207
248
 
208
- 1. Each project's terminal window is **raised and focused**
209
- 2. If a project isn't running yet, it gets **launched** automatically
210
- 3. Windows with a `tile` value are **tiled** to that position
211
- 4. The previous layer's windows stay open behind the new ones
249
+ 1. Each project's window is **raised and focused**
250
+ 2. App windows are matched by `app` / `title` / `url`
251
+ 3. If a project isn't running yet, it gets **launched** automatically
252
+ 4. Windows with a `tile` value are **tiled** to that position
253
+ 5. The previous layer's windows stay open behind the new ones
212
254
 
213
255
  The app remembers which layer was last active across restarts.
214
256
 
257
+ ### Named layer switching
258
+
259
+ You can switch layers by name from the CLI:
260
+
261
+ ```bash
262
+ lattices layer hudson # Switch to the layer named "hudson"
263
+ lattices layer 0 # Switch to the first layer (by index)
264
+ ```
265
+
266
+ This is useful for scripting — you don't need to know the index,
267
+ just the layer's `id` or `label`.
268
+
269
+ ### Window tagging
270
+
271
+ You can manually assign any window to a layer, even if it's not
272
+ declared in `workspace.json`. This is useful for ad-hoc windows
273
+ that you want to move with a layer:
274
+
275
+ ```bash
276
+ lattices window assign <wid> <layer> # Tag a window to a layer
277
+ lattices window map # Show all window→layer assignments
278
+ ```
279
+
280
+ Tagged windows behave like declared ones — they're raised and tiled
281
+ when their layer activates. Remove a tag by reassigning or with:
282
+
283
+ ```bash
284
+ # Via the agent API
285
+ await daemonCall('window.removeLayer', { wid: 1234 })
286
+ ```
287
+
288
+ ### Layer bezel
289
+
290
+ When you switch layers via hotkey, a translucent HUD pill appears
291
+ briefly at the top of the screen showing the new layer's name.
292
+ This provides instant visual feedback without interrupting your flow.
293
+
215
294
  ### Programmatic switching
216
295
 
217
- Agents and scripts can switch layers via the daemon API:
296
+ Agents and scripts can switch layers via the agent API:
218
297
 
219
298
  ```js
220
- import { daemonCall } from '@arach/lattices/daemon-client'
299
+ import { daemonCall } from '@lattices/cli'
221
300
 
222
301
  // List available layers
223
302
  const { layers, active } = await daemonCall('layers.list')
@@ -225,13 +304,69 @@ console.log(`Active: ${layers[active].label}`)
225
304
 
226
305
  // Switch to a layer by index
227
306
  await daemonCall('layer.switch', { index: 0 })
307
+
308
+ // Switch to a layer by name
309
+ await daemonCall('layer.switch', { name: 'hudson' })
228
310
  ```
229
311
 
230
312
  The `layer.switch` call focuses and tiles all windows in the target
231
313
  layer, just like the hotkey or command palette. A `layer.switched`
232
314
  event is broadcast to all connected clients.
233
315
 
234
- More methods in the [Daemon API reference](/docs/api).
316
+ More methods in the [Agent API reference](/docs/api).
317
+
318
+ ## Rule-backed Studio layers
319
+
320
+ Studio layers are live window rules stored in `~/.lattices/layers.json`.
321
+ They are separate from `workspace.json` launch-and-tile layers: Studio
322
+ layers do not launch projects. They resolve matching desktop windows,
323
+ then recall or scope those windows in Studio and Screen Map.
324
+
325
+ Each layer has a `match` array. A window joins the layer when it matches
326
+ any clause in that array. Inside one clause, every present positive field
327
+ must match, and every clause in `not` must fail.
328
+
329
+ ```json
330
+ [
331
+ {
332
+ "id": "review",
333
+ "name": "Review",
334
+ "match": [
335
+ {
336
+ "appEquals": "Google Chrome",
337
+ "titleRegex": "(GitHub|Pull Request)",
338
+ "not": [
339
+ { "titleContains": "Actions" }
340
+ ]
341
+ },
342
+ {
343
+ "sessionContains": "lattices",
344
+ "isOnScreen": true
345
+ }
346
+ ]
347
+ }
348
+ ]
349
+ ```
350
+
351
+ Supported clause fields:
352
+
353
+ | Field | Match |
354
+ |-------|-------|
355
+ | `app` | App name contains this string |
356
+ | `appEquals` | App name exactly equals this string |
357
+ | `appRegex` | App name matches this regular expression |
358
+ | `titleContains` | Window title contains this string |
359
+ | `titleEquals` | Window title exactly equals this string |
360
+ | `titleRegex` | Window title matches this regular expression |
361
+ | `session` | Parsed lattices tmux session exactly equals this string |
362
+ | `sessionContains` | Parsed lattices tmux session contains this string |
363
+ | `isOnScreen` | Window is, or is not, visible on the current Space |
364
+ | `spaceId` | Window belongs to this macOS Space id |
365
+ | `not` | Exclusion clauses; any match rejects the window |
366
+
367
+ `app` and `titleContains` are the original substring fields, so older
368
+ `layers.json` files continue to work. New layers created from plucked
369
+ windows use `appEquals` by default to avoid accidental substring matches.
235
370
 
236
371
  ### Layer bar
237
372
 
@@ -258,7 +393,7 @@ header and search field in the menu bar panel:
258
393
  ```json
259
394
  {
260
395
  "projects": [
261
- { "path": "/Users/you/dev/talkie" }
396
+ { "path": "/Users/you/dev/vox" }
262
397
  ]
263
398
  }
264
399
  ```
@@ -276,12 +411,23 @@ No `tile` — just focuses the window wherever it is.
276
411
  }
277
412
  ```
278
413
 
414
+ ### Mixed: apps + terminals
415
+
416
+ ```json
417
+ {
418
+ "projects": [
419
+ { "app": "Google Chrome", "title": "GitHub", "tile": "left" },
420
+ { "path": "/Users/you/dev/api", "tile": "right" }
421
+ ]
422
+ }
423
+ ```
424
+
279
425
  ### Group + project
280
426
 
281
427
  ```json
282
428
  {
283
429
  "projects": [
284
- { "group": "talkie", "tile": "left" },
430
+ { "group": "vox", "tile": "left" },
285
431
  { "path": "/Users/you/dev/api", "tile": "right" }
286
432
  ]
287
433
  }
@@ -305,6 +451,8 @@ No `tile` — just focuses the window wherever it is.
305
451
  - Projects don't need a `.lattices.json` config to be in a layer — any
306
452
  directory path works. If the project has a config, lattices uses it; if
307
453
  not, it opens a plain terminal in that directory.
454
+ - App windows don't need any config at all — just specify `app` and
455
+ optionally `title` or `url` to match the right window.
308
456
  - You can have up to 9 layers (Cmd+Option+1 through Cmd+Option+9).
309
457
  - Edit `workspace.json` by hand — the app re-reads it on launch. Use
310
458
  the Refresh Projects button or restart the app to pick up changes.