@vibelord/lanes 1.0.1 → 1.0.3

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 (52) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +664 -50
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +87 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/INSTALL.md +37 -2
  19. package/payload/lanes/docs/RUNBOOK.md +458 -39
  20. package/payload/lanes/docs/SHARING.md +240 -5
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders.lock +13 -0
  23. package/payload/lanes/install/enable-studio-host.sh +14 -0
  24. package/payload/lanes/install/host-install.sh +15 -3
  25. package/payload/lanes/install/services.py +128 -4
  26. package/payload/lanes/install.sh +108 -3
  27. package/payload/lanes/libexec/agent_state.py +32 -0
  28. package/payload/lanes/libexec/cursor_sync.py +10 -2
  29. package/payload/lanes/libexec/economy_poll.py +685 -0
  30. package/payload/lanes/libexec/guard.py +27 -1
  31. package/payload/lanes/libexec/lanes_lib.py +285 -38
  32. package/payload/lanes/libexec/notify.py +317 -0
  33. package/payload/lanes/libexec/registrations.py +1 -1
  34. package/payload/lanes/libexec/session_actions.py +7 -8
  35. package/payload/lanes/libexec/sidebar.py +911 -218
  36. package/payload/lanes/libexec/statusd.py +258 -74
  37. package/payload/lanes/libexec/studio_host.py +1608 -0
  38. package/payload/lanes/libexec/tokens.py +38 -7
  39. package/payload/lanes/libexec/view.sh +13 -3
  40. package/payload/lanes/systemd/lanes-economy.service +20 -0
  41. package/payload/lanes/systemd/lanes-notify.service +19 -0
  42. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  43. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  44. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  45. package/payload/lanes/systemd/lanes-sync.service +3 -0
  46. package/payload/lanes/web/README.md +83 -4
  47. package/payload/lanes/web/index.html +2 -1
  48. package/payload/lanes/web/mobile.css +53 -198
  49. package/payload/lanes/web/mobile.js +87 -55
  50. package/payload/lanes/zellij/config.kdl +67 -16
  51. package/payload/manifest.json +5 -3
  52. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """The Lanes design tokens: ONE table, rendered four ways. The sidebar imports `sgr_table`; `--kdl`, `--css`, `--sh`
3
- and `--md` write the Zellij theme, the phone CSS, the viewer's shell variables and the docs table between markers;
4
- `--check` fails when any committed block differs. Spec: docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md.
2
+ """The Lanes design tokens: ONE table, rendered seven ways. The sidebar imports `sgr_table`; `--kdl`, `--css`, `--sh`,
3
+ `--md`, `--js` and `--ghostty-dark|--ghostty-light` write the Zellij theme, the phone CSS, the viewer's shell variables,
4
+ the docs table, the phone's terminal palette and the optional Ghostty palette files between markers; `--check` fails
5
+ when any committed block differs. Spec: docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md.
5
6
  No third-party code; importable with no side effects."""
6
7
  import difflib
7
8
  import os
@@ -215,10 +216,40 @@ def render_md():
215
216
  return '\n'.join(rows) + '\n'
216
217
 
217
218
 
218
- RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md}
219
- TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md'}
219
+ def render_js():
220
+ """The phone's xterm.js slots for BOTH variants. Zellij's web_client.theme is one server-wide (dark) palette that
221
+ arrives in the HTTP boot config and that SetConfig re-sends on every config reload, assigned wholesale; mobile.js
222
+ applies these on attach and re-applies them after each SetConfig (spec §6.3; DEF-1413). 495 B against mobile.js's
223
+ 3072 B allowance (measured 2026-10-04)."""
224
+ body = ', '.join(f"{v}: [{', '.join(repr(c) for c in PALETTE16[v])}]" for v in ('dark', 'light'))
225
+ return ('// BEGIN lanes tokens (generated by libexec/tokens.py --js; do not edit by hand)\n'
226
+ f' const LANES_ANSI = {{{body}}};\n // END lanes tokens\n')
227
+
228
+
229
+ def render_ghostty(variant):
230
+ """The optional Ghostty palette (spec §7; `install.sh --client --palette`): colours only — never font, theme,
231
+ padding or cursor style, the everyday config keeps those. Valid both as an included config file and as a Ghostty
232
+ theme file, so install.sh can include the dark one directly or pair both through `theme = light:…,dark:…`."""
233
+ t, p = TOKENS[variant], PALETTE16[variant]
234
+ lines = [f'# BEGIN lanes tokens (generated by libexec/tokens.py --ghostty-{variant}; do not edit by hand)',
235
+ '# Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).',
236
+ '# Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.',
237
+ f'background = {t["surface"]}', f'foreground = {t["ink"]}', f'cursor-color = {t["accent"]}',
238
+ f'selection-background = {t["line"]}', f'selection-foreground = {t["ink"]}']
239
+ lines += [f'palette = {i}={c}' for i, c in enumerate(p)]
240
+ lines.append('# END lanes tokens')
241
+ return '\n'.join(lines) + '\n'
242
+
243
+
244
+ RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md, 'js': render_js,
245
+ 'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light')}
246
+ TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md',
247
+ 'js': 'web/mobile.js', 'ghostty-dark': 'client/ghostty/lanes-palette.conf',
248
+ 'ghostty-light': 'client/ghostty/lanes-palette-light.conf'}
220
249
  MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
221
- 'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->')}
250
+ 'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->'),
251
+ 'js': ('// BEGIN lanes tokens', '// END lanes tokens'),
252
+ 'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens')}
222
253
 
223
254
 
224
255
  def render(kind):
@@ -270,7 +301,7 @@ def main(argv):
270
301
  return 0
271
302
  kind = argv[0].lstrip('-') if argv else ''
272
303
  if kind not in RENDERERS:
273
- sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--check|--write')
304
+ sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--check|--write')
274
305
  sys.stdout.write(render(kind))
275
306
  return 0
276
307
 
@@ -58,12 +58,22 @@ reset_terminal() {
58
58
  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
59
  }
60
60
 
61
+ # The 16-colour SGR literals of the design tokens (libexec/tokens.py --sh): sourced once, printf only, no fork.
62
+ # They hold `\033[…m` as text, so they go into printf's FORMAT (which turns `\033` into ESC), never into a %s argument.
63
+ # shellcheck source=libexec/tokens.sh
64
+ . "$repo/libexec/tokens.sh"
65
+
61
66
  # 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.
62
70
  status_screen() {
63
71
  reset_terminal
64
- if [ -n "${4:-}" ]; then printf '\n \033[1m%s\033[0m\n\n %s\n' "$4" "$1"
65
- else printf '\n \033[1m%s\033[0m\n' "$1"; fi
66
- printf '\n %s\n\n \033[2m%s\033[0m\n' "$2" "$3"
72
+ glyphs=unicode; { IFS= read -r glyphs < "$state/clients/$device.glyphs"; } 2>/dev/null || :
73
+ 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"
75
+ else printf "\n ${LZ_BOLD}%s${LZ_RESET}\n" "$1"; fi
76
+ printf "\n %s\n\n ${LZ_DIM}%s${LZ_RESET}\n" "$2" "$3"
67
77
  }
68
78
 
69
79
  # Is the socket of the lane just shown still there? Then only this VIEWER ended: a lane lives in its holder's server
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=lanes economy projection (opt-in: GETs the Throttle daemon's loopback /status and /sessions, writes economy.json)
3
+ After=lanes-statusd.service
4
+
5
+ # No installer enables this unit. It reads the production daemon's control port, so switching it on is an operator
6
+ # action: systemctl --user enable --now lanes-economy.service (RUNBOOK "Economy projection (opt-in)").
7
+ # It runs on the HOST, not in the daemon's container: point LANES_THROTTLE_CONTAINER at that container for the log,
8
+ # and use LANES_THROTTLE_CONTROL=none unless the control port is reachable from the host (RUNBOOK, DEF-1525).
9
+ [Service]
10
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/economy_poll.py" --interval 10
11
+ Restart=on-failure
12
+ RestartSec=10
13
+ Nice=10
14
+ NoNewPrivileges=true
15
+ MemoryHigh=96M
16
+ MemoryMax=192M
17
+ Slice=lanes.slice
18
+
19
+ [Install]
20
+ WantedBy=default.target
@@ -0,0 +1,19 @@
1
+ [Unit]
2
+ Description=lanes push notifications (opt-in: POSTs needs-you transitions from status.json to an ntfy topic)
3
+ After=lanes-statusd.service
4
+
5
+ # No installer enables this unit. It sends lane names and agent tasks to the ntfy server in
6
+ # ~/.config/lanes/notify.conf (LANES_NOTIFY_CONF), so switching it on is an operator action:
7
+ # systemctl --user enable --now lanes-notify.service (RUNBOOK "Push notifications (opt-in)").
8
+ [Service]
9
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/notify.py" --interval 3
10
+ Restart=on-failure
11
+ RestartSec=10
12
+ Nice=10
13
+ NoNewPrivileges=true
14
+ MemoryHigh=48M
15
+ MemoryMax=96M
16
+ Slice=lanes.slice
17
+
18
+ [Install]
19
+ WantedBy=default.target
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=lanes: adopt a deploy receipt or registration key for the co-located Studio Host, then start it
3
+ Documentation=https://github.com/bwllaming/bw-tools/blob/main/lanes/docs/SHARING.md
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ # Acts only on something NEW (an unadopted receipt, a key not tried in the last hour, a fresh enrolment the Host
8
+ # has not started with); otherwise one log line and exit 0. Never restarts a Host that refused the same enrolment.
9
+ ExecStart=/bin/sh "@REPO@/install/enable-studio-host.sh" --retry
10
+ Nice=5
11
+ Slice=lanes.slice
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=lanes: retry the co-located Studio Host's enrolment every 5 minutes (opt-in, enabled by enable-studio-host.sh)
3
+
4
+ [Timer]
5
+ OnActiveSec=1min
6
+ OnUnitActiveSec=5min
7
+ AccuracySec=30s
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,37 @@
1
+ [Unit]
2
+ Description=lanes: Throttle Studio Host co-located on this lanes server (opt-in: install/enable-studio-host.sh)
3
+ Documentation=https://github.com/bwllaming/bw-tools/blob/main/lanes/docs/SHARING.md
4
+ # The Host reads the harness's status.json; it starts after the observer and asks for it, but does not die with it.
5
+ After=lanes-statusd.service
6
+ Wants=lanes-statusd.service
7
+
8
+ [Service]
9
+ Type=simple
10
+ # The binary is the installer-selected, sha256-verified link (`lanes studio-host install`, holders.lock).
11
+ # The config the installer wrote, in lanes-server mode (design §8.10 C2): %h/.config/…, or under $XDG_CONFIG_HOME
12
+ # when the installing account set one. services.py ALWAYS pins XDG_CONFIG_HOME/XDG_DATA_HOME above (the installer's,
13
+ # else %h/.config and %h/.local/share), so the user manager's own environment never moves the credential.
14
+ ExecStart="@THROTTLE_RC@" studio host --config "@STUDIO_HOST_CONFIG@"
15
+ Restart=on-failure
16
+ # 2 s growing to 60 s, as `throttle-rc studio service` renders it. RestartSteps=/RestartMaxDelaySec= need
17
+ # systemd 254; an older manager ignores them with a warning, keeps RestartSec=2, and its start limit stops a loop.
18
+ RestartSec=2
19
+ RestartSteps=5
20
+ RestartMaxDelaySec=60
21
+ # Exit 3 is the Host's TYPED refusal (not enrolled yet, revoked, another Host holds the lock). Restarting into it
22
+ # fixes nothing, so it is a clean stop. Waiting for enrolment is exactly this state: lanes-studio-host-enrol.timer
23
+ # starts the unit again once a deploy receipt or a registration key has enrolled the machine.
24
+ SuccessExitStatus=3
25
+ # The Host's graceful stop (detach every channel, then `stopped`) runs on SIGINT.
26
+ KillSignal=SIGINT
27
+ # The Host only, never the cgroup: tmux clients it attached stay with their lanes; it reaps its own on a clean stop.
28
+ KillMode=process
29
+ TimeoutStopSec=20
30
+ # Deliberately no NoNewPrivileges= (design §8.9): it is inherited for life by anything the Host's children start.
31
+ Nice=5
32
+ MemoryHigh=512M
33
+ MemoryMax=1G
34
+ Slice=lanes.slice
35
+
36
+ [Install]
37
+ WantedBy=default.target
@@ -4,6 +4,9 @@ Description=lanes registry sync (read-only: rebuild registry.json from the repos
4
4
  [Service]
5
5
  Type=oneshot
6
6
  ExecStart="@REPO@/bin/lanes" sync
7
+ # Bounded: one `docker inspect` (20 s) plus the registry reads. A sync wedged on a hung docker or volume is
8
+ # stopped here instead of holding the oneshot (and every later timer tick) forever.
9
+ TimeoutStartSec=120
7
10
  # A momentarily invalid registry keeps the previous lanes; a non-zero exit here is informational.
8
11
  SuccessExitStatus=1
9
12
  Nice=5
@@ -11,8 +11,9 @@ workspace sheet as a left panel, and set `zellij:mobile-ui` to `on` when it is u
11
11
  user agent). Desktop browsers (fine pointer) get only a **Lanes** chip and an overlay bar with workspace and lane
12
12
  pickers (`laneBarModel`, from the `MobileState` sent to every web client). The laptop/tablet code and styles live in
13
13
  `browser.js`/`browser.css`, which `mobile.js` appends only on a tablet or desktop (a phone never downloads them), so
14
- the phone's `mobile.js`/`mobile.css` budget (spec §8) stays with the phone. The chip cancels `mousedown` and
15
- refocuses the terminal, so the keys stay the terminal's. On a tablet, `browser.js` also undoes the client's per-load single-pane start once
14
+ a phone downloads only the two files that spec §8 budgets (measured figures under Bundle budget below). The chip
15
+ cancels `mousedown` and refocuses the terminal, so the keys stay the terminal's. On a tablet, `browser.js` also
16
+ undoes the client's per-load single-pane start once
16
17
  (`laneFullRender`, the client's own `SetMobileRenderPreferences {single_pane: false, fit: true}`), so a desktop
17
18
  view's sidebar shows beside its lanes. `tests/web/activation.test.mjs` (`node --test`) pins the activation matrix,
18
19
  the picker model and that render preference; `tests/unit/test_web_view.py` pins the size budgets and runs that
@@ -25,6 +26,14 @@ Zellij's original capture input in its closed shadow root, preserving its keyboa
25
26
  blocks programmatic focus on text editors and keeps `__zjSoftKbdEnabled` false, so upstream touch handlers,
26
27
  two-finger gestures, server `SetSoftKeyboard` messages and modal autofocus cannot summon it. Search and
27
28
  login inputs still focus normally when tapped. Keep this policy in place when upgrading the upstream client.
29
+ The guard is installed before Zellij's first terminal or login modal: otherwise Zellij refocuses a hidden input on
30
+ touchstart, touchend, click and server `SetSoftKeyboard` messages. The **Type here** field keeps the original input
31
+ and its event handlers inside the closed shadow root (including its protection from password-manager extensions) and
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
34
+ their touch click in Safari, so the shell cancels `mousedown` instead: typing focus is kept while the native click
35
+ still activates CTRL, the arrows and the other keys. The shell attaches only once server state has arrived — resizing
36
+ before the control socket opens makes 0.45.1 throw `InvalidStateError` and leaves the server with the old size.
28
37
 
29
38
  `lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
30
39
  It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
@@ -41,8 +50,78 @@ clearing login cookies. There is no service worker caching terminal data or cred
41
50
  Layout uses the visual viewport and safe-area insets, with independent handling for portrait, landscape,
42
51
  keyboard-open and Home Screen views. The default terminal font is 16 px; the user's text-size choice
43
52
  is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px). The lane picker in
44
- `libexec/sidebar.py` uses three terminal rows per lane on mobile. `tests/mobile_view.py` verifies
45
- the touch-to-lane path with isolated holders; sidebar unit tests cover the complete touch target.
53
+ `libexec/sidebar.py` uses three terminal rows per lane on mobile: name, state and age, the agent's task.
54
+ `tests/mobile_view.py` verifies the touch-to-lane path with isolated holders; sidebar unit tests cover the
55
+ complete touch target.
56
+
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.
60
+ Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
61
+ `parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
62
+ `data-count` and the workspace cards, inside the existing `refresh()` / `renderCards()` — no fetch, file or timer.
63
+ Until plan Task 26 (2026-10-04) this reader was inert (DEF-1531): `bin/lanes` `mobile_workspace_tab` gave the
64
+ picker pane a fixed `name="Lanes"`, and Zellij reports a pane's layout name instead of its OSC 2 title, so
65
+ `pickerTitle()` read `Lanes`, `parseCounts` returned null and the shell showed no counts ("Choose a lane to continue").
66
+ Task 26 dropped that `name=` (pinned by `tests/unit/test_layout.py`) for sessions laid out since: an existing
67
+ phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-session` never re-lays-out a tab;
68
+ `lanes doctor` names such a device). Whether the OSC 2 title now reaches
69
+ `panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
70
+ sidebar header). The quick-key row (`⏎ y n Esc ^C ↑ ↓`, `.lanes-quick`) shows on view
71
+ 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.
74
+
75
+ Colour (spec §6.3; the operator legend, token table and phone mockup are in [`docs/DESIGN.md`](../docs/DESIGN.md)):
76
+ `mobile.css` uses only the generated `--lanes-*` tokens (legacy names such as `--lanes-bg` stay
77
+ as aliases for one release); `test_web` rejects any colour literal outside the generated block. The terminal palette
78
+ is the phone's own, because Zellij's `web_client.theme` is one server-wide dark palette (DEF-1413): `lanesTheme()`
79
+ builds xterm.js's theme from the CSS tokens (`getComputedStyle(root)`) and the sixteen `LANES_ANSI` slots, which
80
+ `libexec/tokens.py --js` generates into `mobile.js` (never hand-edited). It is applied at three points, all event
81
+ driven: (1) on attach, after `build(); applyFont();`; (2) on every `data-zj-theme` change, through one
82
+ `MutationObserver` on `<html>` (upstream's theme toggle only flips that attribute); (3) after every `SetConfig`, by
83
+ chaining `window.__zjSyncInactiveCursorStyle`, which the pinned `app.js` calls in its `SetConfig` handler right after
84
+ assigning the server theme wholesale (`SetConfig` is sent on each config-file reload). The handler then runs
85
+ `applyTerminalBackground`, which writes the dark background inline on `<body>` and `#terminal`; the stylesheet's
86
+ `!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.
88
+ `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).
94
+
95
+ ## Code notes
96
+
97
+ `mobile.js` carries no explanations (the spec §8 budget above); each note is keyed to the code it explains, in file
98
+ order.
99
+
100
+ - The file: the Lanes browser shell (phone, tablet, desktop) over the pinned Zellij 0.45.1 client's window hooks.
101
+ - `root.dataset.lanesShell = mode`: read by `browser.js` for the desktop bar and the tablet's full rendering.
102
+ - `if (mode !== 'phone')`: appends the laptop/tablet extras (`browser.css`, `browser.js`); a phone never fetches them.
103
+ - `if (mode === 'tablet')`: iPad Safari's user agent is a desktop one, so the shell asks for the client's mobile UI
104
+ (`zellij:mobile-ui` set to `on`); a stored `off` stays.
105
+ - The `HTMLElement.prototype.focus` guard: only a native tap on an input may open the keyboard; it is installed before
106
+ upstream's first terminal or modal.
107
+ - `viewport()`: browser toolbars alone must not mean keyboard mode (`occluded` needs more than 120 px hidden). Zellij's
108
+ input is in a closed shadow root, so `document.activeElement` is its host; `__zjSoftKbdCapture.isFocused` is read
109
+ first.
110
+ - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field.
111
+ - `GLYPHS` / `parseCounts()`: counts from the picker sidebar's OSC 2 pane title (spec §6.4).
112
+ - `build()`, the `.zj-mobile-modbar` keys: cancel `mousedown`, not `pointerdown` (upstream's `pointerdown` cancel kills
113
+ Safari's touch click on the extra keys).
114
+ - `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.
117
+ - `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
118
+ - `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
119
+ re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
120
+ toggle.
121
+ - The `zellij:soft-keyboard-visibility` listener: rotation also shrinks the viewport, so the shell sends
122
+ `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.
46
125
 
47
126
  Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
48
127
  838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
@@ -4,7 +4,8 @@
4
4
  <head>
5
5
  <meta charset="UTF-8" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
7
- <meta name="theme-color" content="#101318" />
7
+ <meta name="theme-color" content="#101318" media="(prefers-color-scheme: dark)" />
8
+ <meta name="theme-color" content="#f3f5f2" media="(prefers-color-scheme: light)" />
8
9
  <meta name="apple-mobile-web-app-capable" content="yes" />
9
10
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
10
11
  <meta name="apple-mobile-web-app-title" content="Lanes" />