@vibelord/lanes 1.1.0 → 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 +47 -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 +625 -15
  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 +653 -81
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +792 -140
  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 +207 -25
  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 +128 -76
  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,9 +17,11 @@ 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,
21
- check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture`, `__zjSyncInactiveCursorStyle` and `window.term`
22
- still exist in the served `app.js`.
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,
23
+ check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture` (with `sendFn` = `websockets.sendAnsiKey`),
24
+ `__zjSyncInactiveCursorStyle` and `window.term` still exist in the served `app.js`.
23
25
 
24
26
  The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
25
27
  Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
@@ -30,7 +32,11 @@ The guard is installed before Zellij's first terminal or login modal: otherwise
30
32
  touchstart, touchend, click and server `SetSoftKeyboard` messages. The **Type here** field keeps the original input
31
33
  and its event handlers inside the closed shadow root (including its protection from password-manager extensions) and
32
34
  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
35
+ internal padding/history stays invisible. Upstream creates that input as `type="password" autocomplete="new-password"`
36
+ (to switch off prediction); shown to the user, that gave iOS a secure-entry keyboard and the strong-password bar
37
+ (DEF-1734), so the shell makes it `type="text" autocomplete="off" autocapitalize="none"` and keeps upstream's
38
+ `autocorrect="off"`, `spellcheck="false"`, `inputmode="text"` and the `data-*ignore` attributes. Upstream cancels
39
+ `pointerdown` on the extra keys, which also suppresses
34
40
  their touch click in Safari, so the shell cancels `mousedown` instead: typing focus is kept while the native click
35
41
  still activates CTRL, the arrows and the other keys. The shell attaches only once server state has arrived — resizing
36
42
  before the control socket opens makes 0.45.1 throw `InvalidStateError` and leaves the server with the old size.
@@ -46,7 +52,9 @@ Run `lanes web <device> --token` on the server (it also adds that Tailscale Serv
46
52
  `lanes-launch web phone|laptop` from an installed client, or `sh install/enable-phone.sh --host lanes` (`--local` on
47
53
  the server).
48
54
  The [installation guide](../docs/INSTALL.md) covers Caddy, Tailscale operator permission and the selected SSH key.
49
- 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).
50
58
 
51
59
  `index.html` keeps the upstream asset integrity hashes. When upgrading Zellij, compare the HTML and
52
60
  window hooks against the new version before changing the pinned binary. The mobile JS and CSS have
@@ -60,12 +68,112 @@ is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px).
60
68
  `tests/mobile_view.py` verifies the touch-to-lane path with isolated holders; sidebar unit tests cover the
61
69
  complete touch target.
62
70
 
63
- Every byte the shell sends to a terminal goes through `__zjSendControl({type: 'Write', bytes})`
64
- (`sendToTerminal`); there is no focus-and-type fallback, since the focus guard above would make it type nowhere.
65
- The lane filter bar sends `Esc /` and the query as UTF-8 (`TextEncoder`), dropping control bytes and DEL.
71
+ ### Input, keys and clipboard (phone shell input v2, CAP-142)
72
+
73
+ Every byte the shell sends to a terminal goes through `send()` to `__zjSoftKbdCapture.sendFn`, which is upstream's
74
+ `websockets.sendAnsiKey`: a text frame on the terminal WebSocket, the path the client's own capture input, ESC/arrow
75
+ bar and xterm `onData` use (an armed CTRL/ALT on the modifier bar therefore applies to a one-character key, through
76
+ upstream's `__zjMobileMergeKey`). Until 2026-10-07 the shell posted `__zjSendControl({type: 'Write', bytes})` on the
77
+ control socket, whose message enum has no `Write` variant: the server logged "Ignoring unknown control message type"
78
+ and dropped every quick key and filter keystroke (DEF-1733). The quick keys send `\r`, `y`, `n`, `\x1b`, `\x03`,
79
+ `\x1b[A`, `\x1b[B` — upstream's own arrow bytes (CSI, never SS3; Zellij re-encodes a key for its pane). With no
80
+ `sendFn` the text is queued and sent by the next `refresh()` (no timer); the pinned client installs `sendFn` together
81
+ with the capture element the shell waits for, so the queue only covers an upstream reordering. A key the socket
82
+ refuses (it throws while not open) is dropped, never replayed later into whatever lane is focused then. There is no
83
+ focus-and-type fallback: the focus guard above would make it type nowhere (DEF-1529).
84
+
85
+ The lane filter bar never puts an Esc and other bytes in one frame. The server parses each text frame with termwiz,
86
+ which holds a leading ESC while more bytes follow: `\x1b/abc` became one Alt-`/` key whose raw bytes are the whole
87
+ frame, then `a`, `b`, `c` again as keys of their own, so the picker sidebar got `\x1b/abc` + `abc` and filtered on
88
+ `abcabc` — on every keystroke, since the one-frame filter re-sent the whole query each time (DEF-1751, INFERRED from
89
+ the independent review's reading of the Zellij 0.45.1 source; `tests/web/phone.test.mjs` models it). The filter now
90
+ sends only what changed. When the field goes from empty to text: a lone `Esc` frame (it leaves whatever the sidebar is
91
+ in — an open filter, a rename, a close prompt, the key sheet), then, no sooner than 60 ms later, `/` alone and the
92
+ text. After that, one DEL (`\x7f`, which the sidebar's filter takes as backspace, as it does `\x08`) for each
93
+ character of the old query after its common prefix with the new one, then the new query's characters after that
94
+ prefix; an emptied field or the Esc key sends a lone `Esc`. The text is the `TextEncoder` bytes with control bytes and
95
+ DEL dropped, decoded back to text. There is no `setTimeout`: `send()` keeps a pause marker in its queue after a lone
96
+ `Esc` and re-arms the one interval, whose next tick sends what follows once 60 ms have passed. The phone keeps 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
107
+ sidebar holds what the phone sent, which each new query's leading `Esc` restores — another client typing into the same
108
+ picker pane, or a character the sidebar does not print (a no-break space, a zero-width joiner), puts them out of step
109
+ until the field is cleared; and an `Esc` while the sidebar's upload strip is armed (`U`) returns the focus to the
110
+ lane, so the `/` and text after it could reach the lane unless the tab change arrives within the pause.
111
+
112
+ Typing in **Type here**: one character at a time flows through upstream's `input` handler unchanged (a diff of the
113
+ field against its no-break-space padding, with an 8 ms duplicate filter for the two `deleteContentBackward` events one
114
+ backspace fires). A text field brings back QuickType, whose multi-character inserts would lose a repeated letter to
115
+ that filter (`hello` → `helo`). The shell therefore cancels two cancelable `beforeinput` types and sends their text
116
+ whole: `insertText` longer than one character, and `insertReplacementText` of any length. The text is `data`, or
117
+ `dataTransfer.getData('text')` where an engine leaves `data` null. A replacement first sends one DEL per character it
118
+ replaces: the length of `getTargetRanges()[0]` (its `endOffset − startOffset` in the field) — iOS accepts a QuickType
119
+ suggestion over a partial word with a caret, not a selection, and names the replaced range there — else the field's
120
+ selection, else nothing (DEF-1753; before, only the selection was read, so a caret replacement sent `hehello`). Every
121
+ other type stays upstream's or another handler's: a composition (`insertCompositionText`, which the Input Events spec
122
+ makes uncancelable, and `insertFromComposition`) is upstream's diff, a paste is the `paste` handler's, a delete is a
123
+ key. Caveat, unverified on a device: an engine that names neither the range nor a selection gives the terminal the
124
+ partial word and then the suggestion (`hehello`); delete by hand or type without the suggestion bar.
125
+
126
+ Paste: a `paste` on the field is cancelled and its `text/plain` sent as ONE bracketed paste — `\x1b[200~`, the text
127
+ with `\r?\n` as `\r` and every end marker neutralised, `\x1b[201~` — so upstream's per-character path, which collapsed
128
+ `ll` to `l` and stripped the newlines (DEF-1735), never sees it. Only the end marker is touched: each run of ESCs
129
+ directly before `[201~` is removed (a run, so `\x1b\x1b[201~` or `\x1b[20\x1b[201~1~` cannot reassemble one), so the
130
+ clipboard cannot end the bracket early and run the rest as typed keys, while a deliberate ANSI paste keeps its other
131
+ escape sequences (until DEF-1755 every ESC was removed). On a picker tab there is no paste: the picker sidebar turns
132
+ on bracketed paste to take file drops and reads any paste as one (text says `that was not a file drop`; a pasted path
133
+ becomes an upload attempt), so the header's **Paste** is hidden there and a paste into **Type here** is cancelled,
134
+ sends nothing and says `Open a lane to paste` (DEF-1754). Explicit markers rather than xterm's `term.paste` (also
135
+ reachable, as `window.term`): `term.paste` brackets only when the server has turned on xterm's bracketed-paste mode,
136
+ which nothing in this tree shows; Zellij reads the markers as a paste and removes them for a pane that has not asked
137
+ for bracketed paste.
138
+
139
+ **Copy** and **Paste** sit in the header beside **More** (nine 44 px quick keys do not fit a 390 px phone, so the row
140
+ keeps its seven). Copy writes the terminal's selection when xterm has one, else the visible rows of
141
+ `window.term.buffer.active` (`translateToString(true)` per row, trailing blank rows dropped), with
142
+ `navigator.clipboard.writeText` called inside the tap: Safari refuses a write outside a user gesture, which is why the
143
+ client's clipboard add-on (OSC 52) and copy-on-select never reached the phone — upstream cancels `touchstart`, a drag
144
+ scrolls and a long press is a right click (DEF-1736). The buffer is the only text source: `initTerminal` loads xterm's
145
+ WebGL renderer (`addon-webgl`), so the DOM holds no row text. The copy is the whole visible screen, Zellij's frames
146
+ and bars included. Paste calls `navigator.clipboard.readText()` inside the tap (iOS shows its own Paste confirmation)
147
+ and sends the text through the bracketed path above; it is hidden on a picker tab. Both cancel `mousedown`, so an open
148
+ keyboard stays open. The header subtitle says `Copied`, `Pasted` or `Clipboard blocked` (no clipboard API, or a
149
+ refusal).
150
+
151
+ Hints (`Hold to send` and the clipboard results) hold the header subtitle for eight ticks of the one 200 ms interval:
152
+ `hint()` re-arms it, `tick()` counts down, restores the subtitle through `refresh()` and clears the interval again,
153
+ and `refresh()` leaves the subtitle alone while a hint is up (before, the next `MobileState` refresh overwrote it at
154
+ once, DEF-1739). **More** toggles: upstream's capture-phase `pointerdown` hook closes an open menu before the button's
155
+ click, which then reopened it (DEF-1738), so a window capture-phase `pointerdown` listener records whether the menu was
156
+ open and the click then leaves it closed. On a picker tab the dock shows the filter bar and hides **Panes** (a picker
157
+ tab has one pane, and upstream's pane list duplicates the picker): four items for the four grid columns, where five
158
+ wrapped **Type here** below the fixed-height dock (DEF-1737).
159
+
160
+ The pinned `app.js` facts this section relies on (`sendFn` = `sendAnsiKey`, the capture input's attributes and
161
+ handlers, the menu dismiss hook, `window.term`, the WebGL addon) were read on 2026-10-07 from the asset embedded in
162
+ the zellij 0.45.1 release binary: the bytes between the `app.js` and `auth.js` names in its asset table hash to this
163
+ `index.html`'s SRI pin (sha384 `zEYVY/2Dhk…`). `tests/web/phone.test.mjs` runs `mobile.js` against a fake page shaped
164
+ on those facts (`node --test`, no jsdom).
165
+
66
166
  Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
67
167
  `parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
68
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.
69
177
  Until plan Task 26 (2026-10-04) this reader was inert (DEF-1531): `bin/lanes` `mobile_workspace_tab` gave the
70
178
  picker pane a fixed `name="Lanes"`, and Zellij reports a pane's layout name instead of its OSC 2 title, so
71
179
  `pickerTitle()` read `Lanes`, `parseCounts` returned null and the shell showed no counts ("Choose a lane to continue").
@@ -74,9 +182,15 @@ phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-
74
182
  `lanes doctor` names such a device). Whether the OSC 2 title now reaches
75
183
  `panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
76
184
  sidebar header). The quick-key row (`⏎ y n Esc ^C ↑ ↓`, `.lanes-quick`) shows on view
77
- tabs only; `refresh()` sets `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
78
- only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); the buttons cancel `mousedown`
79
- so a tap never moves focus.
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
190
+ only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); `pointercancel` and `pointerleave`
191
+ disarm a key, and a `pointerup` without the key's own `pointerdown` sends nothing (before, a cancelled hold stayed
192
+ armed, so a pointer released over `^C` later sent it, DEF-1739). The buttons cancel `mousedown` so a tap never moves
193
+ focus.
80
194
 
81
195
  Colour (spec §6.3; the operator legend, token table and phone mockup are in [`docs/DESIGN.md`](../docs/DESIGN.md)):
82
196
  `mobile.css` uses only the generated `--lanes-*` tokens (legacy names such as `--lanes-bg` stay
@@ -90,46 +204,114 @@ chaining `window.__zjSyncInactiveCursorStyle`, which the pinned `app.js` calls i
90
204
  assigning the server theme wholesale (`SetConfig` is sent on each config-file reload). The handler then runs
91
205
  `applyTerminalBackground`, which writes the dark background inline on `<body>` and `#terminal`; the stylesheet's
92
206
  `!important` `--lanes-surface` rule for those two elements outranks it in either variant. An SRI bump of `app.js`
93
- re-opens this contract: re-read `startWsControl` and `installSoftKeyboardCapture` before accepting it.
94
- `index.html` carries one `theme-color` per colour scheme. Bundle budget (spec §8, `test_web.test_bundle_growth`):
95
- `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 22 174 B; the caps are never raised — the rules sit one per line and the
96
- explanations live here rather than in the shipped files. MEASURED 2026-10-04 with `wc -c` after LNS-31 (plan Task
97
- 31) met Tasks 13/28: `mobile.js` 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css`
98
- 18 945 B. A laptop or tablet also fetches `browser.js` (3 641 B) and `browser.css` (1 947 B); `test_web_view` pins
99
- that download at `LAPTOP_TABLET_JS_RATCHET` and `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
207
+ re-opens this contract: re-read `startWsControl`, `installSoftKeyboardCapture`, `initWebSockets` (`sendAnsiKey`) and
208
+ `installMenuDismissHook` before accepting it.
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
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`
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.
100
240
 
101
241
  ## Code notes
102
242
 
103
243
  `mobile.js` carries no explanations (the spec §8 budget above); each note is keyed to the code it explains, in file
104
244
  order.
105
245
 
106
- - 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).
107
248
  - `root.dataset.lanesShell = mode`: read by `browser.js` for the desktop bar and the tablet's full rendering.
108
249
  - `if (mode !== 'phone')`: appends the laptop/tablet extras (`browser.css`, `browser.js`); a phone never fetches them.
109
250
  - `if (mode === 'tablet')`: iPad Safari's user agent is a desktop one, so the shell asks for the client's mobile UI
110
251
  (`zellij:mobile-ui` set to `on`); a stored `off` stays.
111
252
  - The `HTMLElement.prototype.focus` guard: only a native tap on an input may open the keyboard; it is installed before
112
253
  upstream's first terminal or modal.
254
+ - `kbd()` / `aria()`: `window.__zjSoftKbdCapture` and an `aria-label` setter, shared to keep the phone budget.
113
255
  - `viewport()`: browser toolbars alone must not mean keyboard mode (`occluded` needs more than 120 px hidden). Zellij's
114
256
  input is in a closed shadow root, so `document.activeElement` is its host; `__zjSoftKbdCapture.isFocused` is read
115
257
  first.
116
- - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field.
258
+ - `setKeyboard()`: also the one place **Type here** gets `is-typing`, driven by upstream's own focus/blur listeners
259
+ (they set `isFocused` and fire `zellij:soft-keyboard-visibility`); the field's own focus/blur listeners were redundant.
260
+ - `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field:
261
+ a text input (DEF-1734); `beforeinput` sends a multi-character `insertText` or any `insertReplacementText` whole,
262
+ after one DEL per character of `getTargetRanges()[0]` or else the selection (DEF-1753); `paste` sends one bracketed
263
+ paste (DEF-1735). See Input, keys and clipboard above.
117
264
  - `GLYPHS` / `parseCounts()`: counts from the picker sidebar's OSC 2 pane title (spec §6.4).
265
+ - `build()`, the header: the window capture-phase `pointerdown` listener records `menuWas` before upstream's dismiss
266
+ hook runs, so **More** closes an open menu instead of reopening it (DEF-1738); **Copy** and **Paste** (DEF-1736).
118
267
  - `build()`, the `.zj-mobile-modbar` keys: cancel `mousedown`, not `pointerdown` (upstream's `pointerdown` cancel kills
119
268
  Safari's touch click on the extra keys).
269
+ - `send()`: the terminal socket through `sendFn` (DEF-1733); queued only while `sendFn` is absent, never after a throw.
270
+ A `null` in the queue is a pause: what follows waits until 60 ms after the last frame sent (`sentAt`), and `send()`
271
+ re-arms the one interval to come back (DEF-1751).
272
+ - `paste`: the bracketed paste through `send()`, its end markers neutralised (DEF-1755); nothing on a picker tab, where
273
+ the sidebar reads a paste as a file drop (DEF-1754).
274
+ - `applyLaneFilter()`: the filter's UTF-8 query as deltas against `laneFilterPrev` — a lone Esc, a pause, `/` and the
275
+ text for a new query; DELs and the added text for an edit; a lone Esc and a pause to clear (DEF-1751).
276
+ - `clip()`: calls the clipboard API synchronously inside the tap (Safari's user-gesture rule) and hints the result.
277
+ - `screen()`: xterm's selection, else the visible rows of `term.buffer.active` (the WebGL renderer leaves no DOM text).
278
+ - `hint()`: holds the subtitle for eight ticks of the one interval (DEF-1739).
120
279
  - `buildQuickRow()` (spec §6.5): `n` and `^C` send after a 400 ms hold, measured from event timestamps with no timer;
121
- nothing takes focus.
122
- - `clearLaneFilter()`: the lone `Esc` leaves the sidebar's filter mode.
280
+ a cancelled or left key is disarmed and a release needs the key's own press (DEF-1739); nothing takes focus.
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.
123
287
  - `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
288
+ - `refresh()`: leaves the subtitle to a live hint, hides **Panes** (and **Paste**, DEF-1754) on a picker tab so the
289
+ dock's four columns hold its four items (DEF-1737), and flushes the `send()` queue after `updateLaneFilterBar()`, so a
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.
124
294
  - `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
125
295
  re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
126
296
  toggle.
127
297
  - The `zellij:soft-keyboard-visibility` listener: rotation also shrinks the viewport, so the shell sends
128
298
  `SoftKeyboardVisibilityChanged` itself to correct upstream's height-only keyboard heuristic.
129
- - `awaitingClient` (the one `setInterval`, 200 ms, cleared once attached): upstream installs its window hooks just after
130
- appending its mobile root.
299
+ - `tick()` / `arm()` (the one `setInterval`, 200 ms): polls `connect()` until attached, because upstream installs its
300
+ window hooks just after appending its mobile root; `hint()` and a paused `send()` re-arm it; each tick flushes the
301
+ queue, and it clears itself once attached, with no hint up and no pause waiting.
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.
131
311
 
132
312
  Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
133
313
  838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
134
- lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens.
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).
135
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;