@vibelord/lanes 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -4,12 +4,23 @@
4
4
  # loops forever and shows a status screen instead of dying.
5
5
  # usage: view.sh <device> <workspace> [slot]
6
6
  # switch lane: write the lane id to $STATE/views/<device>/<workspace>.<slot>.target, then `kill -USR1` this pid.
7
+ # new tree (cockpit v3 U5): `kill -USR2` this pid after an update flipped ~/.local/share/lanes/current. The view
8
+ # re-executes itself (`exec "$0" …`: the same pid, the path it was started by — a `current` path runs the new tree) at
9
+ # the NEXT NATURAL EXIT of its child: an attached lane's viewer detaching or a USR1 switch, the end of a status
10
+ # screen's wait. USR2 itself never signals the child, so it never drops an attached tmux client.
7
11
  set -u
12
+ # Trapped first, before this view's pid file names it: a USR2 must never find the default action (terminate).
13
+ reexec=0
14
+ on_reexec() { reexec=1; }
15
+ trap on_reexec USR2
8
16
  device=${1:?device} ws=${2:?workspace} slot=${3:-main}
9
17
  mobile=0; [ "${4:-}" != --mobile ] || mobile=1
10
18
  case "$device$ws$slot" in *[!A-Za-z0-9_.-]*) echo 'invalid view name'; exit 64 ;; esac
11
19
  state=${LANES_STATE:-$HOME/.local/state/lanes}
12
- repo=$(cd "$(dirname "$0")/.." && pwd)
20
+ # -P: the PHYSICAL tree. A layout names this script through the `current` link (W9 U1); resolving it once pins every
21
+ # holder and connection.py this loop runs to the tree the view started from, so an update that flips the link never
22
+ # mixes an old view.sh with new holders. The view moves to the new tree when it is started again.
23
+ repo=$(cd "$(dirname "$0")/.." && pwd -P)
13
24
  dir=$state/views/$device
14
25
  mkdir -p "$dir"
15
26
  target_file=$dir/$ws.$slot.target
@@ -50,6 +61,13 @@ run() {
50
61
  # A trapped signal interrupts wait; make sure the child is really gone before moving on.
51
62
  while kill -0 "$child" 2>/dev/null; do wait "$child" 2>/dev/null || :; done
52
63
  child=
64
+ # A USR2 came while the child ran (or before it): it has ended by itself now, so run the tree `current` names. A
65
+ # failed exec ends a non-interactive shell, so a script that is gone (a pruned tree) is not exec'd: this one goes on.
66
+ if [ "$reexec" = 1 ]; then
67
+ reexec=0
68
+ if [ -x "$0" ] && [ "$mobile" = 1 ]; then exec "$0" "$device" "$ws" "$slot" --mobile; fi
69
+ if [ -x "$0" ]; then exec "$0" "$device" "$ws" "$slot"; fi
70
+ fi
53
71
  return "$rc"
54
72
  }
55
73
 
@@ -58,20 +76,30 @@ reset_terminal() {
58
76
  printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[<u\033[0m\033[?25h\033[2J\033[3J\033[H'
59
77
  }
60
78
 
61
- # The 16-colour SGR literals of the design tokens (libexec/tokens.py --sh): sourced once, printf only, no fork.
79
+ # The SGR literals of the design tokens (libexec/tokens.py --sh): sourced once, printf only, no fork. A plain source
80
+ # assigns the 16-colour set; `lz_tones` re-points them to another set (assignments and a `case`, no fork either).
62
81
  # They hold `\033[…m` as text, so they go into printf's FORMAT (which turns `\033` into ESC), never into a %s argument.
63
82
  # shellcheck source=libexec/tokens.sh
64
83
  . "$repo/libexec/tokens.sh"
65
84
 
66
85
  # status_screen <title> <text> <dim line> [<lead>]: a lead, when given, is the bold headline above the title.
67
- # The lead glyph follows the sidebar's published glyph mode ($STATE/clients/<device>.glyphs, written by sidebar.py from
68
- # devices/<device>.json) and is read on EVERY call with the builtin read, no fork: this runs exactly when the host may be
69
- # too slow for one, and `lanes attach DEVICE --ascii` against a live session must reach the next screen without a restart.
86
+ # The look follows the stamps the sidebar publishes for this device from its resolved modes (devices/<device>.json
87
+ # first): $STATE/clients/<device>.glyphs, .color and .theme, read on EVERY call with the builtin read, no fork: this
88
+ # runs exactly when the host may be too slow for one, and `lanes attach DEVICE --ascii|--color C|--theme T` against a
89
+ # live session must reach the next screen without a restart. The lead glyph is `!` in ascii. `.color` and `.theme`
90
+ # pick the colour set (cockpit v3 P3): truecolor or 256 of dark or light, none (NO_COLOR: bold only), and anything
91
+ # else — no stamp, a vivid mode with no known variant yet — the 16-colour set, the user's own palette, never a guess.
92
+ # In the vivid sets (LZ_VIVID: the colours Lanes defines) the lead takes the warning mark's tone, attention, as the
93
+ # sidebar's banner does; 16 and none keep v1's bytes, as the sidebar's rows do.
70
94
  status_screen() {
71
95
  reset_terminal
72
96
  glyphs=unicode; { IFS= read -r glyphs < "$state/clients/$device.glyphs"; } 2>/dev/null || :
97
+ color=16; { IFS= read -r color < "$state/clients/$device.color"; } 2>/dev/null || :
98
+ theme=; { IFS= read -r theme < "$state/clients/$device.theme"; } 2>/dev/null || :
99
+ lz_tones "$color" "$theme"
73
100
  lead_glyph='⚠'; [ "$glyphs" != ascii ] || lead_glyph='!'
74
- if [ -n "${4:-}" ]; then printf "\n ${LZ_BOLD}%s %s${LZ_RESET}\n\n %s\n" "$lead_glyph" "$4" "$1"
101
+ lead_tone=; [ -z "${LZ_VIVID:-}" ] || lead_tone=$LZ_ATTENTION
102
+ if [ -n "${4:-}" ]; then printf "\n ${LZ_BOLD}${lead_tone}%s %s${LZ_RESET}\n\n %s\n" "$lead_glyph" "$4" "$1"
75
103
  else printf "\n ${LZ_BOLD}%s${LZ_RESET}\n" "$1"; fi
76
104
  printf "\n %s\n\n ${LZ_DIM}%s${LZ_RESET}\n" "$2" "$3"
77
105
  }
@@ -3,5 +3,8 @@ Description=lanes: recreate any missing device view session (agents are never to
3
3
 
4
4
  [Service]
5
5
  Type=oneshot
6
+ # No After=/Conflicts= with lanes-update.service: `lanes ensure` holds the view-session lock (locks/sessions.lock,
7
+ # DEF-1792) itself, as the update's `ensure --if-layout-changed`, `lanes rebuild` and every view create do. A tick that
8
+ # finds it held waits 10 s, then skips (exit 0). Conflicts= would stop a running update; After= orders queued jobs only.
6
9
  ExecStart="@REPO@/bin/lanes" ensure
7
10
  Slice=lanes.slice
@@ -1,5 +1,5 @@
1
1
  [Unit]
2
- Description=lanes housekeeping (zmx logs never rotate; stale dynamic resolve files)
2
+ Description=lanes housekeeping (zmx logs never rotate; stale dynamic resolve files; old staged trees)
3
3
 
4
4
  [Service]
5
5
  Type=oneshot
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=lanes: install the newest npm release when there is one (lanes update --timer)
3
+ Documentation=https://github.com/bwllaming/bw-tools/blob/main/lanes/docs/USERGUIDE.md#update
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ # Asks the npm registry for the release @vibelord/lanes's dist-tag names (`latest`, or `lanes-stable` with channel=stable
8
+ # in ~/.config/lanes/update.conf) and, when it is newer than the installed one, runs the npx the npm wrapper recorded at
9
+ # install (~/.local/state/lanes/update/npx): `npx --yes @vibelord/lanes@<release> --server --update-only`. A last run
10
+ # that ended `partial` gets its post-commit again first (once per release from here). A checkout install, an update
11
+ # already running and an older or rolled-back release are a line in the journal and exit 0 (plan W9 U3, LNS-91).
12
+ ExecStart="@REPO@/bin/lanes" update --timer
13
+ # A oneshot has no start timeout by default. The update's own bounds are npx (50 min) and each post-commit step (300 s);
14
+ # this one stops a run wedged anywhere else. A stop is SIGTERM to every process of the run: the installer records it.
15
+ TimeoutStartSec=1h
16
+ Nice=5
17
+ Slice=lanes.slice
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=lanes: look for a newer npm release 15 min after boot, then every 30 min (enabled by the server install)
3
+
4
+ [Timer]
5
+ # Enabled by every full server install (install/host-install.sh) with the other timers. OnBootSec= is already in the
6
+ # past on a running host, so enabling it starts one check at once — refused while that install still holds the run lock
7
+ # (~/.local/state/lanes/update/.lock), harmless after it (the release just installed is the newest, or the next one is).
8
+ OnBootSec=15min
9
+ OnUnitActiveSec=30min
10
+ RandomizedDelaySec=5min
11
+ # Persistent= acts only on OnCalendar= timers (systemd.timer(5)): a host that was off misses no check here because
12
+ # OnBootSec= checks 15 min after every boot. Kept as the plan sets it; `lanes update` itself skips a check made less than
13
+ # 20 min before (update.INTERVAL).
14
+ Persistent=true
15
+
16
+ [Install]
17
+ WantedBy=timers.target
@@ -17,7 +17,9 @@ undoes the client's per-load single-pane start once
17
17
  (`laneFullRender`, the client's own `SetMobileRenderPreferences {single_pane: false, fit: true}`), so a desktop
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
- suite when node is installed. After an upgrade,
20
+ suite when node is installed; `tests/web/phone.test.mjs` boots `mobile.js` as a phone against a fake page and
21
+ client and records every frame it sends (input v2's bytes, keyboard, clipboard, dock and menu; cockpit v3 S5's filter
22
+ Esc and prompt quick row; `test_web_view.PhoneKeys` names S5's tests and counts the file's). After an upgrade,
21
23
  check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture` (with `sendFn` = `websockets.sendAnsiKey`),
22
24
  `__zjSyncInactiveCursorStyle` and `window.term` still exist in the served `app.js`.
23
25
 
@@ -50,7 +52,9 @@ Run `lanes web <device> --token` on the server (it also adds that Tailscale Serv
50
52
  `lanes-launch web phone|laptop` from an installed client, or `sh install/enable-phone.sh --host lanes` (`--local` on
51
53
  the server).
52
54
  The [installation guide](../docs/INSTALL.md) covers Caddy, Tailscale operator permission and the selected SSH key.
53
- Service registration supplies `LANES_REPO` so Caddy serves the actual checkout from any installation path.
55
+ Service registration supplies `LANES_REPO` — the `~/.local/share/lanes/current` link, which names the installed
56
+ tree (W9 U1) — so Caddy serves that tree from any installation path, and the files an update brings as soon as
57
+ the link moves; the routes of a new Caddyfile take a restart of `lanes-mobile` (the server installer does it).
54
58
 
55
59
  `index.html` keeps the upstream asset integrity hashes. When upgrading Zellij, compare the HTML and
56
60
  window hooks against the new version before changing the pinned binary. The mobile JS and CSS have
@@ -90,9 +94,16 @@ character of the old query after its common prefix with the new one, then the ne
90
94
  prefix; an emptied field or the Esc key sends a lone `Esc`. The text is the `TextEncoder` bytes with control bytes and
91
95
  DEL dropped, decoded back to text. There is no `setTimeout`: `send()` keeps a pause marker in its queue after a lone
92
96
  `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
97
+ last query it sent (`laneFilterPrev`) and the picker it was typed on (`filterTab`, cockpit v3 S5); once a refresh
98
+ finds another tab current — a lane, or another workspace's picker by the Workspaces sheet — it forgets the query,
99
+ empties the field and drops a filter tail still waiting in the queue, without sending anything: the lane now has the
100
+ focus, and an `Esc` there can interrupt an agent (DEF-1752 here, DEF-1817 on S5's line; the sidebar clears its own
101
+ filter when it opens a lane), and another workspace's picker starts with an empty bar instead of the first one's
102
+ query, whose next letter the deltas would have sent there as a bare key (DEF-1820). The lone `Esc` of an emptied
103
+ field or of the Esc key, and the one a picker left filtered is owed, go through one debt (`owed`, picker tab names):
104
+ `updateLaneFilterBar` sends it only while the picker it is owed to is the current tab, so a picker left filtered shows
105
+ its whole list again on its next visit, and an Escape that arrives once the client has already moved on is held,
106
+ never sent into the lane or another picker. Limits: the deltas assume the
96
107
  sidebar holds what the phone sent, which each new query's leading `Esc` restores — another client typing into the same
97
108
  picker pane, or a character the sidebar does not print (a no-break space, a zero-width joiner), puts them out of step
98
109
  until the field is cleared; and an `Esc` while the sidebar's upload strip is armed (`U`) returns the focus to the
@@ -155,6 +166,14 @@ on those facts (`node --test`, no jsdom).
155
166
  Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
156
167
  `parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
157
168
  `data-count` and the workspace cards, inside the existing `refresh()` / `renderCards()` — no fetch, file or timer.
169
+ The shell makes one request (W9 V3, LNS-85): once per page load, when it builds, `installFontControls()` asks
170
+ `/enrol/site` on the same origin (the `/enrol/*` route above; the enrolment service's `GET /site` answers
171
+ `{alias, host_name, host_key, version, request_mode, caller, source}`) and shows its `version` as the first row of
172
+ the client's **More** menu, **Lanes v1.2.0**, above **Text size** — the same `.lanes-font-controls` row, so no CSS.
173
+ Only a release is shown (`^\d[\w.+-]{0,31}$`, as text): `dev`, anything else, an error answer, a non-JSON 502 while
174
+ the service is down, or no network shows nothing and throws nothing. The version is the enrolment service's tree
175
+ (the server installer restarts it on every install). `tests/web/version.test.mjs` boots `mobile.js` against a fake
176
+ logged-in page to pin this; `test_web_view` pins one fetch, inside `installFontControls()`, which `build()` runs once.
158
177
  Until plan Task 26 (2026-10-04) this reader was inert (DEF-1531): `bin/lanes` `mobile_workspace_tab` gave the
159
178
  picker pane a fixed `name="Lanes"`, and Zellij reports a pane's layout name instead of its OSC 2 title, so
160
179
  `pickerTitle()` read `Lanes`, `parseCounts` returned null and the shell showed no counts ("Choose a lane to continue").
@@ -163,7 +182,11 @@ phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-
163
182
  `lanes doctor` names such a device). Whether the OSC 2 title now reaches
164
183
  `panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
165
184
  sidebar header). The quick-key row (`⏎ y n Esc ^C ↑ ↓`, `.lanes-quick`) shows on view
166
- tabs only; `refresh()` sets `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
185
+ tabs; on the picker tab it shows only while the sidebar asks for a key (a close, the Register confirmation or Add to
186
+ workspace, which the sidebar announces by ending its pane title with ` · › <verb>`, ` · > <verb>` under ASCII glyphs;
187
+ DM-LANES-SIDEBAR-TITLE), and then only `⏎ y Esc` (cockpit v3 S5) — `^C`, `n` and the arrows have no use in a prompt.
188
+ A name field (Rename, Register as) is never announced: there `y` is a letter of the name (DEF-1819). `refresh()` sets
189
+ `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
167
190
  only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); `pointercancel` and `pointerleave`
168
191
  disarm a key, and a `pointerup` without the key's own `pointerdown` sends nothing (before, a cancelled hold stayed
169
192
  armed, so a pointer released over `^C` later sent it, DEF-1739). The buttons cancel `mousedown` so a tap never moves
@@ -183,26 +206,45 @@ assigning the server theme wholesale (`SetConfig` is sent on each config-file re
183
206
  `!important` `--lanes-surface` rule for those two elements outranks it in either variant. An SRI bump of `app.js`
184
207
  re-opens this contract: re-read `startWsControl`, `installSoftKeyboardCapture`, `initWebSockets` (`sendAnsiKey`) and
185
208
  `installMenuDismissHook` before accepting it.
186
- `index.html` carries one `theme-color` per colour scheme. Bundle budget (spec §8, `test_web.test_bundle_growth`):
187
- `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 24 258 B; the rules sit one per line and the explanations live here rather
209
+ `index.html` carries one `theme-color` per colour scheme, each that variant's `surface` token, and the manifest's
210
+ `background_color` and `theme_color` are the dark `surface`; they are hand-written, so `test_tokens.GeneratedSurfaces`
211
+ holds them to the table (cockpit v3 P5). The icon (`icon.svg`, `icon-192.png`, a raster with anti-aliased edges) is
212
+ the brand mark, drawn before the token table and not generated: palette v2 did not recolour it, and its bars keep
213
+ v1's dark accent. Bundle budget (spec §8, `test_web.test_bundle_growth`):
214
+ `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 24 612 B; the rules sit one per line and the explanations live here rather
188
215
  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).
216
+ 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css` 18 945 B. A laptop or tablet also
217
+ fetches `browser.js` and `browser.css` (1 947 B); `test_web_view` pins that download at `LAPTOP_TABLET_JS_RATCHET` and
218
+ `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
219
+
220
+ The two lines that met at the cockpit v3 main sync (D2, LNS-99, 2026-10-07). On `wave/lanes-cockpit-v3`: after
221
+ cockpit v3 S5 (the filter's Esc, the prompt quick row) `mobile.js` 22 164 B, and `browser.js` 3 334 B (3 641 B before
222
+ its comments moved to Code notes below, to keep that download inside the ratchet, then pinned at 25 498 B); after S5
223
+ fix cycle 1 (the Esc owed to the picker it was typed on) `mobile.js` 22 162 B and the ratchet 25 496 B. W9 V3's
224
+ release row (+257 B of code; `mobile.js`'s line-1 pointer comment moved here first) measured 22 164 B on its own
225
+ track; the fan-in of the two tracks measured 22 362 B, over §8's 22 174 B, and paid for it inside `mobile.js` with
226
+ equivalent code — the four viewport properties through one setter, one `fill` style shared by the typing field's
227
+ host and capture, the Workspaces icon's four squares drawn from their corners: 22 153 B, the download 25 487 B. On
228
+ `bwtools/main`: the JS cap was spec §8's 22 174 B until phone shell input v2 (2026-10-07, DEF-1733): the working send
229
+ path, the text keyboard, paste, Copy and Paste, the dock, More and hint fixes grew `mobile.js` to 23 456 B MEASURED
230
+ after the dead `Write` path, the redundant focus/blur `is-typing` listeners and the file's header comment were
231
+ removed and the new code was written compactly (`kbd()`, `aria()`), so the cap rose by exactly 1 282 B to that size
232
+ (`mobile.css` unchanged); the independent review's fixes the same day (the filter's Esc-free frames and deltas,
233
+ replacement ranges, no paste on a picker, only the paste end marker neutralised; DEF-1751..DEF-1755) grew it to
234
+ 24 258 B MEASURED, and the cap and the ratchet (27 899 B: 25 605 B plus the same 1 492 + 802 B) rose by exactly those
235
+ 802 B again. The composed file — input v2's frames, deltas, Copy/Paste and dock, plus S5's per-picker Esc debt and
236
+ prompt quick row and W9 V3's release row, the branch's equivalent rewrites applied to the code both kept —
237
+ MEASURED `mobile.js` 24 612 B (+354 B over input v2) and the download 24 612 + 3 334 = 27 946 B: the phone cap rose by
238
+ those 354 B and the ratchet by 47 B (354 less the 307 B `browser.js` had already shed), each to the measured size
239
+ (`test_web_view.JS_COCKPIT_V3`, `LAPTOP_TABLET_JS_RATCHET`), and all four cap assertions red on one byte more.
199
240
 
200
241
  ## Code notes
201
242
 
202
243
  `mobile.js` carries no explanations (the spec §8 budget above); each note is keyed to the code it explains, in file
203
244
  order.
204
245
 
205
- - The file: the Lanes browser shell (phone, tablet, desktop) over the pinned Zellij 0.45.1 client's window hooks.
246
+ - The file: the Lanes browser shell (phone, tablet, desktop) over the pinned Zellij 0.45.1 client's window hooks. Its
247
+ explanations are these notes (the file's line-1 pointer to them moved here too, W9 V3).
206
248
  - `root.dataset.lanesShell = mode`: read by `browser.js` for the desktop bar and the tablet's full rendering.
207
249
  - `if (mode !== 'phone')`: appends the laptop/tablet extras (`browser.css`, `browser.js`); a phone never fetches them.
208
250
  - `if (mode === 'tablet')`: iPad Safari's user agent is a desktop one, so the shell asks for the client's mobile UI
@@ -236,12 +278,19 @@ order.
236
278
  - `hint()`: holds the subtitle for eight ticks of the one interval (DEF-1739).
237
279
  - `buildQuickRow()` (spec §6.5): `n` and `^C` send after a 400 ms hold, measured from event timestamps with no timer;
238
280
  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.
281
+ - `clearLaneFilter()`: forgets the query and owes its picker (`filterTab`) the lone `Esc` that leaves the sidebar's
282
+ filter mode (`owed`, a set of picker tab names). `updateLaneFilterBar(tab, isPicker)`, on every refresh: once the
283
+ current tab is not the one the query was typed on, a filter tail still waiting in the queue is dropped and the query
284
+ cleared, sending nothing (the lane may have the focus now, DEF-1752); the lone `Esc` and its pause are sent only when
285
+ the current tab is an owed picker; then `filterTab` becomes the current tab if it is a picker. Escape in the bar and
286
+ an emptied field clear and call `refresh()`, so the `Esc` goes out at once while that picker is still current.
241
287
  - `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
242
288
  - `refresh()`: leaves the subtitle to a live hint, hides **Panes** (and **Paste**, DEF-1754) on a picker tab so the
243
289
  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.
290
+ filter tail is dropped before it could reach a lane; `quick` is `true` on a view tab (every key), `2` on the picker
291
+ while its title ends ` · › <verb>` (only `⏎ y Esc`), else none.
292
+ - `menuRow()` / `installFontControls()`: a row of the client's **More** menu, prepended (Text size, then the release
293
+ above it once `/enrol/site` answers). The one `fetch` is there because `build()` runs once per page load.
245
294
  - `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
246
295
  re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
247
296
  toggle.
@@ -251,8 +300,18 @@ order.
251
300
  window hooks just after appending its mobile root; `hint()` and a paused `send()` re-arm it; each tick flushes the
252
301
  queue, and it clears itself once attached, with no hint up and no pause waiting.
253
302
 
303
+ `browser.js` (laptop/tablet extras) carries none either, since cockpit v3 S5:
304
+
305
+ - `laneFullRender()`: the client starts a tablet in single pane on every load; once, it sends the client's own
306
+ switch-to-desktop preference.
307
+ - `desktop()`: the chip toggles an overlay bar (no refit); a pick sends the phone picker's `FocusPane`. The chip
308
+ cancels `mousedown`, so the terminal keeps the keys.
309
+ - `ui()`: `window.__zjMobileUi` is set synchronously after the client appends its mobile root, which the
310
+ `MutationObserver` on `<body>` waits for.
311
+
254
312
  Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
255
313
  838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
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).
314
+ lane selection, pane navigation, font controls and the release row in **More**, rotation and the visible viewport
315
+ while the keyboard opens, and the input checklist in the [runbook](../docs/RUNBOOK.md) (text keyboard, quick keys,
316
+ paste, Copy and Paste, the picker's filter and prompt row).
258
317
  Use disposable view sessions and revoke any temporary test token afterward.
@@ -1,4 +1,4 @@
1
- /* LNS-31 laptop/tablet extras; see docs/ARCHITECTURE.md (Web and phone). */
1
+ /* Lanes laptop/tablet extras; see web/README.md, Code notes */
2
2
  function laneBarModel(d) {
3
3
  const panes = (d?.panes || []).filter(p => !p.is_plugin), at = d?.active_pane || {}, ws = new Map();
4
4
  const first = t => t && panes.find(p => p.tab_position === t.position);
@@ -14,7 +14,6 @@ function laneBarModel(d) {
14
14
  .map(p => item(p.title || `Pane ${p.pane_id}`, p))].filter(Boolean),
15
15
  };
16
16
  }
17
- // Tablet: upstream starts single pane on every load; once, send its own switch-to-desktop prefs.
18
17
  function laneFullRender(api, send) {
19
18
  let done;
20
19
  return () => {
@@ -41,7 +40,6 @@ if (typeof module === 'object') module.exports = {laneBarModel, laneFullRender};
41
40
  const full = laneFullRender(api, send);
42
41
  return () => full() && dispatchEvent(new Event('zellij:rendering-resize'));
43
42
  };
44
- // Desktop: the chip toggles an overlay bar (no refit); picks send the phone picker's FocusPane.
45
43
  const desktop = api => {
46
44
  let last;
47
45
  const picks = ['Workspace', 'Lane'].map(title => make('select', {title, onchange() {
@@ -61,13 +59,13 @@ if (typeof module === 'object') module.exports = {laneBarModel, laneFullRender};
61
59
  render();
62
60
  window.term?.focus();
63
61
  }});
64
- chip.addEventListener('mousedown', ev => ev.preventDefault()); // the terminal keeps the keys
62
+ chip.addEventListener('mousedown', ev => ev.preventDefault());
65
63
  bar.addEventListener('keydown', ev => ev.key === 'Escape' && focus());
66
64
  bar.append(...picks);
67
65
  document.body.append(bar, chip);
68
66
  return render;
69
67
  };
70
- const ui = () => window.__zjMobileUi; // set synchronously after the client appends its mobile root
68
+ const ui = () => window.__zjMobileUi;
71
69
  if (ui()) return ready(ui());
72
70
  new MutationObserver((_, o) => ui() && (o.disconnect(), ready(ui()))).observe(document.body, {childList: true});
73
71
  })();
@@ -3,20 +3,20 @@ html.lanes-mobile {
3
3
  --lanes-surface: #101318;
4
4
  --lanes-surface-raised: #191e25;
5
5
  --lanes-line: #2c333d;
6
- --lanes-accent-wash: #1a2721;
6
+ --lanes-accent-wash: #1c3326;
7
7
  --lanes-accent-line: #4b6955;
8
8
  --lanes-press: #26332c;
9
9
  --lanes-chip: #ffffff0f;
10
- --lanes-ink: #edf1f5;
11
- --lanes-ink-dim: #9ca7b5;
12
- --lanes-ink-faint: #6b7683;
13
- --lanes-accent: #b4e3bf;
10
+ --lanes-ink: #f2f5f8;
11
+ --lanes-ink-dim: #a6b1be;
12
+ --lanes-ink-faint: #6f7a87;
13
+ --lanes-accent: #8fe3a6;
14
14
  --lanes-on-accent: #101318;
15
15
  --lanes-on-accent-urgent: #7a1a12;
16
- --lanes-tone-urgent: #f28b7d;
17
- --lanes-tone-attention: #e5b84a;
18
- --lanes-tone-working: #7fb3f2;
19
- --lanes-tone-done: #6fd39a;
16
+ --lanes-tone-urgent: #ff7b6e;
17
+ --lanes-tone-attention: #ffc233;
18
+ --lanes-tone-working: #6fb6ff;
19
+ --lanes-tone-done: #5fdc8a;
20
20
  --lanes-scrim: #000000a6;
21
21
  --lanes-shadow: 0 -16px 80px #0007;
22
22
  color-scheme: dark;
@@ -29,16 +29,16 @@ html.lanes-mobile:root[data-zj-theme="light"] {
29
29
  --lanes-accent-line: #acc9b4;
30
30
  --lanes-press: #d8e9db;
31
31
  --lanes-chip: #edf3ec;
32
- --lanes-ink: #17241b;
33
- --lanes-ink-dim: #56616d;
34
- --lanes-ink-faint: #7f8a96;
35
- --lanes-accent: #2f6b45;
32
+ --lanes-ink: #111a14;
33
+ --lanes-ink-dim: #4a5661;
34
+ --lanes-ink-faint: #6f7b87;
35
+ --lanes-accent: #1f6f3f;
36
36
  --lanes-on-accent: #ffffff;
37
37
  --lanes-on-accent-urgent: #ffd3cc;
38
- --lanes-tone-urgent: #b7322a;
39
- --lanes-tone-attention: #8a5a00;
40
- --lanes-tone-working: #2456a8;
41
- --lanes-tone-done: #1b7142;
38
+ --lanes-tone-urgent: #c41c1c;
39
+ --lanes-tone-attention: #a04d00;
40
+ --lanes-tone-working: #1a56c4;
41
+ --lanes-tone-done: #0d7338;
42
42
  --lanes-scrim: #1a222b80;
43
43
  --lanes-shadow: 0 -16px 80px #0003;
44
44
  color-scheme: light;
@@ -10,7 +10,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
10
10
  width: innerWidth, height: innerHeight});
11
11
  const root = document.documentElement;
12
12
  // BEGIN lanes tokens (generated by libexec/tokens.py --js; do not edit by hand)
13
- const LANES_ANSI = {dark: ['#7d8896', '#f28b7d', '#6fd39a', '#e5b84a', '#7fb3f2', '#c9a6ff', '#6fd6d0', '#9ca7b5', '#8a95a3', '#ff9d96', '#8fe0a3', '#f7cf7f', '#a5cbff', '#dcc6ff', '#9ae6e0', '#edf1f5'], light: ['#3b4a42', '#b7322a', '#1b7142', '#8a5a00', '#2456a8', '#7a3fb8', '#0f7b7b', '#56616d', '#5f6d66', '#c2332a', '#1b7a38', '#8f5a00', '#2860c4', '#8a4dc8', '#0f7272', '#17241b']};
13
+ const LANES_ANSI = {dark: ['#8792a0', '#ff7b6e', '#5fdc8a', '#ffc233', '#6fb6ff', '#c58bff', '#3fd9cf', '#a6b1be', '#97a3b1', '#ff9a8e', '#86f0ad', '#ffd866', '#98cbff', '#dcb2ff', '#7deee6', '#f2f5f8'], light: ['#2f3b34', '#c41c1c', '#0d7338', '#a04d00', '#1a56c4', '#8228c4', '#006f73', '#4a5661', '#55625b', '#d0221a', '#0e7a3a', '#a85200', '#1f5fd8', '#9133d0', '#00797c', '#111a14']};
14
14
  // END lanes tokens
15
15
  root.dataset.lanesShell = mode;
16
16
  if (mode !== 'phone') {
@@ -22,7 +22,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
22
22
  root.classList.add('lanes-mobile');
23
23
  if (mode === 'tablet') {
24
24
  root.classList.add('lanes-tablet');
25
- try {localStorage.getItem('zellij:mobile-ui') || localStorage.setItem('zellij:mobile-ui', 'on');} catch (_) {}
25
+ try {localStorage.getItem('zellij:mobile-ui') || localStorage.setItem('zellij:mobile-ui', 'on');} catch {}
26
26
  }
27
27
 
28
28
  const elementFocus = HTMLElement.prototype.focus;
@@ -37,9 +37,9 @@ if (typeof module === 'object') module.exports = {laneShellMode};
37
37
  const theme = localStorage.getItem('zellij:mobile-theme') || 'dark';
38
38
  localStorage.setItem('zellij:mobile-theme', theme);
39
39
  root.dataset.zjTheme = theme;
40
- } catch (_) {}
40
+ } catch {}
41
41
  const icons = {
42
- workspaces: '<rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/>',
42
+ workspaces: [[3, 3], [14, 3], [3, 14], [14, 14]].map(([x, y]) => `<rect x="${x}" y="${y}" width="7" height="7" rx="2"/>`).join(''),
43
43
  lanes: '<path d="M8 5h13M8 12h13M8 19h13"/><path d="M3 5h.01M3 12h.01M3 19h.01"/>',
44
44
  panes: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M12 4v16"/>',
45
45
  keyboard: '<rect x="2" y="5" width="20" height="14" rx="3"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 13h.01M10 13h.01M14 13h.01M18 13h.01M8 16h8"/>',
@@ -69,14 +69,14 @@ if (typeof module === 'object') module.exports = {laneShellMode};
69
69
  return el;
70
70
  };
71
71
  let shell, heading, subtitle, dock, sheet, list, search, count, lanesButton, panesButton, pasteKey, typingField, quickRow, quickOn;
72
- let laneFilterBar, laneFilterInput, laneFilterPrev = '', sentAt = 0;
72
+ let laneFilterBar, laneFilterInput, laneFilterPrev = '', sentAt = 0, filterTab, owed = new Set();
73
73
  let api, term, previousCards = '', sheetOpen = false, returnFocus, menuWas, hintN = 0, timer;
74
74
  const queue = [];
75
75
  let fontSize = 16;
76
76
  try {
77
77
  const stored = Number(localStorage.getItem('lanes:font-size'));
78
78
  if (stored >= 14 && stored <= 24) fontSize = stored;
79
- } catch (_) {}
79
+ } catch {}
80
80
  const state = () => api?.getState();
81
81
  const data = () => state()?.data;
82
82
  const nativeClick = selector => document.querySelector(selector)?.click();
@@ -85,10 +85,9 @@ if (typeof module === 'object') module.exports = {laneShellMode};
85
85
 
86
86
  function viewport() {
87
87
  const vv = window.visualViewport;
88
- root.style.setProperty('--lanes-vh', `${vv?.height ?? innerHeight}px`);
89
- root.style.setProperty('--lanes-vw', `${vv?.width ?? innerWidth}px`);
90
- root.style.setProperty('--lanes-offset-top', `${vv?.offsetTop ?? 0}px`);
91
- root.style.setProperty('--lanes-offset-left', `${vv?.offsetLeft ?? 0}px`);
88
+ const px = (k, v) => root.style.setProperty(`--lanes-${k}`, `${v}px`);
89
+ px('vh', vv?.height ?? innerHeight); px('vw', vv?.width ?? innerWidth);
90
+ px('offset-top', vv?.offsetTop ?? 0); px('offset-left', vv?.offsetLeft ?? 0);
92
91
  const occluded = !!vv && innerHeight - vv.height - vv.offsetTop > 120;
93
92
  const focused = kbd()?.isFocused || document.activeElement?.matches('input, textarea');
94
93
  setKeyboard(occluded && focused);
@@ -110,14 +109,14 @@ if (typeof module === 'object') module.exports = {laneShellMode};
110
109
  label.append(make('span', '', 'Type here'));
111
110
  label.setAttribute('aria-hidden', 'true');
112
111
  host.removeAttribute('aria-hidden');
113
- host.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
112
+ const fill = 'position:absolute;inset:0;width:100%;height:100%;opacity:1;pointer-events:auto;';
113
+ host.style.cssText = fill;
114
114
  capture.removeAttribute('aria-hidden');
115
115
  aria(capture, 'Type in terminal');
116
116
  for (const a of ['type text', 'autocomplete off', 'autocapitalize none']) capture.setAttribute(...a.split(' '));
117
117
  capture.tabIndex = 0;
118
- capture.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;font-size:16px;' +
119
- 'border:0;border-radius:12px;padding:0;margin:0;opacity:1;background:transparent;color:transparent;' +
120
- 'caret-color:transparent;outline:none;pointer-events:auto;user-select:text;-webkit-user-select:text;';
118
+ capture.style.cssText = fill + 'box-sizing:border-box;font-size:16px;border:0;border-radius:12px;padding:0;margin:0;' +
119
+ 'background:transparent;color:transparent;caret-color:transparent;outline:none;user-select:text;-webkit-user-select:text;';
121
120
  field.append(label, host);
122
121
  capture.addEventListener('beforeinput', ev => {
123
122
  const t = ev.data ?? ev.dataTransfer?.getData('text'), r = ev.getTargetRanges?.()[0];
@@ -289,7 +288,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
289
288
  else if (Date.now() - sentAt < 60) return arm();
290
289
  else queue.shift();
291
290
  }
292
- } catch (_) { queue.length = 0; }
291
+ } catch { queue.length = 0; }
293
292
  }
294
293
  const paste = t => currentTab()?.name.endsWith(' ☰') ? hint('Open a lane to paste') :
295
294
  (send('\x1b[200~' + t.replace(/\x1b+(?=\[201~)/g, '').replace(/\r?\n/g, '\r') + '\x1b[201~'), 1);
@@ -300,12 +299,12 @@ if (typeof module === 'object') module.exports = {laneShellMode};
300
299
  let i = 0;
301
300
  while (i < p.length && p[i] === q[i]) i++;
302
301
  laneFilterPrev = s;
303
- if (!s) send('\x1b', null);
302
+ if (!s) { clearLaneFilter(); refresh(); }
304
303
  else if (!p.length) send('\x1b', null, '/', s);
305
304
  else send('\x7f'.repeat(p.length - i) + q.slice(i).join(''));
306
305
  }
307
306
  async function clip(f, ok) {
308
- try { ok(await f(navigator.clipboard)); } catch (_) { hint('Clipboard blocked'); }
307
+ try { ok(await f(navigator.clipboard)); } catch { hint('Clipboard blocked'); }
309
308
  }
310
309
  function screen() {
311
310
  if (term.hasSelection()) return term.getSelection();
@@ -333,6 +332,10 @@ if (typeof module === 'object') module.exports = {laneShellMode};
333
332
  }
334
333
  return row;
335
334
  }
335
+ function clearLaneFilter() {
336
+ owed.add(filterTab);
337
+ laneFilterPrev = laneFilterInput.value = '';
338
+ }
336
339
  function buildLaneFilterBar() {
337
340
  const bar = make('div', 'lanes-filter-bar');
338
341
  aria(bar, 'Filter lanes');
@@ -344,24 +347,26 @@ if (typeof module === 'object') module.exports = {laneShellMode};
344
347
  aria(laneFilterInput, 'Filter lanes in this workspace');
345
348
  laneFilterInput.addEventListener('input', () => applyLaneFilter(laneFilterInput.value));
346
349
  laneFilterInput.addEventListener('keydown', ev => {
347
- if (ev.key === 'Escape') { ev.preventDefault(); send('\x1b', null); laneFilterPrev = laneFilterInput.value = ''; laneFilterInput.blur(); }
348
- if (ev.key === 'Enter') { ev.preventDefault(); laneFilterInput.blur(); }
350
+ if (ev.key === 'Escape') { clearLaneFilter(); refresh(); }
351
+ if (/^E(scape|nter)$/.test(ev.key)) { ev.preventDefault(); laneFilterInput.blur(); }
349
352
  });
350
353
  bar.append(laneFilterInput);
351
354
  return bar;
352
355
  }
353
- function updateLaneFilterBar(isPicker) {
354
- if (!laneFilterBar) return;
356
+ function updateLaneFilterBar(tab, isPicker) {
355
357
  laneFilterBar.hidden = !isPicker;
356
- if (isPicker) return;
357
- const wait = queue.indexOf(null);
358
- if (wait >= 0) queue.length = wait;
359
- laneFilterPrev = laneFilterInput.value = '';
358
+ if (tab !== filterTab) {
359
+ const wait = queue.indexOf(null);
360
+ if (wait >= 0) queue.length = wait;
361
+ if (laneFilterPrev) clearLaneFilter();
362
+ }
363
+ if (owed.delete(tab)) send('\x1b', null);
364
+ if (isPicker) filterTab = tab;
360
365
  }
361
366
  function applyFont() {
362
367
  term.options.fontSize = fontSize;
363
368
  term.options.lineHeight = 1.1;
364
- try {localStorage.setItem('lanes:font-size', String(fontSize));} catch (_) {}
369
+ try {localStorage.setItem('lanes:font-size', String(fontSize));} catch {}
365
370
  text(document.getElementById('lanes-font-value'), `${fontSize} px`);
366
371
  resize();
367
372
  }
@@ -376,9 +381,14 @@ if (typeof module === 'object') module.exports = {laneShellMode};
376
381
  return t;
377
382
  }
378
383
  const applyTheme = () => { if (term) term.options.theme = lanesTheme(); };
379
- function installFontControls() {
384
+ function menuRow(label) {
380
385
  const row = make('div', 'lanes-font-controls');
381
- row.append(make('span', '', 'Text size'));
386
+ row.append(make('span', '', label));
387
+ document.querySelector('.zj-mobile-menu')?.prepend(row);
388
+ return row;
389
+ }
390
+ function installFontControls() {
391
+ const row = menuRow('Text size');
382
392
  const smaller = make('button', '', 'A−'), larger = make('button', '', 'A+');
383
393
  aria(smaller, 'Smaller terminal text');
384
394
  aria(larger, 'Larger terminal text');
@@ -386,7 +396,8 @@ if (typeof module === 'object') module.exports = {laneShellMode};
386
396
  smaller.addEventListener('click', () => {fontSize = Math.max(14, fontSize - 1); applyFont();});
387
397
  larger.addEventListener('click', () => {fontSize = Math.min(24, fontSize + 1); applyFont();});
388
398
  row.append(smaller, value, larger);
389
- document.querySelector('.zj-mobile-menu')?.prepend(row);
399
+ fetch('/enrol/site', {credentials: 'same-origin'}).then(r => r.json())
400
+ .then(({version: v}) => /^\d[\w.+-]{0,31}$/.test(v) && menuRow(`Lanes v${v}`)).catch(() => {});
390
401
  }
391
402
  function refresh() {
392
403
  if (!api || !shell) return;
@@ -397,7 +408,7 @@ if (typeof module === 'object') module.exports = {laneShellMode};
397
408
  const name = tab?.name.replace(/ ☰$/, '') || 'Lanes';
398
409
  const picker = tab?.name.endsWith(' ☰');
399
410
  const pane = data()?.panes.find(p => p.pane_id === data()?.active_pane?.pane_id && !p.is_plugin);
400
- const counts = parseCounts(pickerTitle(name));
411
+ const title = pickerTitle(name), counts = parseCounts(title);
401
412
  const chips = countParts(counts).map(([glyph, n]) => glyph + n).join(' ');
402
413
  text(heading, name);
403
414
  if (!hintN) text(subtitle, picker ? chips || 'Choose a lane to continue' : pane?.title || 'Your workspace');
@@ -405,12 +416,13 @@ if (typeof module === 'object') module.exports = {laneShellMode};
405
416
  panesButton.hidden = pasteKey.hidden = !!picker;
406
417
  const needs = counts ? counts.approval + counts.input : 0;
407
418
  if (needs) lanesButton.dataset.count = String(needs); else delete lanesButton.dataset.count;
408
- updateLaneFilterBar(picker);
419
+ updateLaneFilterBar(tab?.name, picker);
409
420
  send();
410
- const quick = active && !picker;
421
+ const quick = active && (!picker || / · [›>] \w+$/.test(title) && 2);
411
422
  if (quick !== quickOn) {
412
423
  quickOn = quick;
413
424
  quickRow.hidden = !quick;
425
+ for (const key of quickRow.children) key.hidden = quick > 1 && !['⏎', 'y', 'Esc'].includes(key.textContent);
414
426
  root.style.setProperty('--lanes-quick', quick ? '48px' : '0px');
415
427
  resize();
416
428
  }