@vibelord/lanes 1.0.4 → 1.1.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 (38) hide show
  1. package/README.md +52 -15
  2. package/bin/lanes-install.mjs +8 -2
  3. package/lib/plan.mjs +34 -1
  4. package/package.json +2 -2
  5. package/payload/lanes/README.md +41 -18
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +994 -49
  8. package/payload/lanes/client/lanes-launch +160 -14
  9. package/payload/lanes/client/ssh.stanza.in +11 -2
  10. package/payload/lanes/docs/ARCHITECTURE.md +93 -4
  11. package/payload/lanes/docs/INSTALL.md +296 -43
  12. package/payload/lanes/docs/RUNBOOK.md +506 -39
  13. package/payload/lanes/docs/USERGUIDE.md +465 -81
  14. package/payload/lanes/install/enable-phone.sh +8 -20
  15. package/payload/lanes/install/enable-web.sh +2 -2
  16. package/payload/lanes/install/host-install.sh +6 -0
  17. package/payload/lanes/install.sh +888 -52
  18. package/payload/lanes/libexec/enrol.py +695 -0
  19. package/payload/lanes/libexec/enrold.py +1264 -0
  20. package/payload/lanes/libexec/keys.py +644 -0
  21. package/payload/lanes/libexec/lanes_lib.py +14 -6
  22. package/payload/lanes/libexec/members.py +297 -0
  23. package/payload/lanes/libexec/notify.py +146 -21
  24. package/payload/lanes/libexec/qr.py +217 -0
  25. package/payload/lanes/libexec/site_edit.py +237 -0
  26. package/payload/lanes/libexec/web_tokens.py +315 -0
  27. package/payload/lanes/site/README.md +13 -1
  28. package/payload/lanes/site/beyondwork/site.conf +3 -0
  29. package/payload/lanes/site/site.example.conf +5 -0
  30. package/payload/lanes/sudo/one-time-root.sh +1 -1
  31. package/payload/lanes/systemd/lanes-enrold.service +22 -0
  32. package/payload/lanes/systemd/lanes-notify.service +4 -4
  33. package/payload/lanes/systemd/lanes-web.service +4 -0
  34. package/payload/lanes/web/Caddyfile +10 -0
  35. package/payload/lanes/web/README.md +151 -22
  36. package/payload/lanes/web/mobile.js +95 -55
  37. package/payload/lanes/workspaces.example/README.md +6 -1
  38. package/payload/manifest.json +7 -4
@@ -18,8 +18,8 @@ undoes the client's per-load single-pane start once
18
18
  view's sidebar shows beside its lanes. `tests/web/activation.test.mjs` (`node --test`) pins the activation matrix,
19
19
  the picker model and that render preference; `tests/unit/test_web_view.py` pins the size budgets and runs that
20
20
  suite when node is installed. After an upgrade,
21
- check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture`, `__zjSyncInactiveCursorStyle` and `window.term`
22
- still exist in the served `app.js`.
21
+ check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture` (with `sendFn` = `websockets.sendAnsiKey`),
22
+ `__zjSyncInactiveCursorStyle` and `window.term` still exist in the served `app.js`.
23
23
 
24
24
  The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
25
25
  Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
@@ -30,15 +30,25 @@ The guard is installed before Zellij's first terminal or login modal: otherwise
30
30
  touchstart, touchend, click and server `SetSoftKeyboard` messages. The **Type here** field keeps the original input
31
31
  and its event handlers inside the closed shadow root (including its protection from password-manager extensions) and
32
32
  makes it a real, tappable field instead of a 1 px invisible focus target; keystrokes appear in the terminal and its
33
- internal padding/history stays invisible. Upstream cancels `pointerdown` on the extra keys, which also suppresses
33
+ internal padding/history stays invisible. Upstream creates that input as `type="password" autocomplete="new-password"`
34
+ (to switch off prediction); shown to the user, that gave iOS a secure-entry keyboard and the strong-password bar
35
+ (DEF-1734), so the shell makes it `type="text" autocomplete="off" autocapitalize="none"` and keeps upstream's
36
+ `autocorrect="off"`, `spellcheck="false"`, `inputmode="text"` and the `data-*ignore` attributes. Upstream cancels
37
+ `pointerdown` on the extra keys, which also suppresses
34
38
  their touch click in Safari, so the shell cancels `mousedown` instead: typing focus is kept while the native click
35
39
  still activates CTRL, the arrows and the other keys. The shell attaches only once server state has arrived — resizing
36
40
  before the control socket opens makes 0.45.1 throw `InvalidStateError` and leaves the server with the old size.
37
41
 
38
42
  `lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
39
43
  It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
40
- the original Zellij service stays on 8082. Caddy must already be installed on the host.
41
- Run `sh install/enable-phone.sh --host lanes` from an installed client, or `--local` on the server.
44
+ the original Zellij service stays on 8082. Caddy (2.5 or later) must already be installed on the host. `/enrol/*` is
45
+ the one route that is neither these assets nor Zellij: `handle_path` strips `/enrol` and forwards to the enrolment
46
+ service on 127.0.0.1:8184 (`lanes-enrold.service`, [runbook](../docs/RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3));
47
+ it must stay before the Zellij routes, and its `trusted_proxies 127.0.0.0/8` keeps the `X-Forwarded-For` Tailscale
48
+ Serve sets (the client's tailnet address), so the redeem rate limit counts per device.
49
+ Run `lanes web <device> --token` on the server (it also adds that Tailscale Serve mapping when it is missing),
50
+ `lanes-launch web phone|laptop` from an installed client, or `sh install/enable-phone.sh --host lanes` (`--local` on
51
+ the server).
42
52
  The [installation guide](../docs/INSTALL.md) covers Caddy, Tailscale operator permission and the selected SSH key.
43
53
  Service registration supplies `LANES_REPO` so Caddy serves the actual checkout from any installation path.
44
54
 
@@ -54,9 +64,94 @@ is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px).
54
64
  `tests/mobile_view.py` verifies the touch-to-lane path with isolated holders; sidebar unit tests cover the
55
65
  complete touch target.
56
66
 
57
- Every byte the shell sends to a terminal goes through `__zjSendControl({type: 'Write', bytes})`
58
- (`sendToTerminal`); there is no focus-and-type fallback, since the focus guard above would make it type nowhere.
59
- The lane filter bar sends `Esc /` and the query as UTF-8 (`TextEncoder`), dropping control bytes and DEL.
67
+ ### Input, keys and clipboard (phone shell input v2, CAP-142)
68
+
69
+ Every byte the shell sends to a terminal goes through `send()` to `__zjSoftKbdCapture.sendFn`, which is upstream's
70
+ `websockets.sendAnsiKey`: a text frame on the terminal WebSocket, the path the client's own capture input, ESC/arrow
71
+ bar and xterm `onData` use (an armed CTRL/ALT on the modifier bar therefore applies to a one-character key, through
72
+ upstream's `__zjMobileMergeKey`). Until 2026-10-07 the shell posted `__zjSendControl({type: 'Write', bytes})` on the
73
+ control socket, whose message enum has no `Write` variant: the server logged "Ignoring unknown control message type"
74
+ and dropped every quick key and filter keystroke (DEF-1733). The quick keys send `\r`, `y`, `n`, `\x1b`, `\x03`,
75
+ `\x1b[A`, `\x1b[B` — upstream's own arrow bytes (CSI, never SS3; Zellij re-encodes a key for its pane). With no
76
+ `sendFn` the text is queued and sent by the next `refresh()` (no timer); the pinned client installs `sendFn` together
77
+ with the capture element the shell waits for, so the queue only covers an upstream reordering. A key the socket
78
+ refuses (it throws while not open) is dropped, never replayed later into whatever lane is focused then. There is no
79
+ focus-and-type fallback: the focus guard above would make it type nowhere (DEF-1529).
80
+
81
+ The lane filter bar never puts an Esc and other bytes in one frame. The server parses each text frame with termwiz,
82
+ which holds a leading ESC while more bytes follow: `\x1b/abc` became one Alt-`/` key whose raw bytes are the whole
83
+ frame, then `a`, `b`, `c` again as keys of their own, so the picker sidebar got `\x1b/abc` + `abc` and filtered on
84
+ `abcabc` — on every keystroke, since the one-frame filter re-sent the whole query each time (DEF-1751, INFERRED from
85
+ the independent review's reading of the Zellij 0.45.1 source; `tests/web/phone.test.mjs` models it). The filter now
86
+ sends only what changed. When the field goes from empty to text: a lone `Esc` frame (it leaves whatever the sidebar is
87
+ in — an open filter, a rename, a close prompt, the key sheet), then, no sooner than 60 ms later, `/` alone and the
88
+ text. After that, one DEL (`\x7f`, which the sidebar's filter takes as backspace, as it does `\x08`) for each
89
+ character of the old query after its common prefix with the new one, then the new query's characters after that
90
+ prefix; an emptied field or the Esc key sends a lone `Esc`. The text is the `TextEncoder` bytes with control bytes and
91
+ DEL dropped, decoded back to text. There is no `setTimeout`: `send()` keeps a pause marker in its queue after a lone
92
+ `Esc` and re-arms the one interval, whose next tick sends what follows once 60 ms have passed. The phone keeps the
93
+ last query it sent (`laneFilterPrev`); on any tab other than a picker it forgets it, empties the field and drops a
94
+ filter tail still waiting in the queue, without sending anything — the lane now has the focus, and an `Esc` there
95
+ can interrupt an agent (DEF-1752; the sidebar clears its own filter when it opens a lane). Limits: the deltas assume the
96
+ sidebar holds what the phone sent, which each new query's leading `Esc` restores — another client typing into the same
97
+ picker pane, or a character the sidebar does not print (a no-break space, a zero-width joiner), puts them out of step
98
+ until the field is cleared; and an `Esc` while the sidebar's upload strip is armed (`U`) returns the focus to the
99
+ lane, so the `/` and text after it could reach the lane unless the tab change arrives within the pause.
100
+
101
+ Typing in **Type here**: one character at a time flows through upstream's `input` handler unchanged (a diff of the
102
+ field against its no-break-space padding, with an 8 ms duplicate filter for the two `deleteContentBackward` events one
103
+ backspace fires). A text field brings back QuickType, whose multi-character inserts would lose a repeated letter to
104
+ that filter (`hello` → `helo`). The shell therefore cancels two cancelable `beforeinput` types and sends their text
105
+ whole: `insertText` longer than one character, and `insertReplacementText` of any length. The text is `data`, or
106
+ `dataTransfer.getData('text')` where an engine leaves `data` null. A replacement first sends one DEL per character it
107
+ replaces: the length of `getTargetRanges()[0]` (its `endOffset − startOffset` in the field) — iOS accepts a QuickType
108
+ suggestion over a partial word with a caret, not a selection, and names the replaced range there — else the field's
109
+ selection, else nothing (DEF-1753; before, only the selection was read, so a caret replacement sent `hehello`). Every
110
+ other type stays upstream's or another handler's: a composition (`insertCompositionText`, which the Input Events spec
111
+ makes uncancelable, and `insertFromComposition`) is upstream's diff, a paste is the `paste` handler's, a delete is a
112
+ key. Caveat, unverified on a device: an engine that names neither the range nor a selection gives the terminal the
113
+ partial word and then the suggestion (`hehello`); delete by hand or type without the suggestion bar.
114
+
115
+ Paste: a `paste` on the field is cancelled and its `text/plain` sent as ONE bracketed paste — `\x1b[200~`, the text
116
+ with `\r?\n` as `\r` and every end marker neutralised, `\x1b[201~` — so upstream's per-character path, which collapsed
117
+ `ll` to `l` and stripped the newlines (DEF-1735), never sees it. Only the end marker is touched: each run of ESCs
118
+ directly before `[201~` is removed (a run, so `\x1b\x1b[201~` or `\x1b[20\x1b[201~1~` cannot reassemble one), so the
119
+ clipboard cannot end the bracket early and run the rest as typed keys, while a deliberate ANSI paste keeps its other
120
+ escape sequences (until DEF-1755 every ESC was removed). On a picker tab there is no paste: the picker sidebar turns
121
+ on bracketed paste to take file drops and reads any paste as one (text says `that was not a file drop`; a pasted path
122
+ becomes an upload attempt), so the header's **Paste** is hidden there and a paste into **Type here** is cancelled,
123
+ sends nothing and says `Open a lane to paste` (DEF-1754). Explicit markers rather than xterm's `term.paste` (also
124
+ reachable, as `window.term`): `term.paste` brackets only when the server has turned on xterm's bracketed-paste mode,
125
+ which nothing in this tree shows; Zellij reads the markers as a paste and removes them for a pane that has not asked
126
+ for bracketed paste.
127
+
128
+ **Copy** and **Paste** sit in the header beside **More** (nine 44 px quick keys do not fit a 390 px phone, so the row
129
+ keeps its seven). Copy writes the terminal's selection when xterm has one, else the visible rows of
130
+ `window.term.buffer.active` (`translateToString(true)` per row, trailing blank rows dropped), with
131
+ `navigator.clipboard.writeText` called inside the tap: Safari refuses a write outside a user gesture, which is why the
132
+ client's clipboard add-on (OSC 52) and copy-on-select never reached the phone — upstream cancels `touchstart`, a drag
133
+ scrolls and a long press is a right click (DEF-1736). The buffer is the only text source: `initTerminal` loads xterm's
134
+ WebGL renderer (`addon-webgl`), so the DOM holds no row text. The copy is the whole visible screen, Zellij's frames
135
+ and bars included. Paste calls `navigator.clipboard.readText()` inside the tap (iOS shows its own Paste confirmation)
136
+ and sends the text through the bracketed path above; it is hidden on a picker tab. Both cancel `mousedown`, so an open
137
+ keyboard stays open. The header subtitle says `Copied`, `Pasted` or `Clipboard blocked` (no clipboard API, or a
138
+ refusal).
139
+
140
+ Hints (`Hold to send` and the clipboard results) hold the header subtitle for eight ticks of the one 200 ms interval:
141
+ `hint()` re-arms it, `tick()` counts down, restores the subtitle through `refresh()` and clears the interval again,
142
+ and `refresh()` leaves the subtitle alone while a hint is up (before, the next `MobileState` refresh overwrote it at
143
+ once, DEF-1739). **More** toggles: upstream's capture-phase `pointerdown` hook closes an open menu before the button's
144
+ click, which then reopened it (DEF-1738), so a window capture-phase `pointerdown` listener records whether the menu was
145
+ open and the click then leaves it closed. On a picker tab the dock shows the filter bar and hides **Panes** (a picker
146
+ tab has one pane, and upstream's pane list duplicates the picker): four items for the four grid columns, where five
147
+ wrapped **Type here** below the fixed-height dock (DEF-1737).
148
+
149
+ The pinned `app.js` facts this section relies on (`sendFn` = `sendAnsiKey`, the capture input's attributes and
150
+ handlers, the menu dismiss hook, `window.term`, the WebGL addon) were read on 2026-10-07 from the asset embedded in
151
+ the zellij 0.45.1 release binary: the bytes between the `app.js` and `auth.js` names in its asset table hash to this
152
+ `index.html`'s SRI pin (sha384 `zEYVY/2Dhk…`). `tests/web/phone.test.mjs` runs `mobile.js` against a fake page shaped
153
+ on those facts (`node --test`, no jsdom).
154
+
60
155
  Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
61
156
  `parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
62
157
  `data-count` and the workspace cards, inside the existing `refresh()` / `renderCards()` — no fetch, file or timer.
@@ -69,8 +164,10 @@ phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-
69
164
  `panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
70
165
  sidebar header). The quick-key row (`⏎ y n Esc ^C ↑ ↓`, `.lanes-quick`) shows on view
71
166
  tabs only; `refresh()` sets `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
72
- only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); the buttons cancel `mousedown`
73
- so a tap never moves focus.
167
+ only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); `pointercancel` and `pointerleave`
168
+ disarm a key, and a `pointerup` without the key's own `pointerdown` sends nothing (before, a cancelled hold stayed
169
+ armed, so a pointer released over `^C` later sent it, DEF-1739). The buttons cancel `mousedown` so a tap never moves
170
+ focus.
74
171
 
75
172
  Colour (spec §6.3; the operator legend, token table and phone mockup are in [`docs/DESIGN.md`](../docs/DESIGN.md)):
76
173
  `mobile.css` uses only the generated `--lanes-*` tokens (legacy names such as `--lanes-bg` stay
@@ -84,13 +181,21 @@ chaining `window.__zjSyncInactiveCursorStyle`, which the pinned `app.js` calls i
84
181
  assigning the server theme wholesale (`SetConfig` is sent on each config-file reload). The handler then runs
85
182
  `applyTerminalBackground`, which writes the dark background inline on `<body>` and `#terminal`; the stylesheet's
86
183
  `!important` `--lanes-surface` rule for those two elements outranks it in either variant. An SRI bump of `app.js`
87
- re-opens this contract: re-read `startWsControl` and `installSoftKeyboardCapture` before accepting it.
184
+ re-opens this contract: re-read `startWsControl`, `installSoftKeyboardCapture`, `initWebSockets` (`sendAnsiKey`) and
185
+ `installMenuDismissHook` before accepting it.
88
186
  `index.html` carries one `theme-color` per colour scheme. Bundle budget (spec §8, `test_web.test_bundle_growth`):
89
- `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 22 174 B; the caps are never raised — the rules sit one per line and the
90
- explanations live here rather than in the shipped files. MEASURED 2026-10-04 with `wc -c` after LNS-31 (plan Task
91
- 31) met Tasks 13/28: `mobile.js` 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css`
92
- 18 945 B. A laptop or tablet also fetches `browser.js` (3 641 B) and `browser.css` (1 947 B); `test_web_view` pins
93
- that download at `LAPTOP_TABLET_JS_RATCHET` and `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
187
+ `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 24 258 B; the rules sit one per line and the explanations live here rather
188
+ than in the shipped files. MEASURED 2026-10-04 with `wc -c` after LNS-31 (plan Task 31) met Tasks 13/28: `mobile.js`
189
+ 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css` 18 945 B. The JS cap was spec §8's
190
+ 22 174 B until phone shell input v2 (2026-10-07, DEF-1733): the working send path, the text keyboard, paste, Copy and
191
+ Paste, the dock, More and hint fixes grew `mobile.js` to 23 456 B MEASURED after the dead `Write` path, the redundant
192
+ focus/blur `is-typing` listeners and the file's header comment were removed and the new code was written compactly
193
+ (`kbd()`, `aria()`), so the cap rose by exactly 1 282 B to that size (`mobile.css` unchanged). The independent
194
+ review's fixes the same day (the filter's Esc-free frames and deltas, replacement ranges, no paste on a picker, only
195
+ the paste end marker neutralised; DEF-1751..DEF-1755) grew it to 24 258 B MEASURED, and the cap rose by exactly those
196
+ 802 B again. A laptop or tablet also fetches `browser.js` (3 641 B) and `browser.css` (1 947 B); `test_web_view` pins
197
+ that download at `LAPTOP_TABLET_JS_RATCHET` (27 899 B: 25 605 B plus the same 1 492 + 802 B of `mobile.js` growth)
198
+ and `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
94
199
 
95
200
  ## Code notes
96
201
 
@@ -104,26 +209,50 @@ order.
104
209
  (`zellij:mobile-ui` set to `on`); a stored `off` stays.
105
210
  - The `HTMLElement.prototype.focus` guard: only a native tap on an input may open the keyboard; it is installed before
106
211
  upstream's first terminal or modal.
212
+ - `kbd()` / `aria()`: `window.__zjSoftKbdCapture` and an `aria-label` setter, shared to keep the phone budget.
107
213
  - `viewport()`: browser toolbars alone must not mean keyboard mode (`occluded` needs more than 120 px hidden). Zellij's
108
214
  input is in a closed shadow root, so `document.activeElement` is its host; `__zjSoftKbdCapture.isFocused` is read
109
215
  first.
110
- - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field.
216
+ - `setKeyboard()`: also the one place **Type here** gets `is-typing`, driven by upstream's own focus/blur listeners
217
+ (they set `isFocused` and fire `zellij:soft-keyboard-visibility`); the field's own focus/blur listeners were redundant.
218
+ - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field:
219
+ a text input (DEF-1734); `beforeinput` sends a multi-character `insertText` or any `insertReplacementText` whole,
220
+ after one DEL per character of `getTargetRanges()[0]` or else the selection (DEF-1753); `paste` sends one bracketed
221
+ paste (DEF-1735). See Input, keys and clipboard above.
111
222
  - `GLYPHS` / `parseCounts()`: counts from the picker sidebar's OSC 2 pane title (spec §6.4).
223
+ - `build()`, the header: the window capture-phase `pointerdown` listener records `menuWas` before upstream's dismiss
224
+ hook runs, so **More** closes an open menu instead of reopening it (DEF-1738); **Copy** and **Paste** (DEF-1736).
112
225
  - `build()`, the `.zj-mobile-modbar` keys: cancel `mousedown`, not `pointerdown` (upstream's `pointerdown` cancel kills
113
226
  Safari's touch click on the extra keys).
227
+ - `send()`: the terminal socket through `sendFn` (DEF-1733); queued only while `sendFn` is absent, never after a throw.
228
+ A `null` in the queue is a pause: what follows waits until 60 ms after the last frame sent (`sentAt`), and `send()`
229
+ re-arms the one interval to come back (DEF-1751).
230
+ - `paste`: the bracketed paste through `send()`, its end markers neutralised (DEF-1755); nothing on a picker tab, where
231
+ the sidebar reads a paste as a file drop (DEF-1754).
232
+ - `applyLaneFilter()`: the filter's UTF-8 query as deltas against `laneFilterPrev` — a lone Esc, a pause, `/` and the
233
+ text for a new query; DELs and the added text for an edit; a lone Esc and a pause to clear (DEF-1751).
234
+ - `clip()`: calls the clipboard API synchronously inside the tap (Safari's user-gesture rule) and hints the result.
235
+ - `screen()`: xterm's selection, else the visible rows of `term.buffer.active` (the WebGL renderer leaves no DOM text).
236
+ - `hint()`: holds the subtitle for eight ticks of the one interval (DEF-1739).
114
237
  - `buildQuickRow()` (spec §6.5): `n` and `^C` send after a 400 ms hold, measured from event timestamps with no timer;
115
- nothing takes focus.
116
- - `clearLaneFilter()`: the lone `Esc` leaves the sidebar's filter mode.
238
+ a cancelled or left key is disarmed and a release needs the key's own press (DEF-1739); nothing takes focus.
239
+ - `updateLaneFilterBar()`: off a picker tab, forgets the query and drops a filter tail still waiting in the queue,
240
+ sending nothing (the lane has the focus now, DEF-1752); the Esc key's lone `Esc` and pause leave the filter mode.
117
241
  - `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
242
+ - `refresh()`: leaves the subtitle to a live hint, hides **Panes** (and **Paste**, DEF-1754) on a picker tab so the
243
+ dock's four columns hold its four items (DEF-1737), and flushes the `send()` queue after `updateLaneFilterBar()`, so a
244
+ filter tail is dropped before it could reach a lane.
118
245
  - `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
119
246
  re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
120
247
  toggle.
121
248
  - The `zellij:soft-keyboard-visibility` listener: rotation also shrinks the viewport, so the shell sends
122
249
  `SoftKeyboardVisibilityChanged` itself to correct upstream's height-only keyboard heuristic.
123
- - `awaitingClient` (the one `setInterval`, 200 ms, cleared once attached): upstream installs its window hooks just after
124
- appending its mobile root.
250
+ - `tick()` / `arm()` (the one `setInterval`, 200 ms): polls `connect()` until attached, because upstream installs its
251
+ window hooks just after appending its mobile root; `hint()` and a paused `send()` re-arm it; each tick flushes the
252
+ queue, and it clears itself once attached, with no hint up and no pause waiting.
125
253
 
126
254
  Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
127
255
  838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
128
- lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens.
256
+ lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens, and the
257
+ input checklist in the [runbook](../docs/RUNBOOK.md) (text keyboard, quick keys, paste, Copy and Paste).
129
258
  Use disposable view sessions and revoke any temporary test token afterward.
@@ -1,4 +1,3 @@
1
- /* Lanes browser shell; see web/README.md, Code notes */
2
1
  function laneShellMode({userAgent, coarse, width, height} = {}) {
3
2
  if (/iPhone|iPod|Android.*Mobile/i.test(userAgent) || (coarse && (width <= 600 || height <= 500))) return 'phone';
4
3
  return coarse ? 'tablet' : 'desktop';
@@ -49,6 +48,8 @@ if (typeof module === 'object') module.exports = {laneShellMode};
49
48
  arrow: '<path d="m9 5 7 7-7 7"/>',
50
49
  check: '<path d="m5 12 4 4L19 6"/>',
51
50
  search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
51
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5h10"/>',
52
+ paste: '<rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 3h6v4H9z"/>',
52
53
  };
53
54
  const svg = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name]}</svg>`;
54
55
  const make = (tag, cls, text) => {
@@ -61,15 +62,16 @@ if (typeof module === 'object') module.exports = {laneShellMode};
61
62
  const button = (label, icon, action) => {
62
63
  const el = make('button', 'lanes-button');
63
64
  el.type = 'button';
64
- el.setAttribute('aria-label', label);
65
+ aria(el, label);
65
66
  el.innerHTML = svg(icon);
66
67
  el.append(make('span', '', label));
67
68
  el.addEventListener('click', action);
68
69
  return el;
69
70
  };
70
- let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, typingField, quickRow, quickOn;
71
- let laneFilterBar, laneFilterInput, laneFilterPrev = '';
72
- let api, term, previousCards = '', sheetOpen = false, returnFocus;
71
+ let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, panesButton, pasteKey, typingField, quickRow, quickOn;
72
+ let laneFilterBar, laneFilterInput, laneFilterPrev = '', sentAt = 0;
73
+ let api, term, previousCards = '', sheetOpen = false, returnFocus, menuWas, hintN = 0, timer;
74
+ const queue = [];
73
75
  let fontSize = 16;
74
76
  try {
75
77
  const stored = Number(localStorage.getItem('lanes:font-size'));
@@ -79,6 +81,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
79
81
  const data = () => state()?.data;
80
82
  const nativeClick = selector => document.querySelector(selector)?.click();
81
83
  const resize = () => window.dispatchEvent(new Event('zellij:rendering-resize'));
84
+ const kbd = () => window.__zjSoftKbdCapture, aria = (el, label) => el.setAttribute('aria-label', label);
82
85
 
83
86
  function viewport() {
84
87
  const vv = window.visualViewport;
@@ -87,7 +90,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
87
90
  root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
88
91
  root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
89
92
  const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
90
- const focused = window.__zjSoftKbdCapture?.isFocused || document.activeElement?.matches('input, textarea');
93
+ const focused = kbd()?.isFocused || document.activeElement?.matches('input, textarea');
91
94
  setKeyboard(occluded && focused);
92
95
  }
93
96
  function setKeyboard(visible) {
@@ -95,10 +98,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
95
98
  root.classList.toggle('lanes-keyboard', !!visible);
96
99
  resize();
97
100
  }
98
- typingField?.classList.toggle('is-typing', !!window.__zjSoftKbdCapture?.isFocused);
101
+ typingField?.classList.toggle('is-typing', !!kbd()?.isFocused);
99
102
  }
100
103
  function buildTypingField() {
101
- const capture = window.__zjSoftKbdCapture?.element;
104
+ const capture = kbd()?.element;
102
105
  const host = document.getElementById('zj-mobile-capture-host');
103
106
  if (!capture || !host) return null;
104
107
  const field = make('div', 'lanes-terminal-input');
@@ -109,14 +112,21 @@ if (typeof module === 'object') module.exports = {laneShellMode};
109
112
  host.removeAttribute('aria-hidden');
110
113
  host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
111
114
  capture.removeAttribute('aria-hidden');
112
- capture.setAttribute('aria-label', 'Type in terminal');
115
+ aria(capture, 'Type in terminal');
116
+ for (const a of ['type text', 'autocomplete off', 'autocapitalize none']) capture.setAttribute(...a.split(' '));
113
117
  capture.tabIndex = 0;
114
118
  capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;font-size:16px;' +
115
119
  'border:0;border-radius:12px;padding:0;margin:0;opacity:1;background:transparent;color:transparent;' +
116
120
  'caret-color:transparent;outline:none;pointer-events:auto;user-select:text;-webkit-user-select:text;';
117
121
  field.append(label, host);
118
- capture.addEventListener('focus', () => field.classList.add('is-typing'));
119
- capture.addEventListener('blur', () => field.classList.remove('is-typing'));
122
+ capture.addEventListener('beforeinput', ev => {
123
+ const t = ev.data ?? ev.dataTransfer?.getData('text'), r = ev.getTargetRanges?.()[0];
124
+ if (ev.cancelable && t && (ev.inputType == 'insertReplacementText' || ev.inputType == 'insertText' && t.length > 1)) {
125
+ ev.preventDefault();
126
+ send('\x7f'.repeat(r ? r.endOffset - r.startOffset : capture.selectionEnd - capture.selectionStart) + t);
127
+ }
128
+ });
129
+ capture.addEventListener('paste', ev => { ev.preventDefault(); paste(ev.clipboardData.getData('text')); });
120
130
  return field;
121
131
  }
122
132
  function focusTab(tab) {
@@ -153,7 +163,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
153
163
  }
154
164
  function openSheet() {
155
165
  returnFocus = document.activeElement;
156
- window.__zjSoftKbdCapture?.element.blur();
166
+ kbd()?.element.blur();
157
167
  document.activeElement?.blur();
158
168
  sheetOpen = true;
159
169
  sheet.hidden = false;
@@ -188,7 +198,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
188
198
  const active = item.name === activeName;
189
199
  const card = make('button', `lanes-workspace${active ? ' is-current' : ''}`);
190
200
  card.type = 'button';
191
- card.setAttribute('aria-label', `${item.name}${active ? ', current workspace' : ''}`);
201
+ aria(card, `${item.name}${active ? ', current workspace' : ''}`);
192
202
  if (active) card.setAttribute('aria-current', 'true');
193
203
  const monogram = make('span', 'lanes-monogram', Array.from(item.name)[0]?.toUpperCase() || 'L');
194
204
  const copy = make('span', 'lanes-workspace-copy');
@@ -211,22 +221,26 @@ if (typeof module === 'object') module.exports = {laneShellMode};
211
221
  const brand = make('img', 'lanes-brand');
212
222
  brand.src = '/lanes-assets/icon.svg'; brand.alt = 'Lanes';
213
223
  const context = make('button', 'lanes-context');
214
- context.type = 'button'; context.setAttribute('aria-label', 'Choose workspace');
224
+ context.type = 'button'; aria(context, 'Choose workspace');
215
225
  heading = make('strong', '', 'Lanes'); subtitle = make('span', '', 'Connecting…');
216
226
  context.append(heading, subtitle); context.addEventListener('click', openSheet);
217
- const more = button('More options', 'more', () => nativeClick('.zj-hamburger'));
218
- more.classList.add('lanes-more');
219
- header.append(brand, context, more);
220
- dock = make('nav', 'lanes-dock'); dock.setAttribute('aria-label', 'Workspace controls');
227
+ addEventListener('pointerdown', () => { menuWas = document.querySelector('.zj-mobile-menu.zj-open'); }, true);
228
+ const more = button('More options', 'more', () => menuWas ? menuWas = null : nativeClick('.zj-hamburger'));
229
+ const copyKey = button('Copy screen', 'copy', () => clip(c => c.writeText(screen()), () => hint('Copied')));
230
+ pasteKey = button('Paste', 'paste', () => clip(c => c.readText(), t => paste(t) && hint('Pasted')));
231
+ for (const b of [copyKey, pasteKey, more]) b.classList.add('lanes-more');
232
+ for (const b of [copyKey, pasteKey]) b.addEventListener('mousedown', ev => ev.preventDefault());
233
+ header.append(brand, context, copyKey, pasteKey, more);
234
+ dock = make('nav', 'lanes-dock'); aria(dock, 'Workspace controls');
221
235
  const spaces = button('Workspaces', 'workspaces', openSheet);
222
236
  lanesButton = button('Lanes', 'lanes', () => {
223
237
  const picker = pickerTab((currentTab()?.name || '').replace(/ ☰$/, ''));
224
238
  if (picker) focusTab(picker); else openSheet();
225
239
  });
226
- const panes = button('Panes', 'panes', () => nativeClick('.zj-pane'));
240
+ panesButton = button('Panes', 'panes', () => nativeClick('.zj-pane'));
227
241
  typingField = buildTypingField();
228
242
  laneFilterBar = buildLaneFilterBar();
229
- dock.append(spaces, lanesButton, panes, laneFilterBar, typingField);
243
+ dock.append(spaces, lanesButton, panesButton, laneFilterBar, typingField);
230
244
  sheet = make('div', 'lanes-sheet'); sheet.hidden = true;
231
245
  sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-labelledby', 'lanes-sheet-title');
232
246
  const panel = make('section', 'lanes-sheet-panel');
@@ -240,7 +254,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
240
254
  searchWrap.innerHTML = svg('search');
241
255
  search = make('input'); search.type = 'search'; search.placeholder = 'Find a workspace';
242
256
  search.autocomplete = 'off'; search.spellcheck = false;
243
- search.setAttribute('aria-label', 'Find a workspace');
257
+ aria(search, 'Find a workspace');
244
258
  search.addEventListener('input', renderCards); searchWrap.append(search);
245
259
  list = make('div', 'lanes-workspaces');
246
260
  const sessionButton = make('button', 'lanes-session-link', 'Switch session');
@@ -266,51 +280,71 @@ if (typeof module === 'object') module.exports = {laneShellMode};
266
280
  installFontControls();
267
281
  resize();
268
282
  }
269
- function sendToTerminal(bytes) {
270
- try { window.__zjSendControl?.({type: 'Write', bytes}); } catch (_) {}
283
+ function send(...frames) {
284
+ const f = kbd()?.sendFn;
285
+ queue.push(...frames);
286
+ if (f) try {
287
+ while (queue.length) {
288
+ if (queue[0] !== null) { f(queue.shift()); sentAt = Date.now(); }
289
+ else if (Date.now() - sentAt < 60) return arm();
290
+ else queue.shift();
291
+ }
292
+ } catch (_) { queue.length = 0; }
293
+ }
294
+ const paste = t => currentTab()?.name.endsWith(' ☰') ? hint('Open a lane to paste') :
295
+ (send('\x1b[200~' + t.replace(/\x1b+(?=\[201~)/g, '').replace(/\r?\n/g, '\r') + '\x1b[201~'), 1);
296
+ function applyLaneFilter(value) {
297
+ const q = [...new TextDecoder().decode(new TextEncoder().encode(value).filter(b => b > 31 && b != 127))];
298
+ const p = [...laneFilterPrev], s = q.join('');
299
+ if (s === laneFilterPrev) return;
300
+ let i = 0;
301
+ while (i < p.length && p[i] === q[i]) i++;
302
+ laneFilterPrev = s;
303
+ if (!s) send('\x1b', null);
304
+ else if (!p.length) send('\x1b', null, '/', s);
305
+ else send('\x7f'.repeat(p.length - i) + q.slice(i).join(''));
306
+ }
307
+ async function clip(f, ok) {
308
+ try { ok(await f(navigator.clipboard)); } catch (_) { hint('Clipboard blocked'); }
271
309
  }
272
- function applyLaneFilter(query) {
273
- const bytes = [...new TextEncoder().encode(query)].filter(b => b >= 0x20 && b !== 0x7f);
274
- sendToTerminal([0x1b, 0x2f, ...bytes]);
310
+ function screen() {
311
+ if (term.hasSelection()) return term.getSelection();
312
+ const b = term.buffer.active, rows = [];
313
+ for (let i = 0; i < term.rows; i++) rows.push(b.getLine(b.viewportY + i)?.translateToString(true));
314
+ return rows.join('\n').trimEnd();
275
315
  }
316
+ function hint(s) { text(subtitle, s); hintN = 8; arm(); }
276
317
  function buildQuickRow() {
277
318
  const row = make('div', 'lanes-quick');
278
319
  row.hidden = true;
279
- for (const [label, bytes, name, hold] of [['⏎', [13], 'Enter'], ['y', [121]], ['n', [110], 0, 1], ['Esc', [27]],
280
- ['^C', [3], 'Control C', 1], ['↑', [27, 91, 65], 'Up'], ['↓', [27, 91, 66], 'Down']]) {
281
- const key = make('button', hold ? 'is-destructive' : '', label);
320
+ for (const [label, key, name, hold] of [['⏎', '\r', 'Enter'], ['y'], ['n', 0, 0, 1], ['Esc', '\x1b'],
321
+ ['^C', '\x03', 'Control C', 1], ['↑', '\x1b[A', 'Up'], ['↓', '\x1b[B', 'Down']]) {
322
+ const b = make('button', hold ? 'is-destructive' : '', label);
282
323
  let down = -1;
283
- key.type = 'button'; if (name) key.setAttribute('aria-label', name);
284
- key.addEventListener('mousedown', ev => ev.preventDefault());
285
- key.addEventListener('pointerdown', ev => { down = ev.timeStamp; });
286
- key.addEventListener('pointerup', ev => {
287
- if (!hold || (down >= 0 && ev.timeStamp - down >= 400)) sendToTerminal(bytes); else text(subtitle, 'Hold to send');
324
+ b.type = 'button'; if (name) aria(b, name);
325
+ b.addEventListener('mousedown', ev => ev.preventDefault());
326
+ b.addEventListener('pointerdown', ev => { down = ev.timeStamp; });
327
+ for (const t of ['pointercancel', 'pointerleave']) b.addEventListener(t, () => { down = -1; });
328
+ b.addEventListener('pointerup', ev => {
329
+ if (down >= 0) !hold || ev.timeStamp - down >= 400 ? send(key || label) : hint('Hold to send');
288
330
  down = -1;
289
331
  });
290
- row.append(key);
332
+ row.append(b);
291
333
  }
292
334
  return row;
293
335
  }
294
- function clearLaneFilter() {
295
- sendToTerminal([0x1b]);
296
- laneFilterPrev = '';
297
- if (laneFilterInput) laneFilterInput.value = '';
298
- }
299
336
  function buildLaneFilterBar() {
300
337
  const bar = make('div', 'lanes-filter-bar');
301
- bar.setAttribute('aria-label', 'Filter lanes');
338
+ aria(bar, 'Filter lanes');
302
339
  bar.hidden = true;
303
340
  bar.innerHTML = svg('search');
304
341
  laneFilterInput = make('input');
305
342
  laneFilterInput.type = 'search'; laneFilterInput.placeholder = 'Filter lanes';
306
343
  laneFilterInput.autocomplete = 'off'; laneFilterInput.spellcheck = false;
307
- laneFilterInput.setAttribute('aria-label', 'Filter lanes in this workspace');
308
- laneFilterInput.addEventListener('input', () => {
309
- const q = laneFilterInput.value;
310
- if (q !== laneFilterPrev) { applyLaneFilter(q); laneFilterPrev = q; }
311
- });
344
+ aria(laneFilterInput, 'Filter lanes in this workspace');
345
+ laneFilterInput.addEventListener('input', () => applyLaneFilter(laneFilterInput.value));
312
346
  laneFilterInput.addEventListener('keydown', ev => {
313
- if (ev.key === 'Escape') { ev.preventDefault(); clearLaneFilter(); laneFilterInput.blur(); }
347
+ if (ev.key === 'Escape') { ev.preventDefault(); send('\x1b', null); laneFilterPrev = laneFilterInput.value = ''; laneFilterInput.blur(); }
314
348
  if (ev.key === 'Enter') { ev.preventDefault(); laneFilterInput.blur(); }
315
349
  });
316
350
  bar.append(laneFilterInput);
@@ -319,7 +353,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
319
353
  function updateLaneFilterBar(isPicker) {
320
354
  if (!laneFilterBar) return;
321
355
  laneFilterBar.hidden = !isPicker;
322
- if (!isPicker && laneFilterPrev) clearLaneFilter();
356
+ if (isPicker) return;
357
+ const wait = queue.indexOf(null);
358
+ if (wait >= 0) queue.length = wait;
359
+ laneFilterPrev = laneFilterInput.value = '';
323
360
  }
324
361
  function applyFont() {
325
362
  term.options.fontSize = fontSize;
@@ -343,8 +380,8 @@ if (typeof module === 'object') module.exports = {laneShellMode};
343
380
  const row = make('div', 'lanes-font-controls');
344
381
  row.append(make('span', '', 'Text size'));
345
382
  const smaller = make('button', '', 'A−'), larger = make('button', '', 'A+');
346
- smaller.setAttribute('aria-label', 'Smaller terminal text');
347
- larger.setAttribute('aria-label', 'Larger terminal text');
383
+ aria(smaller, 'Smaller terminal text');
384
+ aria(larger, 'Larger terminal text');
348
385
  const value = make('output', '', `${fontSize} px`); value.id = 'lanes-font-value';
349
386
  smaller.addEventListener('click', () => {fontSize = Math.max(14, fontSize - 1); applyFont();});
350
387
  larger.addEventListener('click', () => {fontSize = Math.min(24, fontSize + 1); applyFont();});
@@ -363,11 +400,13 @@ if (typeof module === 'object') module.exports = {laneShellMode};
363
400
  const counts = parseCounts(pickerTitle(name));
364
401
  const chips = countParts(counts).map(([glyph, n]) => glyph + n).join(' ');
365
402
  text(heading, name);
366
- text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
403
+ if (!hintN) text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
367
404
  lanesButton.setAttribute('aria-pressed', String(!!picker));
405
+ panesButton.hidden = pasteKey.hidden = !!picker;
368
406
  const needs = counts ? counts.approval + counts.input : 0;
369
407
  if (needs) lanesButton.dataset.count = String(needs); else delete lanesButton.dataset.count;
370
408
  updateLaneFilterBar(picker);
409
+ send();
371
410
  const quick = active && !picker;
372
411
  if (quick !== quickOn) {
373
412
  quickOn = quick;
@@ -379,7 +418,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
379
418
  renderCards();
380
419
  }
381
420
  function connect() {
382
- if (api || !window.__zjMobileUi || !window.term || !window.__zjSoftKbdCapture?.element) return;
421
+ if (api || !window.__zjMobileUi || !window.term || !kbd()?.element) return;
383
422
  if (!window.__zjMobileUi.getState()?.data?.tabs?.length) return;
384
423
  api = window.__zjMobileUi; term = window.term;
385
424
  build(); applyFont(); applyTheme();
@@ -396,7 +435,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
396
435
  const token = document.getElementById('token');
397
436
  if (!token || token.dataset.lanes) return;
398
437
  token.dataset.lanes = 'true';
399
- token.autocomplete = 'current-password'; token.setAttribute('aria-label', 'Login token');
438
+ token.autocomplete = 'current-password'; aria(token, 'Login token');
400
439
  token.closest('.security-modal-content').querySelector('h3').textContent = 'Connect to Lanes';
401
440
  token.closest('.security-modal-content').querySelector('.submit-btn').textContent = 'Connect';
402
441
  }
@@ -412,6 +451,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
412
451
  });
413
452
  document.addEventListener('focusout', () => requestAnimationFrame(viewport));
414
453
  new MutationObserver(() => {styleLogin(); connect();}).observe(document.body, {childList: true});
415
- const awaitingClient = setInterval(() => {connect(); if (api) clearInterval(awaitingClient);}, 200);
416
- styleLogin(); connect();
454
+ function tick() { connect(); send(); if (hintN && !--hintN) refresh(); if (api && !hintN && queue[0] !== null) timer = clearInterval(timer); }
455
+ function arm() { timer ||= setInterval(tick, 200); }
456
+ arm(); styleLogin(); connect();
417
457
  })();
@@ -6,7 +6,12 @@ The server reads its boards from a **site directory**, not from the package:
6
6
  2. `~/.config/lanes/workspaces/` on the server, if it contains a `catalog.json`;
7
7
  3. `workspaces/` inside the checkout (what a single-site checkout uses).
8
8
 
9
- Copy this folder to `~/.config/lanes/workspaces/` and edit it.
9
+ Copy this folder to `~/.config/lanes/workspaces/` and edit it, or let the server write a one-repository board for you:
10
+ `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> [--title T] [--write]` appends the board to
11
+ `catalog.json` (keeping a `catalog.json.bak-<UTC>Z` copy on the first write) and creates its `workspaces.<id>.json`.
12
+ It is a dry run until `--write`. An id that is already in the catalog is checked and never rewritten. A repository
13
+ belongs to one board: one that another board already declares is refused, since `lanes cursor-sync` rewrites a
14
+ repository's `terminals.json` (and a `cursor` file) for every board that names it. Keep that rule when editing by hand.
10
15
 
11
16
  | File | What it says |
12
17
  |---|---|
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.4",
4
- "commit": "6b92bcb292e4123f2d723f0fe9da209642c2f2a2",
5
- "digest": "sha256:130e2da52ee8a4999a51e351c4bf3faef4a1d9e309ebd946e04c861742dfa719",
3
+ "version": "1.1.1",
4
+ "commit": "34ec2d7ba9a5f13e72c812d3e2fce116e170e260",
5
+ "digest": "sha256:c10719aaa2279205460136ed3589f349d1e22dce32ddfb77584586d68ae1b3a4",
6
6
  "executables": [
7
7
  "bin/lanes",
8
8
  "client/install.sh",
@@ -32,5 +32,8 @@
32
32
  ],
33
33
  "sites": [
34
34
  "beyondwork"
35
- ]
35
+ ],
36
+ "site_codes": {
37
+ "LN": "beyondwork"
38
+ }
36
39
  }