@vibelord/lanes 1.1.1 → 1.3.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 (71) 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 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  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 +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -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
  }
@@ -0,0 +1,29 @@
1
+ [Unit]
2
+ Description=lanes docs service (login-bound docs, artifacts and uploads) on loopback 127.0.0.1:8185
3
+
4
+ # Enabled and restarted by every server install (install/host-install.sh). lanes-mobile's Caddy forwards
5
+ # https://<host>:8443/lanes-docs/* here (web/Caddyfile `handle_path /lanes-docs/*`). It reads and writes the docs store
6
+ # ~/.local/state/lanes/docs/ and docs.log and reads members/, enrol-mode and registry.json; its publish pick-up (W4)
7
+ # reads each workspace container's ~/.lz/state/publish/ through /proc/<pid>/root, as lanes-statusd does, and writes the
8
+ # receipts back there; and (W5) it lands uploads in a workspace's uploads/ through the container's home volume. Hence
9
+ # it has no ProtectHome.
10
+ # It answers nothing until the operator pins the Serve header check (`lanes enrol mode request`, read on every
11
+ # request). Optional settings (LANES_DOCS_DOMAINS, LANES_DOCS_PORT, and the default-off encryption at rest
12
+ # LANES_DOCS_AGE_RECIPIENT / LANES_DOCS_AGE_IDENTITY, which `lanes upload` reads from the same drop-in) go in a drop-in,
13
+ # `systemctl --user edit lanes-docsd.service`, which a re-install keeps (RUNBOOK "Docs service").
14
+ # Memory: docsd holds at most two 32 MiB buffers at once (UPLOAD_SLOTS, shared by uploads and decryptions, one per
15
+ # login), two Markdown renders and an 8 MiB render cache, and its publish pick-up reads at most 4,096 directory entries
16
+ # of a container per cycle, so what a container plants or a member sends stays under MemoryMax (RUNBOOK "Docs service",
17
+ # Limits; the composed peak is not measured: DEF-1917).
18
+ [Service]
19
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/docsd.py"
20
+ Restart=on-failure
21
+ RestartSec=10
22
+ NoNewPrivileges=true
23
+ UMask=0077
24
+ MemoryHigh=128M
25
+ MemoryMax=192M
26
+ Slice=lanes.slice
27
+
28
+ [Install]
29
+ WantedBy=default.target
@@ -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
@@ -8,12 +8,35 @@
8
8
  http://:8083 {
9
9
  bind 127.0.0.1
10
10
  root * "{$LANES_REPO}/web"
11
- header {
11
+ # lanes-docsd answers /lanes-docs/* with its own security headers per route (an agent's HTML or SVG artifact is a
12
+ # sandboxed top-level document), so the site-wide set below skips that prefix: docsd's are the only ones it sends.
13
+ @notdocs not path /lanes-docs/*
14
+ header @notdocs {
12
15
  X-Content-Type-Options nosniff
13
16
  X-Frame-Options DENY
14
17
  Referrer-Policy strict-origin-when-cross-origin
15
18
  Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; img-src 'self' data:"
16
19
  }
20
+ # lanes-docsd.service (libexec/docsd.py): the docs cockpit, login-bound pages, downloads, artifacts and uploads.
21
+ # handle_path strips /lanes-docs. It stays the FIRST handle of the site: Caddy never reaches a block declared after
22
+ # a handle with a named matcher (the Zellij routes below).
23
+ handle_path /lanes-docs/* {
24
+ reverse_proxy 127.0.0.1:8185 {
25
+ # As on /enrol/*: keep the X-Forwarded-For Tailscale Serve sets (the client's tailnet address): docsd
26
+ # answers only a request that carries one, and only with Serve's identity headers. Caddy >= 2.5.
27
+ trusted_proxies 127.0.0.0/8
28
+ }
29
+ }
30
+ # The docs page's pinned Mermaid (plan W10 W6, LNS-107): install/enable-docs.sh puts holders.lock's pin into
31
+ # $LANES_SHARE/vendor (services.py sets LANES_SHARE in lanes-mobile.service). Immutable: a page names a VERSIONED
32
+ # file, so a new pin is a new URL (DEF-1870). Caddy sorts a single-path route ahead of /lanes-assets/* only past
33
+ # routes that also have a single path (Caddy issue #5037; DEF-1872): keep this block with the single-path routes
34
+ # above the named-matcher ones (/lanes-docs/* does not overlap it, so their order between themselves is free).
35
+ handle_path /lanes-assets/vendor/* {
36
+ root * "{$LANES_SHARE}/vendor"
37
+ header Cache-Control "public, max-age=31536000, immutable"
38
+ file_server
39
+ }
17
40
  handle_path /lanes-assets/* {
18
41
  header Cache-Control "no-cache"
19
42
  file_server
@@ -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
 
@@ -41,16 +43,22 @@ before the control socket opens makes 0.45.1 throw `InvalidStateError` and leave
41
43
 
42
44
  `lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
43
45
  It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
44
- the original Zellij service stays on 8082. Caddy (2.5 or later) must already be installed on the host. `/enrol/*` is
45
- the one route that is neither these assets nor Zellij: `handle_path` strips `/enrol` and forwards to the enrolment
46
- service on 127.0.0.1:8184 (`lanes-enrold.service`, [runbook](../docs/RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3));
46
+ the original Zellij service stays on 8082. Caddy (2.5 or later) must already be installed on the host. `/enrol/*` and
47
+ `/lanes-docs/*` are the routes that are neither these assets nor Zellij: `handle_path` strips `/enrol` and forwards to
48
+ the enrolment service on 127.0.0.1:8184 (`lanes-enrold.service`, [runbook](../docs/RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3));
47
49
  it must stay before the Zellij routes, and its `trusted_proxies 127.0.0.0/8` keeps the `X-Forwarded-For` Tailscale
48
- Serve sets (the client's tailnet address), so the redeem rate limit counts per device.
50
+ Serve sets (the client's tailnet address), so the redeem rate limit counts per device. `/lanes-docs/*` (prefix
51
+ stripped) goes to the docs service on 127.0.0.1:8185 (`lanes-docsd.service`,
52
+ [runbook](../docs/RUNBOOK.md#docs-service-lanes-docsd-w10-w3)): it is the FIRST handle of the site, keeps Serve's
53
+ `X-Forwarded-For` the same way, and the site-wide `header` block skips it (`header @notdocs`, `@notdocs not path
54
+ /lanes-docs/*`) so docsd's own per-route security headers are the only ones a document gets.
49
55
  Run `lanes web <device> --token` on the server (it also adds that Tailscale Serve mapping when it is missing),
50
56
  `lanes-launch web phone|laptop` from an installed client, or `sh install/enable-phone.sh --host lanes` (`--local` on
51
57
  the server).
52
58
  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.
59
+ Service registration supplies `LANES_REPO` — the `~/.local/share/lanes/current` link, which names the installed
60
+ tree (W9 U1) — so Caddy serves that tree from any installation path, and the files an update brings as soon as
61
+ the link moves; the routes of a new Caddyfile take a restart of `lanes-mobile` (the server installer does it).
54
62
 
55
63
  `index.html` keeps the upstream asset integrity hashes. When upgrading Zellij, compare the HTML and
56
64
  window hooks against the new version before changing the pinned binary. The mobile JS and CSS have
@@ -90,9 +98,16 @@ character of the old query after its common prefix with the new one, then the ne
90
98
  prefix; an emptied field or the Esc key sends a lone `Esc`. The text is the `TextEncoder` bytes with control bytes and
91
99
  DEL dropped, decoded back to text. There is no `setTimeout`: `send()` keeps a pause marker in its queue after a lone
92
100
  `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
101
+ last query it sent (`laneFilterPrev`) and the picker it was typed on (`filterTab`, cockpit v3 S5); once a refresh
102
+ finds another tab current — a lane, or another workspace's picker by the Workspaces sheet — it forgets the query,
103
+ empties the field and drops a filter tail still waiting in the queue, without sending anything: the lane now has the
104
+ focus, and an `Esc` there can interrupt an agent (DEF-1752 here, DEF-1817 on S5's line; the sidebar clears its own
105
+ filter when it opens a lane), and another workspace's picker starts with an empty bar instead of the first one's
106
+ query, whose next letter the deltas would have sent there as a bare key (DEF-1820). The lone `Esc` of an emptied
107
+ field or of the Esc key, and the one a picker left filtered is owed, go through one debt (`owed`, picker tab names):
108
+ `updateLaneFilterBar` sends it only while the picker it is owed to is the current tab, so a picker left filtered shows
109
+ its whole list again on its next visit, and an Escape that arrives once the client has already moved on is held,
110
+ never sent into the lane or another picker. Limits: the deltas assume the
96
111
  sidebar holds what the phone sent, which each new query's leading `Esc` restores — another client typing into the same
97
112
  picker pane, or a character the sidebar does not print (a no-break space, a zero-width joiner), puts them out of step
98
113
  until the field is cleared; and an `Esc` while the sidebar's upload strip is armed (`U`) returns the focus to the
@@ -155,6 +170,14 @@ on those facts (`node --test`, no jsdom).
155
170
  Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
156
171
  `parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
157
172
  `data-count` and the workspace cards, inside the existing `refresh()` / `renderCards()` — no fetch, file or timer.
173
+ The shell makes one request (W9 V3, LNS-85): once per page load, when it builds, `installFontControls()` asks
174
+ `/enrol/site` on the same origin (the `/enrol/*` route above; the enrolment service's `GET /site` answers
175
+ `{alias, host_name, host_key, version, request_mode, caller, source}`) and shows its `version` as the first row of
176
+ the client's **More** menu, **Lanes v1.2.0**, above **Text size** — the same `.lanes-font-controls` row, so no CSS.
177
+ Only a release is shown (`^\d[\w.+-]{0,31}$`, as text): `dev`, anything else, an error answer, a non-JSON 502 while
178
+ the service is down, or no network shows nothing and throws nothing. The version is the enrolment service's tree
179
+ (the server installer restarts it on every install). `tests/web/version.test.mjs` boots `mobile.js` against a fake
180
+ logged-in page to pin this; `test_web_view` pins one fetch, inside `installFontControls()`, which `build()` runs once.
158
181
  Until plan Task 26 (2026-10-04) this reader was inert (DEF-1531): `bin/lanes` `mobile_workspace_tab` gave the
159
182
  picker pane a fixed `name="Lanes"`, and Zellij reports a pane's layout name instead of its OSC 2 title, so
160
183
  `pickerTitle()` read `Lanes`, `parseCounts` returned null and the shell showed no counts ("Choose a lane to continue").
@@ -163,7 +186,11 @@ phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-
163
186
  `lanes doctor` names such a device). Whether the OSC 2 title now reaches
164
187
  `panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
165
188
  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
189
+ tabs; on the picker tab it shows only while the sidebar asks for a key (a close, the Register confirmation or Add to
190
+ workspace, which the sidebar announces by ending its pane title with ` · › <verb>`, ` · > <verb>` under ASCII glyphs;
191
+ DM-LANES-SIDEBAR-TITLE), and then only `⏎ y Esc` (cockpit v3 S5) — `^C`, `n` and the arrows have no use in a prompt.
192
+ A name field (Rename, Register as) is never announced: there `y` is a letter of the name (DEF-1819). `refresh()` sets
193
+ `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
167
194
  only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); `pointercancel` and `pointerleave`
168
195
  disarm a key, and a `pointerup` without the key's own `pointerdown` sends nothing (before, a cancelled hold stayed
169
196
  armed, so a pointer released over `^C` later sent it, DEF-1739). The buttons cancel `mousedown` so a tap never moves
@@ -183,26 +210,111 @@ assigning the server theme wholesale (`SetConfig` is sent on each config-file re
183
210
  `!important` `--lanes-surface` rule for those two elements outranks it in either variant. An SRI bump of `app.js`
184
211
  re-opens this contract: re-read `startWsControl`, `installSoftKeyboardCapture`, `initWebSockets` (`sendAnsiKey`) and
185
212
  `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
213
+ `index.html` carries one `theme-color` per colour scheme, each that variant's `surface` token, and the manifest's
214
+ `background_color` and `theme_color` are the dark `surface`; they are hand-written, so `test_tokens.GeneratedSurfaces`
215
+ holds them to the table (cockpit v3 P5). The icon (`icon.svg`, `icon-192.png`, a raster with anti-aliased edges) is
216
+ the brand mark, drawn before the token table and not generated: palette v2 did not recolour it, and its bars keep
217
+ v1's dark accent. Bundle budget (spec §8, `test_web.test_bundle_growth`):
218
+ `mobile.css` ≤ 18 982 B and `mobile.js` ≤ 24 747 B; the rules sit one per line and the explanations live here rather
188
219
  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).
220
+ 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
221
+ fetches `browser.js` and `browser.css` (1 947 B); `test_web_view` pins that download at `LAPTOP_TABLET_JS_RATCHET` and
222
+ `LAPTOP_TABLET_CSS_CAP` (DEF-1569).
223
+
224
+ The two lines that met at the cockpit v3 main sync (D2, LNS-99, 2026-10-07). On `wave/lanes-cockpit-v3`: after
225
+ 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
226
+ its comments moved to Code notes below, to keep that download inside the ratchet, then pinned at 25 498 B); after S5
227
+ 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
228
+ release row (+257 B of code; `mobile.js`'s line-1 pointer comment moved here first) measured 22 164 B on its own
229
+ 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
230
+ equivalent code — the four viewport properties through one setter, one `fill` style shared by the typing field's
231
+ host and capture, the Workspaces icon's four squares drawn from their corners: 22 153 B, the download 25 487 B. On
232
+ `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
233
+ path, the text keyboard, paste, Copy and Paste, the dock, More and hint fixes grew `mobile.js` to 23 456 B MEASURED
234
+ after the dead `Write` path, the redundant focus/blur `is-typing` listeners and the file's header comment were
235
+ removed and the new code was written compactly (`kbd()`, `aria()`), so the cap rose by exactly 1 282 B to that size
236
+ (`mobile.css` unchanged); the independent review's fixes the same day (the filter's Esc-free frames and deltas,
237
+ replacement ranges, no paste on a picker, only the paste end marker neutralised; DEF-1751..DEF-1755) grew it to
238
+ 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
239
+ 802 B again. The composed file — input v2's frames, deltas, Copy/Paste and dock, plus S5's per-picker Esc debt and
240
+ prompt quick row and W9 V3's release row, the branch's equivalent rewrites applied to the code both kept —
241
+ 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
242
+ those 354 B and the ratchet by 47 B (354 less the 307 B `browser.js` had already shed), each to the measured size
243
+ (`test_web_view.JS_COCKPIT_V3`, `LAPTOP_TABLET_JS_RATCHET`), and all four cap assertions red on one byte more.
244
+
245
+ The docs cockpit (plan W10 W7, LNS-108, 2026-10-08): **More** gains a **Docs** row, a link to `/lanes-docs/` (the docs
246
+ page, `lanes-docsd` behind Caddy's `/lanes-docs/*`) in a new tab, and the desktop bar a **Docs** link beside its two
247
+ pickers. The operator raised the phone cap by the measured amount, pinned exact: `mobile.js` MEASURED 24 747 B with
248
+ `wc -c`, +135 B (`test_web_view.JS_DOCS`; `menuRow()` takes a tag and an extra class, and the row borrows
249
+ `.lanes-session-link` for its 48 px touch target, so `mobile.css` stays 18 945 B). `browser.js` paid for its link
250
+ inside its own bytes — its line-1 pointer comment moved to Code notes below and the bar's Escape listener became an
251
+ `onkeydown` property — 3 334 -> 3 318 B MEASURED, so the laptop/tablet download is 24 747 + 3 318 = 28 065 B and the
252
+ DEF-1569 ratchet rose by the 135 `mobile.js` bytes net of those 16, to that measured size.
253
+
254
+ | File | Cap | MEASURED 2026-10-08 (`wc -c`) | Bound by |
255
+ | --- | --- | --- | --- |
256
+ | `mobile.js` | 24 747 B (exact) | 24 747 B | spec §8 phone row, `test_web.test_bundle_growth`, `test_web_view` `JS_DOCS` |
257
+ | `mobile.css` | 18 982 B | 18 945 B | spec §8 phone row |
258
+ | `browser.js` | 4 096 B | 3 318 B | `test_web_view.BROWSER_JS_CAP` |
259
+ | `browser.css` | 2 048 B | 1 947 B | `test_web_view.BROWSER_CSS_CAP` |
260
+ | `mobile.js` + `browser.js` | 28 065 B (exact) | 28 065 B | DEF-1569, `LAPTOP_TABLET_JS_RATCHET` |
261
+ | `mobile.css` + `browser.css` | 20 993 B | 20 892 B | DEF-1569's CSS twin, `LAPTOP_TABLET_CSS_CAP` |
262
+
263
+ ## Docs page (plan W10 W6, LNS-107)
264
+
265
+ The docs cockpit's pages (Markdown, Mermaid, files; served by `lanes-docsd` behind `/lanes-docs/*`, plan W3) are their
266
+ own HTML documents with their own assets, outside the phone budget above: `docs.css` (cap 2 560 B, MEASURED 2 351 B)
267
+ and `docs.js` (cap 1 536 B, MEASURED 1 530 B), both from `/lanes-assets/` with `no-cache` like the shell's. A page links
268
+ `/lanes-assets/docs.css` and loads `<script src="/lanes-assets/docs.js" defer></script>`, and renders a ```` ```mermaid ````
269
+ fence as `<pre class="mermaid">` holding the escaped source (`libexec/mdlite.py`, plan W2).
270
+
271
+ - `docs.css` is phone first: a 16 px gutter (safe-area aware), a 46 rem measure, tables and code blocks that scroll
272
+ sideways instead of the page, `pre.mermaid` centred, system fonts only (the docs CSP allows no font source). Its
273
+ colours are the block between the `BEGIN`/`END lanes tokens` markers, generated by `libexec/tokens.py --docs-css`:
274
+ the six roles it paints (`DOCS_ROLES`) on `:root`, dark by default and light under `prefers-color-scheme` — not the
275
+ phone's `html.lanes-mobile` / `data-zj-theme` block. `tokens.py --check` covers it; no colour lies outside it.
276
+ - `docs.js` is a classic script that does nothing on a page without `pre.mermaid`. With one, it appends one
277
+ `<script>` for `/lanes-assets/vendor/mermaid-<version>.min.js` with `integrity="sha256-…"` (base64 of the raw
278
+ sha256 of the pinned member, `holders.lock`) and `crossorigin="anonymous"`; on load it calls
279
+ `mermaid.initialize({startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, theme})` (`dark`
280
+ when the system scheme is dark, else `default`) and `mermaid.run({querySelector: 'pre.mermaid'})`. Mermaid empties a
281
+ fence before it draws, so each fence left without an `<svg>` gets its source text back (`textContent`, never markup;
282
+ DEF-1873). A missing file (`install/enable-docs.sh` not run yet: 404) or an integrity mismatch fires no load, and every
283
+ fence stays a `<pre>` with its source. It never evaluates strings, writes markup or names the unversioned link.
284
+ - The vendor route: `handle_path /lanes-assets/vendor/*` serves `$LANES_SHARE/vendor` (`install/services.py` puts
285
+ `LANES_SHARE` in `lanes-mobile.service`, as it does `LANES_REPO`) with `Cache-Control: public, max-age=31536000,
286
+ immutable` and no listing. Immutable is safe only because a page names the VERSIONED file: under the stable
287
+ `mermaid.min.js` link a browser would keep the old bytes after a pin bump and refuse them under the new integrity
288
+ (DEF-1870). The block sits first among the `handle` routes: Caddy sorts it ahead of `/lanes-assets/*` (longest path
289
+ first) only past routes that also have exactly one path (Caddy issue #5037), so after `@backend` it would never be
290
+ reached (DEF-1872). `test_docs_assets.VendorRoute` proves the order on a model of Caddy's sort; no Caddy runs in the
291
+ tests (DEF-1871).
292
+ - CSP needs, MEASURED 2026-10-08 in headless Chromium 153 with Mermaid 11.17.2: `script-src 'self'` suffices — no
293
+ `'unsafe-eval'` (a flowchart and a sequence diagram rendered with no violation; a control page's `eval` was refused
294
+ and reported) — but the SVG Mermaid draws carries inline `<style>` and `style` attributes, so `style-src` needs
295
+ `'unsafe-inline'` (without it: 88 violations, unstyled diagrams). The docs page's CSP (plan W3) has both.
296
+ - Tests: `tests/unit/test_docs_assets.py` (the pin, the enable script against a fake curl, the route model, the unit's
297
+ environment, both files' caps, tokens and SRI) and `tests/web/docs.test.mjs` (docs.js in a vm against a fake page and
298
+ a fake Mermaid that empties fences as the real one does). How to bump the pin: [runbook](../docs/RUNBOOK.md#mermaid-pin).
299
+ - The drop area (plan W10 W5, LNS-106): the index's upload form is a plain form that works without a script — a
300
+ `label.docs-drop` wrapping a multi-file input, so a tap opens the picker and a drop onto the input takes the files.
301
+ `upload.js` (cap 2 892 B, MEASURED 2 892 B, exact) and `upload.css` (cap 1 033 B, MEASURED 1 033 B, exact) make it
302
+ better: a drop anywhere on the area (`input.files` = the dropped files), a size check against the form's `data-max`
303
+ before anything is sent, an `XMLHttpRequest` `POST up` asking for JSON with a `<progress>`, then one line per receipt
304
+ — a link to `d/<id>` named with `textContent`, its size, sha256 and the in-container path it `landed` at — and a
305
+ refusal or a network error in words. They are their own files because `docs.js` had 6 B of its cap left and
306
+ `docs.css` 209 B; docsd serves them like `docs.css`/`docs.js`, and only the index (the page with the form) loads them.
307
+ The page CSP has `connect-src 'self'` for that request. The styles use `docs.css`'s token roles only (no colour of
308
+ their own), 44 px touch targets, phone first. Tests: `test_docs_assets.UploadAssets` (caps, classic script, no
309
+ markup, roles) and `tests/web/upload.test.mjs` (upload.js in a vm against a fake page and XHR).
199
310
 
200
311
  ## Code notes
201
312
 
202
313
  `mobile.js` carries no explanations (the spec §8 budget above); each note is keyed to the code it explains, in file
203
314
  order.
204
315
 
205
- - The file: the Lanes browser shell (phone, tablet, desktop) over the pinned Zellij 0.45.1 client's window hooks.
316
+ - The file: the Lanes browser shell (phone, tablet, desktop) over the pinned Zellij 0.45.1 client's window hooks. Its
317
+ explanations are these notes (the file's line-1 pointer to them moved here too, W9 V3).
206
318
  - `root.dataset.lanesShell = mode`: read by `browser.js` for the desktop bar and the tablet's full rendering.
207
319
  - `if (mode !== 'phone')`: appends the laptop/tablet extras (`browser.css`, `browser.js`); a phone never fetches them.
208
320
  - `if (mode === 'tablet')`: iPad Safari's user agent is a desktop one, so the shell asks for the client's mobile UI
@@ -236,12 +348,23 @@ order.
236
348
  - `hint()`: holds the subtitle for eight ticks of the one interval (DEF-1739).
237
349
  - `buildQuickRow()` (spec §6.5): `n` and `^C` send after a 400 ms hold, measured from event timestamps with no timer;
238
350
  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.
351
+ - `clearLaneFilter()`: forgets the query and owes its picker (`filterTab`) the lone `Esc` that leaves the sidebar's
352
+ filter mode (`owed`, a set of picker tab names). `updateLaneFilterBar(tab, isPicker)`, on every refresh: once the
353
+ current tab is not the one the query was typed on, a filter tail still waiting in the queue is dropped and the query
354
+ cleared, sending nothing (the lane may have the focus now, DEF-1752); the lone `Esc` and its pause are sent only when
355
+ the current tab is an owed picker; then `filterTab` becomes the current tab if it is a picker. Escape in the bar and
356
+ an emptied field clear and call `refresh()`, so the `Esc` goes out at once while that picker is still current.
241
357
  - `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
242
358
  - `refresh()`: leaves the subtitle to a live hint, hides **Panes** (and **Paste**, DEF-1754) on a picker tab so the
243
359
  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.
360
+ filter tail is dropped before it could reach a lane; `quick` is `true` on a view tab (every key), `2` on the picker
361
+ while its title ends ` · › <verb>` (only `⏎ y Esc`), else none.
362
+ - `menuRow()` / `installFontControls()`: a row of the client's **More** menu, prepended (Text size, then **Docs** above
363
+ it, then the release at the top once `/enrol/site` answers). The one `fetch` is there because `build()` runs once per
364
+ page load. `menuRow(label, tag, more)`: the **Docs** row is an `<a>` (plan W10 W7) to `/lanes-docs/` with
365
+ `target="_blank"` — a new tab, so the terminal and its socket stay where they are, and a Home Screen app, which has
366
+ no back button, is never navigated away — carrying `lanes-session-link` as well, the existing rule that gives it a
367
+ 48 px touch target with no CSS of its own.
245
368
  - `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
246
369
  re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
247
370
  toggle.
@@ -251,8 +374,22 @@ order.
251
374
  window hooks just after appending its mobile root; `hint()` and a paused `send()` re-arm it; each tick flushes the
252
375
  queue, and it clears itself once attached, with no hint up and no pause waiting.
253
376
 
377
+ `browser.js` (laptop/tablet extras) carries none either, since cockpit v3 S5:
378
+
379
+ - The file: the Lanes laptop/tablet extras (its line-1 pointer to these notes moved here in plan W10 W7, to pay for the
380
+ bar's Docs link).
381
+
382
+ - `laneFullRender()`: the client starts a tablet in single pane on every load; once, it sends the client's own
383
+ switch-to-desktop preference.
384
+ - `desktop()`: the chip toggles an overlay bar (no refit); a pick sends the phone picker's `FocusPane`. The chip
385
+ cancels `mousedown`, so the terminal keeps the keys. The bar ends in a **Docs** link to `/lanes-docs/` in a new tab
386
+ (plan W10 W7); Escape in the bar gives the terminal its keys back (`onkeydown`, one handler, as the listener was).
387
+ - `ui()`: `window.__zjMobileUi` is set synchronously after the client appends its mobile root, which the
388
+ `MutationObserver` on `<body>` waits for.
389
+
254
390
  Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
255
391
  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).
392
+ lane selection, pane navigation, font controls and the release row in **More**, rotation and the visible viewport
393
+ while the keyboard opens, and the input checklist in the [runbook](../docs/RUNBOOK.md) (text keyboard, quick keys,
394
+ paste, Copy and Paste, the picker's filter and prompt row).
258
395
  Use disposable view sessions and revoke any temporary test token afterward.
@@ -1,4 +1,3 @@
1
- /* LNS-31 laptop/tablet extras; see docs/ARCHITECTURE.md (Web and phone). */
2
1
  function laneBarModel(d) {
3
2
  const panes = (d?.panes || []).filter(p => !p.is_plugin), at = d?.active_pane || {}, ws = new Map();
4
3
  const first = t => t && panes.find(p => p.tab_position === t.position);
@@ -14,7 +13,6 @@ function laneBarModel(d) {
14
13
  .map(p => item(p.title || `Pane ${p.pane_id}`, p))].filter(Boolean),
15
14
  };
16
15
  }
17
- // Tablet: upstream starts single pane on every load; once, send its own switch-to-desktop prefs.
18
16
  function laneFullRender(api, send) {
19
17
  let done;
20
18
  return () => {
@@ -41,14 +39,13 @@ if (typeof module === 'object') module.exports = {laneBarModel, laneFullRender};
41
39
  const full = laneFullRender(api, send);
42
40
  return () => full() && dispatchEvent(new Event('zellij:rendering-resize'));
43
41
  };
44
- // Desktop: the chip toggles an overlay bar (no refit); picks send the phone picker's FocusPane.
45
42
  const desktop = api => {
46
43
  let last;
47
44
  const picks = ['Workspace', 'Lane'].map(title => make('select', {title, onchange() {
48
45
  send({type: 'FocusPane', pane_id: +this.value, is_plugin: false});
49
46
  focus();
50
47
  }}));
51
- const bar = make('nav', {className: 'lanes-bar', hidden: true});
48
+ const bar = make('nav', {className: 'lanes-bar', hidden: true, onkeydown: ev => ev.key === 'Escape' && focus()});
52
49
  const render = () => {
53
50
  const m = !bar.hidden && laneBarModel(api.getState()?.data), sig = JSON.stringify(m);
54
51
  if (!m || sig === last) return;
@@ -61,13 +58,12 @@ if (typeof module === 'object') module.exports = {laneBarModel, laneFullRender};
61
58
  render();
62
59
  window.term?.focus();
63
60
  }});
64
- chip.addEventListener('mousedown', ev => ev.preventDefault()); // the terminal keeps the keys
65
- bar.addEventListener('keydown', ev => ev.key === 'Escape' && focus());
66
- bar.append(...picks);
61
+ chip.addEventListener('mousedown', ev => ev.preventDefault());
62
+ bar.append(...picks, make('a', {href: '/lanes-docs/', target: '_blank', textContent: 'Docs'}));
67
63
  document.body.append(bar, chip);
68
64
  return render;
69
65
  };
70
- const ui = () => window.__zjMobileUi; // set synchronously after the client appends its mobile root
66
+ const ui = () => window.__zjMobileUi;
71
67
  if (ui()) return ready(ui());
72
68
  new MutationObserver((_, o) => ui() && (o.disconnect(), ready(ui()))).observe(document.body, {childList: true});
73
69
  })();
@@ -0,0 +1,52 @@
1
+ /* Lanes docs page (plan W10 W6, LNS-107), phone first. Colours: the generated block below; system fonts only.
2
+ Notes: web/README.md "Docs page". */
3
+ /* BEGIN lanes tokens (generated by libexec/tokens.py --docs-css; do not edit by hand) */
4
+ :root {
5
+ --lanes-surface: #101318;
6
+ --lanes-surface-raised: #191e25;
7
+ --lanes-line: #2c333d;
8
+ --lanes-ink: #f2f5f8;
9
+ --lanes-ink-dim: #a6b1be;
10
+ --lanes-accent: #8fe3a6;
11
+ color-scheme: dark;
12
+ }
13
+ @media (prefers-color-scheme: light) {
14
+ :root {
15
+ --lanes-surface: #f3f5f2;
16
+ --lanes-surface-raised: #ffffff;
17
+ --lanes-line: #d5ded6;
18
+ --lanes-ink: #111a14;
19
+ --lanes-ink-dim: #4a5661;
20
+ --lanes-accent: #1f6f3f;
21
+ color-scheme: light;
22
+ }
23
+ }
24
+ /* END lanes tokens */
25
+ *, *::before, *::after { box-sizing: border-box; }
26
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
27
+ body {
28
+ max-width: 46rem; margin: 0 auto;
29
+ padding: 16px max(16px, env(safe-area-inset-right)) 32px max(16px, env(safe-area-inset-left));
30
+ background: var(--lanes-surface); color: var(--lanes-ink);
31
+ font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; overflow-wrap: break-word;
32
+ }
33
+ h1, h2, h3 { line-height: 1.25; margin: 1.4em 0 .5em; }
34
+ h1 { font-size: 1.6em; margin-top: .4em; }
35
+ h2 { font-size: 1.3em; padding-bottom: .2em; border-bottom: 1px solid var(--lanes-line); }
36
+ a { color: var(--lanes-accent); }
37
+ small { color: var(--lanes-ink-dim); }
38
+ img { max-width: 100%; height: auto; }
39
+ hr { border: 0; border-top: 1px solid var(--lanes-line); }
40
+ blockquote { margin: 1em 0; padding-left: 12px; border-left: 3px solid var(--lanes-line); color: var(--lanes-ink-dim); }
41
+ code, pre { font: .875em/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
42
+ code { padding: .1em .3em; border-radius: 4px; background: var(--lanes-surface-raised); }
43
+ pre {
44
+ margin: 1em 0; padding: 12px; overflow-x: auto; tab-size: 4;
45
+ background: var(--lanes-surface-raised); border: 1px solid var(--lanes-line); border-radius: 8px;
46
+ }
47
+ pre code { padding: 0; font-size: 1em; background: none; }
48
+ pre.mermaid { text-align: center; }
49
+ pre.mermaid svg { max-width: 100%; height: auto; }
50
+ table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 1em 0; }
51
+ th, td { padding: 6px 10px; border: 1px solid var(--lanes-line); text-align: left; }
52
+ th { background: var(--lanes-surface-raised); }
@@ -0,0 +1,36 @@
1
+ // Lanes docs page (plan W10 W6, LNS-107): render ```mermaid fences with the pinned Mermaid, only on a page that has
2
+ // one: its versioned file (DEF-1870), its sha256 as SRI. Without it every fence keeps its source. web/README.md.
3
+ (function () {
4
+ 'use strict';
5
+ var SRC = '/lanes-assets/vendor/mermaid-11.17.2.min.js';
6
+ var SRI = 'sha256-WB7X10vZBI0OOpE2OSfXLvIpQtdyJUayf3zCnjU5Drg=';
7
+
8
+ function render() {
9
+ var pres = document.querySelectorAll('pre.mermaid'), text = [], i;
10
+ for (i = 0; i < pres.length; i++) text[i] = pres[i].textContent;
11
+ // Mermaid empties each fence first: one left without a diagram gets its source back (DEF-1873).
12
+ function restore() {
13
+ for (i = 0; i < pres.length; i++) if (!pres[i].querySelector('svg')) pres[i].textContent = text[i];
14
+ }
15
+ try {
16
+ mermaid.initialize({startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true,
17
+ theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default'});
18
+ mermaid.run({querySelector: 'pre.mermaid'}).then(restore, restore);
19
+ } catch (e) {
20
+ restore();
21
+ }
22
+ }
23
+
24
+ function start() {
25
+ if (!document.querySelector('pre.mermaid')) return;
26
+ var s = document.createElement('script');
27
+ s.setAttribute('integrity', SRI);
28
+ s.setAttribute('crossorigin', 'anonymous');
29
+ s.setAttribute('src', SRC);
30
+ s.onload = render;
31
+ document.head.appendChild(s);
32
+ }
33
+
34
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
35
+ else start();
36
+ }());