@vibelord/lanes 1.0.0 → 1.0.2
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.
- package/package.json +1 -1
- package/payload/lanes/README.md +92 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +928 -150
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +62 -5
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +326 -25
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +158 -6
- package/payload/lanes/docs/DESIGN.md +250 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +507 -24
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +246 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +147 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +436 -36
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +53 -22
- package/payload/lanes/libexec/sidebar.py +1190 -186
- package/payload/lanes/libexec/statusd.py +352 -90
- package/payload/lanes/libexec/tokens.py +310 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +22 -11
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +96 -2
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +108 -221
- package/payload/lanes/web/mobile.js +105 -55
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +128 -19
- package/payload/manifest.json +7 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The Lanes design tokens: ONE table, rendered seven ways. The sidebar imports `sgr_table`; `--kdl`, `--css`, `--sh`,
|
|
3
|
+
`--md`, `--js` and `--ghostty-dark|--ghostty-light` write the Zellij theme, the phone CSS, the viewer's shell variables,
|
|
4
|
+
the docs table, the phone's terminal palette and the optional Ghostty palette files between markers; `--check` fails
|
|
5
|
+
when any committed block differs. Spec: docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md.
|
|
6
|
+
No third-party code; importable with no side effects."""
|
|
7
|
+
import difflib
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ROLES = ('surface', 'surface-raised', 'line', 'accent-wash', 'accent-line', 'press', 'chip',
|
|
13
|
+
'ink', 'ink-dim', 'ink-faint', 'accent', 'on-accent', 'on-accent-urgent',
|
|
14
|
+
'tone-urgent', 'tone-attention', 'tone-working', 'tone-done', 'scrim', 'shadow')
|
|
15
|
+
TOKENS = {
|
|
16
|
+
'dark': {'surface': '#101318', 'surface-raised': '#191e25', 'line': '#2c333d', 'accent-wash': '#1a2721',
|
|
17
|
+
'accent-line': '#4b6955', 'press': '#26332c', 'chip': '#ffffff0f', 'ink': '#edf1f5', 'ink-dim': '#9ca7b5',
|
|
18
|
+
'ink-faint': '#6b7683', 'accent': '#b4e3bf', 'on-accent': '#101318', 'on-accent-urgent': '#7a1a12',
|
|
19
|
+
'tone-urgent': '#f28b7d', 'tone-attention': '#e5b84a', 'tone-working': '#7fb3f2', 'tone-done': '#6fd39a',
|
|
20
|
+
'scrim': '#000000a6', 'shadow': '0 -16px 80px #0007'},
|
|
21
|
+
'light': {'surface': '#f3f5f2', 'surface-raised': '#ffffff', 'line': '#d5ded6', 'accent-wash': '#e5f0e6',
|
|
22
|
+
'accent-line': '#acc9b4', 'press': '#d8e9db', 'chip': '#edf3ec', 'ink': '#17241b', 'ink-dim': '#56616d',
|
|
23
|
+
'ink-faint': '#7f8a96', 'accent': '#2f6b45', 'on-accent': '#ffffff', 'on-accent-urgent': '#ffd3cc',
|
|
24
|
+
'tone-urgent': '#b7322a', 'tone-attention': '#8a5a00', 'tone-working': '#2456a8', 'tone-done': '#1b7142',
|
|
25
|
+
'scrim': '#1a222b80', 'shadow': '0 -16px 80px #0003'},
|
|
26
|
+
}
|
|
27
|
+
# 16-colour fallback: the user's own palette resolves these. Selection is achromatic (bold) there, dim is bright-black.
|
|
28
|
+
ANSI16 = {'ink': '39', 'ink-dim': '90', 'ink-faint': '90', 'accent': '1;39',
|
|
29
|
+
'tone-urgent': '31', 'tone-attention': '33', 'tone-working': '34', 'tone-done': '32'}
|
|
30
|
+
# Lane-identity palette for the two places that DEFINE a palette: Zellij's `web_client { theme {} }` (the phone's
|
|
31
|
+
# xterm.js; dark only, see _web_client) and the optional Ghostty file. A Zellij theme's legacy `black…white` keys are
|
|
32
|
+
# NOT one of them (DEF-1413). The TUI itself keeps 38;5;N. Slots 1-4 are the four tones. Every slot >= 4.5:1.
|
|
33
|
+
PALETTE16 = {
|
|
34
|
+
'dark': ['#7d8896', '#f28b7d', '#6fd39a', '#e5b84a', '#7fb3f2', '#c9a6ff', '#6fd6d0', '#9ca7b5',
|
|
35
|
+
'#8a95a3', '#ff9d96', '#8fe0a3', '#f7cf7f', '#a5cbff', '#dcc6ff', '#9ae6e0', '#edf1f5'],
|
|
36
|
+
'light': ['#3b4a42', '#b7322a', '#1b7142', '#8a5a00', '#2456a8', '#7a3fb8', '#0f7b7b', '#56616d',
|
|
37
|
+
'#5f6d66', '#c2332a', '#1b7a38', '#8f5a00', '#2860c4', '#8a4dc8', '#0f7272', '#17241b'],
|
|
38
|
+
}
|
|
39
|
+
# key -> (unicode, ascii twin, tone). Position disambiguates: agent glyphs in column 2, stream glyphs in column 33.
|
|
40
|
+
AGENT_GLYPHS = {'needs_approval': ('◆', '!', 'tone-urgent'), 'needs_input': ('◇', '?', 'tone-attention'),
|
|
41
|
+
'working': ('▸', '>', 'tone-working'), 'done': ('✓', 'v', 'tone-done'),
|
|
42
|
+
'errored': ('✗', 'X', 'tone-urgent'), 'shell': ('_', '_', 'ink-dim'), 'idle': ('·', '.', 'ink-dim')}
|
|
43
|
+
GLYPHS = {'active': ('●', '*', 'tone-done'), 'quiet': ('○', 'o', 'ink-dim'), 'missing': ('✗', 'x', 'tone-urgent'),
|
|
44
|
+
'absent': ('–', '-', 'ink-dim'), 'dead': ('!', '!', 'tone-urgent'), 'helper': ('·', '.', 'ink-dim'),
|
|
45
|
+
'unknown': ('?', '?', 'ink-dim')}
|
|
46
|
+
MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'viewport-b': ('²', '2', 'accent'),
|
|
47
|
+
'viewport-c': ('³', '3', 'accent'), 'viewer': ('·', '.', 'ink-dim'), 'truncation': ('…', '~', 'ink'),
|
|
48
|
+
'close': ('×', 'x', 'tone-urgent'), 'new': ('+', '+', 'tone-done'), 'rule': ('─', '-', 'ink-faint'),
|
|
49
|
+
'more': ('⋮', ':', 'ink-dim'), 'warning': ('⚠', '!', 'tone-attention'), 'upload': ('↑', '^', 'ink-dim'),
|
|
50
|
+
'armed': ('↓', 'v', 'tone-done'), 'enter': ('⏎', 'Enter', 'ink-dim'), 'caret': ('█', '_', 'tone-working'),
|
|
51
|
+
'stale': ('?', '?', 'ink-dim'), 'lead': ('›', '>', 'ink'),
|
|
52
|
+
# Tone-less punctuation (spec §3 completeness rule): every ` · ` and every quote the sidebar draws is a lookup, so
|
|
53
|
+
# LANES_GLYPHS=ascii yields a seven-bit frame. `sep` shares `-` with `rule` on purpose: neither carries state.
|
|
54
|
+
'sep': ('·', '-', 'ink-dim'), 'quote-open': ('‘', "'", 'ink'), 'quote-close': ('’', "'", 'ink'),
|
|
55
|
+
'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')}
|
|
56
|
+
ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
|
|
57
|
+
MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
|
|
58
|
+
'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
|
|
59
|
+
PAINTED = tuple(ANSI16) # the roles the TUI ever emits
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def luminance(hexval):
|
|
63
|
+
r, g, b = (int(hexval[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
|
64
|
+
lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in (r, g, b)]
|
|
65
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def contrast(fg, bg):
|
|
69
|
+
hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True)
|
|
70
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def rgb(hexval):
|
|
74
|
+
return tuple(int(hexval[i:i + 2], 16) for i in (1, 3, 5))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sgr_table(mode, variant):
|
|
78
|
+
"""Complete escape per painted role. `mode` truecolor|16|none; `variant` dark|light. Resolved ONCE per process."""
|
|
79
|
+
table = {'bold': '\033[1m', 'reset': '\033[0m'}
|
|
80
|
+
dim16 = '\033[2m' if os.environ.get('LANES_DIM') == '2' else '\033[90m'
|
|
81
|
+
for role in PAINTED:
|
|
82
|
+
if mode == 'none':
|
|
83
|
+
table[role] = '\033[1m' if role == 'accent' else ''
|
|
84
|
+
elif mode == '16':
|
|
85
|
+
table[role] = dim16 if role in ('ink-dim', 'ink-faint') else f'\033[{ANSI16[role]}m'
|
|
86
|
+
else:
|
|
87
|
+
table[role] = ('\033[1m' if role == 'accent' else '') + '\033[38;2;%d;%d;%dm' % rgb(TOKENS[variant][role])
|
|
88
|
+
return table
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def glyph_set(mode):
|
|
92
|
+
"""mode unicode|ascii|emoji -> {table name: {key: glyph}}; emoji differs from unicode only in the icon column."""
|
|
93
|
+
index = 1 if mode == 'ascii' else 0
|
|
94
|
+
return {name: {k: v[index] for k, v in table.items()}
|
|
95
|
+
for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('marks', MARKS), ('economy', ECONOMY_TAIL))}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def tone_of(table, key):
|
|
99
|
+
return table[key][2]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ renderers
|
|
103
|
+
COMPONENTS = ('text_unselected', 'text_selected', 'ribbon_selected', 'ribbon_unselected', 'table_title', 'table_cell_selected',
|
|
104
|
+
'table_cell_unselected', 'list_selected', 'list_unselected', 'frame_selected', 'frame_highlight',
|
|
105
|
+
'exit_code_success', 'exit_code_error')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _component(t, base, background, emphasis):
|
|
109
|
+
e = emphasis
|
|
110
|
+
return f'base "{t[base]}"; background "{t[background]}"; emphasis_0 "{t[e[0]]}"; emphasis_1 "{t[e[1]]}"; emphasis_2 "{t[e[2]]}"; emphasis_3 "{t[e[3]]}";'
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _theme(name, variant):
|
|
114
|
+
t, p = TOKENS[variant], PALETTE16[variant]
|
|
115
|
+
text = ('tone-attention', 'tone-done', 'accent', 'tone-working')
|
|
116
|
+
comps = {
|
|
117
|
+
'text_unselected': ('ink', 'surface', text), 'text_selected': ('ink', 'accent-wash', text),
|
|
118
|
+
'ribbon_selected': ('on-accent', 'accent', ('on-accent-urgent', 'on-accent', 'on-accent', 'on-accent')),
|
|
119
|
+
'ribbon_unselected': ('ink-dim', 'surface-raised', ('tone-urgent', 'ink', 'tone-attention', 'tone-working')),
|
|
120
|
+
'table_title': ('accent', 'surface', text), 'table_cell_selected': ('ink', 'accent-wash', text),
|
|
121
|
+
'table_cell_unselected': ('ink', 'surface', text), 'list_selected': ('ink', 'accent-wash', text),
|
|
122
|
+
'list_unselected': ('ink', 'surface', text),
|
|
123
|
+
'frame_selected': ('accent', 'surface', ('tone-attention', 'tone-done', 'tone-working', 'tone-urgent')),
|
|
124
|
+
'frame_highlight': ('tone-attention', 'surface', ('tone-urgent', 'tone-done', 'accent', 'tone-working')),
|
|
125
|
+
'exit_code_success': ('tone-done', 'surface', ('tone-done',) * 4),
|
|
126
|
+
'exit_code_error': ('tone-urgent', 'surface', ('tone-urgent',) * 4),
|
|
127
|
+
}
|
|
128
|
+
lines = [f' {name} {{']
|
|
129
|
+
for comp in COMPONENTS:
|
|
130
|
+
base, background, emphasis = comps[comp]
|
|
131
|
+
lines.append(f' {comp} {{ {_component(t, base, background, emphasis)} }}')
|
|
132
|
+
players = [t['tone-working'], t['tone-attention'], t['tone-done'], t['tone-urgent'], t['accent'], p[5], p[6], p[11], p[10], p[9]]
|
|
133
|
+
lines.append(' multiplayer_user_colors { ' + ' '.join(f'player_{i + 1} "{c}";' for i, c in enumerate(players)) + ' }')
|
|
134
|
+
# No legacy `fg bg black … orange` line: in a component-format theme Zellij 0.45.1 neither reads nor validates
|
|
135
|
+
# those keys, and none of a theme's colours reach the browser's xterm.js palette (DEF-1413; _web_client does).
|
|
136
|
+
lines.append(' }')
|
|
137
|
+
return lines
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
ANSI_NAMES = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _web_client():
|
|
144
|
+
"""The phone's terminal palette. Zellij 0.45.1 sends `web_client.theme` to every browser as `SetConfig.theme`,
|
|
145
|
+
which the web client assigns to xterm.js `options.theme` (zellij-client `control_message.rs` / `app.js`
|
|
146
|
+
`terminalConfigOptions`). Without this block only background, foreground and selection fall back to a theme's
|
|
147
|
+
components and the sixteen ANSI slots stay xterm.js's built-in defaults. The block is server-wide and holds ONE
|
|
148
|
+
palette: the dark variant, the phone's default. It reaches the browser in the HTTP boot config at connect (the
|
|
149
|
+
xterm.js `Terminal` constructor) and in a `SetConfig` on every config-file reload, which the client assigns
|
|
150
|
+
wholesale before repainting `<body>`/`#terminal` inline, so the phone's light toggle needs a client-side
|
|
151
|
+
`term.options.theme` override applied on attach and re-applied after each `SetConfig`, plus a CSS background rule,
|
|
152
|
+
not set once (spec §6.3, Task 28; DEF-1413). Zellij validates every colour here (an unparseable one fails
|
|
153
|
+
`setup --check`), so `test_tokens` can prove the block is read."""
|
|
154
|
+
t, p = TOKENS['dark'], PALETTE16['dark']
|
|
155
|
+
lines = ['web_client {', ' font "monospace"', ' theme {',
|
|
156
|
+
f' background "{t["surface"]}"', f' foreground "{t["ink"]}"']
|
|
157
|
+
lines += [f' {n} "{p[i]}"' for i, n in enumerate(ANSI_NAMES)]
|
|
158
|
+
lines += [f' bright_{n} "{p[i + 8]}"' for i, n in enumerate(ANSI_NAMES)]
|
|
159
|
+
lines += [f' selection_background "{t["accent-wash"]}"', f' selection_foreground "{t["ink"]}"']
|
|
160
|
+
lines += [' }', '}']
|
|
161
|
+
return lines
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_kdl():
|
|
165
|
+
lines = ['// BEGIN lanes tokens (generated by libexec/tokens.py --kdl; do not edit by hand)', 'themes {']
|
|
166
|
+
lines += _theme('lanes-dark', 'dark') + _theme('lanes-light', 'light')
|
|
167
|
+
lines += ['}', 'theme_dark "lanes-dark"', 'theme_light "lanes-light"']
|
|
168
|
+
lines += _web_client() + ['// END lanes tokens']
|
|
169
|
+
return '\n'.join(lines) + '\n'
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
ALIASES = {'bg': 'surface', 'panel': 'surface-raised', 'border': 'line', 'text': 'ink', 'muted': 'ink-dim',
|
|
173
|
+
'current': 'accent-wash', 'current-border': 'accent-line'}
|
|
174
|
+
ZJ = {'bg': 'surface', 'surface': 'surface-raised', 'border': 'line', 'text': 'ink', 'text-dim': 'ink-dim',
|
|
175
|
+
'green': 'tone-done', 'blue': 'tone-working', 'red': 'tone-urgent', 'yellow': 'tone-attention'}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_css():
|
|
179
|
+
"""Values per variant; every `var()` indirection (legacy aliases, upstream `--zj-*`) ONCE. All three selectors
|
|
180
|
+
match the same <html> element and a custom property's var() resolves against that element's winning value, so a
|
|
181
|
+
single alias follows the light override exactly like a per-variant copy did — 289 B less of the phone's mobile.css
|
|
182
|
+
growth allowance (spec §8; DEF-1414)."""
|
|
183
|
+
out = ['/* BEGIN lanes tokens (generated by libexec/tokens.py --css; do not edit by hand) */']
|
|
184
|
+
for selector, variant, scheme in (('html.lanes-mobile', 'dark', 'dark'), ('html.lanes-mobile:root[data-zj-theme="light"]', 'light', 'light')):
|
|
185
|
+
out.append(selector + ' {')
|
|
186
|
+
out += [f' --lanes-{role}: {TOKENS[variant][role]};' for role in ROLES]
|
|
187
|
+
out.append(f' color-scheme: {scheme};')
|
|
188
|
+
out.append('}')
|
|
189
|
+
out.append('html.lanes-mobile:root {')
|
|
190
|
+
out += [f' --lanes-{old}: var(--lanes-{new});' for old, new in ALIASES.items()]
|
|
191
|
+
out += [f' --zj-{k}: var(--lanes-{v});' for k, v in ZJ.items()]
|
|
192
|
+
out.append('}')
|
|
193
|
+
out.append('/* END lanes tokens */')
|
|
194
|
+
return '\n'.join(out) + '\n'
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def render_sh():
|
|
198
|
+
return ('# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)\n'
|
|
199
|
+
"LZ_BOLD='\\033[1m'\nLZ_DIM='\\033[90m'\n"
|
|
200
|
+
f"LZ_URGENT='\\033[{ANSI16['tone-urgent']}m'\nLZ_ATTENTION='\\033[{ANSI16['tone-attention']}m'\n"
|
|
201
|
+
f"LZ_WORKING='\\033[{ANSI16['tone-working']}m'\nLZ_DONE='\\033[{ANSI16['tone-done']}m'\nLZ_RESET='\\033[0m'\n"
|
|
202
|
+
'# END lanes tokens\n')
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def render_md():
|
|
206
|
+
rows = ['<!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->',
|
|
207
|
+
'| Role | Dark | Light | ANSI-16 | CSS variable |', '|---|---|---|---|---|']
|
|
208
|
+
rows += [f'| {r} | `{TOKENS["dark"][r]}` | `{TOKENS["light"][r]}` | `{ANSI16.get(r, "—")}` | `--lanes-{r}` |' for r in ROLES]
|
|
209
|
+
rows += ['', '| Glyph | Codepoint | ASCII | Tone | Meaning |', '|---|---|---|---|---|']
|
|
210
|
+
for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('mark', MARKS), ('economy', ECONOMY_TAIL)):
|
|
211
|
+
for key, (glyph, ascii_twin, tone) in table.items():
|
|
212
|
+
cp = ' '.join(f'U+{ord(c):04X}' for c in glyph)
|
|
213
|
+
cell = ascii_twin.replace('|', '\\|') # a bare pipe splits a GFM table cell even inside backticks
|
|
214
|
+
rows.append(f'| `{glyph}` | {cp} | `{cell}` | {tone} | {name} `{key}` |')
|
|
215
|
+
rows.append('<!-- END lanes tokens -->')
|
|
216
|
+
return '\n'.join(rows) + '\n'
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_js():
|
|
220
|
+
"""The phone's xterm.js slots for BOTH variants. Zellij's web_client.theme is one server-wide (dark) palette that
|
|
221
|
+
arrives in the HTTP boot config and that SetConfig re-sends on every config reload, assigned wholesale; mobile.js
|
|
222
|
+
applies these on attach and re-applies them after each SetConfig (spec §6.3; DEF-1413). 495 B against mobile.js's
|
|
223
|
+
3072 B allowance (measured 2026-10-04)."""
|
|
224
|
+
body = ', '.join(f"{v}: [{', '.join(repr(c) for c in PALETTE16[v])}]" for v in ('dark', 'light'))
|
|
225
|
+
return ('// BEGIN lanes tokens (generated by libexec/tokens.py --js; do not edit by hand)\n'
|
|
226
|
+
f' const LANES_ANSI = {{{body}}};\n // END lanes tokens\n')
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_ghostty(variant):
|
|
230
|
+
"""The optional Ghostty palette (spec §7; `install.sh --client --palette`): colours only — never font, theme,
|
|
231
|
+
padding or cursor style, the everyday config keeps those. Valid both as an included config file and as a Ghostty
|
|
232
|
+
theme file, so install.sh can include the dark one directly or pair both through `theme = light:…,dark:…`."""
|
|
233
|
+
t, p = TOKENS[variant], PALETTE16[variant]
|
|
234
|
+
lines = [f'# BEGIN lanes tokens (generated by libexec/tokens.py --ghostty-{variant}; do not edit by hand)',
|
|
235
|
+
'# Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).',
|
|
236
|
+
'# Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.',
|
|
237
|
+
f'background = {t["surface"]}', f'foreground = {t["ink"]}', f'cursor-color = {t["accent"]}',
|
|
238
|
+
f'selection-background = {t["line"]}', f'selection-foreground = {t["ink"]}']
|
|
239
|
+
lines += [f'palette = {i}={c}' for i, c in enumerate(p)]
|
|
240
|
+
lines.append('# END lanes tokens')
|
|
241
|
+
return '\n'.join(lines) + '\n'
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md, 'js': render_js,
|
|
245
|
+
'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light')}
|
|
246
|
+
TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md',
|
|
247
|
+
'js': 'web/mobile.js', 'ghostty-dark': 'client/ghostty/lanes-palette.conf',
|
|
248
|
+
'ghostty-light': 'client/ghostty/lanes-palette-light.conf'}
|
|
249
|
+
MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
|
|
250
|
+
'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->'),
|
|
251
|
+
'js': ('// BEGIN lanes tokens', '// END lanes tokens'),
|
|
252
|
+
'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens')}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def render(kind):
|
|
256
|
+
return RENDERERS[kind]()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _splice(text, kind):
|
|
260
|
+
"""Replace the marker block in `text` with the renderer's output; append when no block exists yet."""
|
|
261
|
+
begin, end = MARKERS[kind]
|
|
262
|
+
block = render(kind)
|
|
263
|
+
start = text.find(begin)
|
|
264
|
+
if start < 0:
|
|
265
|
+
return text + ('' if text.endswith('\n') or not text else '\n') + block
|
|
266
|
+
stop = text.find(end, start)
|
|
267
|
+
stop = text.find('\n', stop) + 1 if stop >= 0 else len(text)
|
|
268
|
+
return text[:start] + block + text[stop:]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check(repo):
|
|
272
|
+
"""Paths (relative to `repo`) whose committed marker block is not what the renderer emits. A target that is
|
|
273
|
+
missing or has no block yet is reported too: an unadopted surface is drift, not a skip."""
|
|
274
|
+
drifted = []
|
|
275
|
+
for kind, rel in TARGETS.items():
|
|
276
|
+
path = Path(repo) / rel
|
|
277
|
+
try:
|
|
278
|
+
text = path.read_text()
|
|
279
|
+
except OSError:
|
|
280
|
+
drifted.append(rel)
|
|
281
|
+
continue
|
|
282
|
+
if _splice(text, kind) != text:
|
|
283
|
+
drifted.append(rel)
|
|
284
|
+
return drifted
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv):
|
|
288
|
+
repo = Path(__file__).resolve().parents[1]
|
|
289
|
+
if argv[:1] == ['--check']:
|
|
290
|
+
drifted = check(repo)
|
|
291
|
+
for rel in drifted:
|
|
292
|
+
kind = next(k for k, r in TARGETS.items() if r == rel)
|
|
293
|
+
current = (repo / rel).read_text() if (repo / rel).exists() else ''
|
|
294
|
+
sys.stdout.writelines(difflib.unified_diff(current.splitlines(True), _splice(current, kind).splitlines(True), rel, rel + ' (generated)'))
|
|
295
|
+
return 1 if drifted else 0
|
|
296
|
+
if argv[:1] == ['--write']:
|
|
297
|
+
for kind, rel in TARGETS.items():
|
|
298
|
+
path = repo / rel
|
|
299
|
+
current = path.read_text() if path.exists() else ''
|
|
300
|
+
path.write_text(_splice(current, kind))
|
|
301
|
+
return 0
|
|
302
|
+
kind = argv[0].lstrip('-') if argv else ''
|
|
303
|
+
if kind not in RENDERERS:
|
|
304
|
+
sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--check|--write')
|
|
305
|
+
sys.stdout.write(render(kind))
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == '__main__':
|
|
310
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Lanes 16-colour SGR literals for view.sh status screens. Sourced, never executed.
|
|
2
|
+
# The block below is generated: edit libexec/tokens.py and run `python3 libexec/tokens.py --write`.
|
|
3
|
+
# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)
|
|
4
|
+
LZ_BOLD='\033[1m'
|
|
5
|
+
LZ_DIM='\033[90m'
|
|
6
|
+
LZ_URGENT='\033[31m'
|
|
7
|
+
LZ_ATTENTION='\033[33m'
|
|
8
|
+
LZ_WORKING='\033[34m'
|
|
9
|
+
LZ_DONE='\033[32m'
|
|
10
|
+
LZ_RESET='\033[0m'
|
|
11
|
+
# END lanes tokens
|
|
@@ -58,12 +58,22 @@ reset_terminal() {
|
|
|
58
58
|
printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[<u\033[0m\033[?25h\033[2J\033[3J\033[H'
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
# The 16-colour SGR literals of the design tokens (libexec/tokens.py --sh): sourced once, printf only, no fork.
|
|
62
|
+
# They hold `\033[…m` as text, so they go into printf's FORMAT (which turns `\033` into ESC), never into a %s argument.
|
|
63
|
+
# shellcheck source=libexec/tokens.sh
|
|
64
|
+
. "$repo/libexec/tokens.sh"
|
|
65
|
+
|
|
61
66
|
# status_screen <title> <text> <dim line> [<lead>]: a lead, when given, is the bold headline above the title.
|
|
67
|
+
# The lead glyph follows the sidebar's published glyph mode ($STATE/clients/<device>.glyphs, written by sidebar.py from
|
|
68
|
+
# devices/<device>.json) and is read on EVERY call with the builtin read, no fork: this runs exactly when the host may be
|
|
69
|
+
# too slow for one, and `lanes attach DEVICE --ascii` against a live session must reach the next screen without a restart.
|
|
62
70
|
status_screen() {
|
|
63
71
|
reset_terminal
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
printf
|
|
72
|
+
glyphs=unicode; { IFS= read -r glyphs < "$state/clients/$device.glyphs"; } 2>/dev/null || :
|
|
73
|
+
lead_glyph='⚠'; [ "$glyphs" != ascii ] || lead_glyph='!'
|
|
74
|
+
if [ -n "${4:-}" ]; then printf "\n ${LZ_BOLD}%s %s${LZ_RESET}\n\n %s\n" "$lead_glyph" "$4" "$1"
|
|
75
|
+
else printf "\n ${LZ_BOLD}%s${LZ_RESET}\n" "$1"; fi
|
|
76
|
+
printf "\n %s\n\n ${LZ_DIM}%s${LZ_RESET}\n" "$2" "$3"
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
# Is the socket of the lane just shown still there? Then only this VIEWER ended: a lane lives in its holder's server
|
|
@@ -103,7 +113,7 @@ while :; do
|
|
|
103
113
|
# A background-created zellij session has no real size yet: idle until a client has attached once.
|
|
104
114
|
while [ ! -e "$state/clients/$device.ready" ] || { [ "$mobile" = 1 ] && [ ! -e "$dir/$ws.$slot.shown" ]; }; do
|
|
105
115
|
status_screen "$ws" 'waiting for the first client…' ''
|
|
106
|
-
run
|
|
116
|
+
run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 2 || :
|
|
107
117
|
done
|
|
108
118
|
|
|
109
119
|
# zellij sizes a tab only once some client has looked at it; until then the pane has a made-up size
|
|
@@ -115,7 +125,7 @@ while :; do
|
|
|
115
125
|
[ "$mobile" != 1 ] || { min_cols=20; min_rows=4; }
|
|
116
126
|
if [ "${cols:-0}" -ge "$min_cols" ] 2>/dev/null && [ "${rows:-0}" -ge "$min_rows" ] 2>/dev/null; then break; fi
|
|
117
127
|
status_screen "$ws" 'waiting to be shown…' ''
|
|
118
|
-
sizing=1; run
|
|
128
|
+
sizing=1; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 3 || :; sizing=0
|
|
119
129
|
done
|
|
120
130
|
|
|
121
131
|
lane=$(head -n 1 "$target_file" 2>/dev/null || :)
|
|
@@ -126,13 +136,13 @@ while :; do
|
|
|
126
136
|
if [ "$slot" = main ]; then hint='Pick one in the sidebar (Ctrl-Alt-/ to find a lane).'
|
|
127
137
|
else hint='In the sidebar, move to a lane and press 2 (or 3) to show it here.'; fi
|
|
128
138
|
status_screen "$ws" 'No lane selected.' "$hint"
|
|
129
|
-
switching=0; run
|
|
139
|
+
switching=0; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 5 --preserve-screen || :; continue ;;
|
|
130
140
|
esac
|
|
131
141
|
resolve=$state/resolve/$lane
|
|
132
142
|
if [ ! -r "$resolve" ]; then resolve=$state/resolve.dynamic/$lane; fi
|
|
133
143
|
if [ ! -r "$resolve" ]; then
|
|
134
144
|
status_screen "$lane" 'This lane is no longer in the registry.' 'Pick another lane in the sidebar.'
|
|
135
|
-
switching=0; run
|
|
145
|
+
switching=0; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 5 --preserve-screen || :; continue
|
|
136
146
|
fi
|
|
137
147
|
{ IFS= read -r holder; IFS= read -r f2; IFS= read -r f3; IFS= read -r f4; IFS= read -r title; } < "$resolve"
|
|
138
148
|
|
|
@@ -140,9 +150,10 @@ while :; do
|
|
|
140
150
|
switching=0
|
|
141
151
|
started=$(date +%s)
|
|
142
152
|
case "$holder" in
|
|
143
|
-
tmux) run "$repo/holders/tmux.sh" "$f2" "$f3" "$f4" || : ;;
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
tmux) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/tmux.sh" "$f2" "$f3" "$f4" || : ;;
|
|
154
|
+
ssh) run "$repo/holders/ssh.sh" "$f2" "$f3" "$f4" || : ;;
|
|
155
|
+
zmx) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/zmx.sh" "$f2" "$f3" "$f4" || : ;;
|
|
156
|
+
exec) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/exec.sh" "$f2" "$f3" "$f4" || : ;;
|
|
146
157
|
*) rc=64 ;;
|
|
147
158
|
esac
|
|
148
159
|
if [ "$switching" = 1 ]; then delay=1; continue; fi
|
|
@@ -173,7 +184,7 @@ while :; do
|
|
|
173
184
|
health_hint "$ended"
|
|
174
185
|
status_screen "${title:-$lane}" "${reason:-viewer detached (exit $rc)}${untouched}; retrying in ${delay}s.${host_hint:+
|
|
175
186
|
$host_hint}" "$holder · $f2 · $f3 · $f4" "$lead"
|
|
176
|
-
run
|
|
187
|
+
run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait "$delay" --preserve-screen || :
|
|
177
188
|
delay=$((delay * 2))
|
|
178
189
|
if [ "$delay" -gt 15 ]; then delay=15; fi
|
|
179
190
|
done
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes economy projection (opt-in: GETs the Throttle daemon's loopback /status and /sessions, writes economy.json)
|
|
3
|
+
After=lanes-statusd.service
|
|
4
|
+
|
|
5
|
+
# No installer enables this unit. It reads the production daemon's control port, so switching it on is an operator
|
|
6
|
+
# action: systemctl --user enable --now lanes-economy.service (RUNBOOK "Economy projection (opt-in)").
|
|
7
|
+
# It runs on the HOST, not in the daemon's container: point LANES_THROTTLE_CONTAINER at that container for the log,
|
|
8
|
+
# and use LANES_THROTTLE_CONTROL=none unless the control port is reachable from the host (RUNBOOK, DEF-1444).
|
|
9
|
+
[Service]
|
|
10
|
+
ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/economy_poll.py" --interval 10
|
|
11
|
+
Restart=on-failure
|
|
12
|
+
RestartSec=10
|
|
13
|
+
Nice=10
|
|
14
|
+
NoNewPrivileges=true
|
|
15
|
+
MemoryHigh=96M
|
|
16
|
+
MemoryMax=192M
|
|
17
|
+
Slice=lanes.slice
|
|
18
|
+
|
|
19
|
+
[Install]
|
|
20
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes push notifications (opt-in: POSTs needs-you transitions from status.json to an ntfy topic)
|
|
3
|
+
After=lanes-statusd.service
|
|
4
|
+
|
|
5
|
+
# No installer enables this unit. It sends lane names and agent tasks to the ntfy server in
|
|
6
|
+
# ~/.config/lanes/notify.conf (LANES_NOTIFY_CONF), so switching it on is an operator action:
|
|
7
|
+
# systemctl --user enable --now lanes-notify.service (RUNBOOK "Push notifications (opt-in)").
|
|
8
|
+
[Service]
|
|
9
|
+
ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/notify.py" --interval 3
|
|
10
|
+
Restart=on-failure
|
|
11
|
+
RestartSec=10
|
|
12
|
+
Nice=10
|
|
13
|
+
NoNewPrivileges=true
|
|
14
|
+
MemoryHigh=48M
|
|
15
|
+
MemoryMax=96M
|
|
16
|
+
Slice=lanes.slice
|
|
17
|
+
|
|
18
|
+
[Install]
|
|
19
|
+
WantedBy=default.target
|
|
@@ -4,6 +4,9 @@ Description=lanes registry sync (read-only: rebuild registry.json from the repos
|
|
|
4
4
|
[Service]
|
|
5
5
|
Type=oneshot
|
|
6
6
|
ExecStart="@REPO@/bin/lanes" sync
|
|
7
|
+
# Bounded: one `docker inspect` (20 s) plus the registry reads. A sync wedged on a hung docker or volume is
|
|
8
|
+
# stopped here instead of holding the oneshot (and every later timer tick) forever.
|
|
9
|
+
TimeoutStartSec=120
|
|
7
10
|
# A momentarily invalid registry keeps the previous lanes; a non-zero exit here is informational.
|
|
8
11
|
SuccessExitStatus=1
|
|
9
12
|
Nice=5
|
|
@@ -5,11 +5,35 @@ The Lanes shell enhances the pinned Zellij 0.45.1 web client on phones. It uses
|
|
|
5
5
|
There is no separate auth system or command API. Workspace names are rendered as text, and navigation
|
|
6
6
|
uses Zellij's authenticated `FocusPane` messages. Desktop browsers keep the upstream interface.
|
|
7
7
|
|
|
8
|
+
`laneShellMode` (in `mobile.js`) chooses the mode once at load and records it on `<html data-lanes-shell>`:
|
|
9
|
+
phones get this shell; tablets (other touch screens) get the same shell with the dock in the top bar and the
|
|
10
|
+
workspace sheet as a left panel, and set `zellij:mobile-ui` to `on` when it is unset (iPad Safari reports a desktop
|
|
11
|
+
user agent). Desktop browsers (fine pointer) get only a **Lanes** chip and an overlay bar with workspace and lane
|
|
12
|
+
pickers (`laneBarModel`, from the `MobileState` sent to every web client). The laptop/tablet code and styles live in
|
|
13
|
+
`browser.js`/`browser.css`, which `mobile.js` appends only on a tablet or desktop (a phone never downloads them), so
|
|
14
|
+
a phone downloads only the two files that spec §8 budgets (measured figures under Bundle budget below). The chip
|
|
15
|
+
cancels `mousedown` and refocuses the terminal, so the keys stay the terminal's. On a tablet, `browser.js` also
|
|
16
|
+
undoes the client's per-load single-pane start once
|
|
17
|
+
(`laneFullRender`, the client's own `SetMobileRenderPreferences {single_pane: false, fit: true}`), so a desktop
|
|
18
|
+
view's sidebar shows beside its lanes. `tests/web/activation.test.mjs` (`node --test`) pins the activation matrix,
|
|
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`.
|
|
23
|
+
|
|
8
24
|
The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
|
|
9
25
|
Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
|
|
10
26
|
blocks programmatic focus on text editors and keeps `__zjSoftKbdEnabled` false, so upstream touch handlers,
|
|
11
27
|
two-finger gestures, server `SetSoftKeyboard` messages and modal autofocus cannot summon it. Search and
|
|
12
28
|
login inputs still focus normally when tapped. Keep this policy in place when upgrading the upstream client.
|
|
29
|
+
The guard is installed before Zellij's first terminal or login modal: otherwise Zellij refocuses a hidden input on
|
|
30
|
+
touchstart, touchend, click and server `SetSoftKeyboard` messages. The **Type here** field keeps the original input
|
|
31
|
+
and its event handlers inside the closed shadow root (including its protection from password-manager extensions) and
|
|
32
|
+
makes it a real, tappable field instead of a 1 px invisible focus target; keystrokes appear in the terminal and its
|
|
33
|
+
internal padding/history stays invisible. Upstream cancels `pointerdown` on the extra keys, which also suppresses
|
|
34
|
+
their touch click in Safari, so the shell cancels `mousedown` instead: typing focus is kept while the native click
|
|
35
|
+
still activates CTRL, the arrows and the other keys. The shell attaches only once server state has arrived — resizing
|
|
36
|
+
before the control socket opens makes 0.45.1 throw `InvalidStateError` and leaves the server with the old size.
|
|
13
37
|
|
|
14
38
|
`lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
|
|
15
39
|
It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
|
|
@@ -26,8 +50,78 @@ clearing login cookies. There is no service worker caching terminal data or cred
|
|
|
26
50
|
Layout uses the visual viewport and safe-area insets, with independent handling for portrait, landscape,
|
|
27
51
|
keyboard-open and Home Screen views. The default terminal font is 16 px; the user's text-size choice
|
|
28
52
|
is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px). The lane picker in
|
|
29
|
-
`libexec/sidebar.py` uses three terminal rows per lane on mobile
|
|
30
|
-
the touch-to-lane path with isolated holders; sidebar unit tests cover the
|
|
53
|
+
`libexec/sidebar.py` uses three terminal rows per lane on mobile: name, state and age, the agent's task.
|
|
54
|
+
`tests/mobile_view.py` verifies the touch-to-lane path with isolated holders; sidebar unit tests cover the
|
|
55
|
+
complete touch target.
|
|
56
|
+
|
|
57
|
+
Every byte the shell sends to a terminal goes through `__zjSendControl({type: 'Write', bytes})`
|
|
58
|
+
(`sendToTerminal`); there is no focus-and-type fallback, since the focus guard above would make it type nowhere.
|
|
59
|
+
The lane filter bar sends `Esc /` and the query as UTF-8 (`TextEncoder`), dropping control bytes and DEL.
|
|
60
|
+
Attention counts come from the picker sidebar's pane title (`WS · ◆1 ◇2 ▸6`, written by the sidebar as OSC 2):
|
|
61
|
+
`parseCounts` (also `window.__lanesParseCounts`) feeds the picker subtitle, `document.title`, the Lanes button's
|
|
62
|
+
`data-count` and the workspace cards, inside the existing `refresh()` / `renderCards()` — no fetch, file or timer.
|
|
63
|
+
Until plan Task 26 (2026-10-04) this reader was inert (DEF-1450): `bin/lanes` `mobile_workspace_tab` gave the
|
|
64
|
+
picker pane a fixed `name="Lanes"`, and Zellij reports a pane's layout name instead of its OSC 2 title, so
|
|
65
|
+
`pickerTitle()` read `Lanes`, `parseCounts` returned null and the shell showed no counts ("Choose a lane to continue").
|
|
66
|
+
Task 26 dropped that `name=` (pinned by `tests/unit/test_layout.py`) for sessions laid out since: an existing
|
|
67
|
+
phone device keeps the old picker pane until `lanes rebuild <device>` (`upgrade-session` never re-lays-out a tab;
|
|
68
|
+
`lanes doctor` names such a device). Whether the OSC 2 title now reaches
|
|
69
|
+
`panes[].title` is still unchecked in a browser (no WebKit pass has run; fallback: the counts stay in the
|
|
70
|
+
sidebar header). The quick-key row (`⏎ y n Esc ^C ↑ ↓`, `.lanes-quick`) shows on view
|
|
71
|
+
tabs only; `refresh()` sets `--lanes-quick` (48 px or 0), which the terminal height subtracts. `n` and `^C` send
|
|
72
|
+
only when `pointerup.timeStamp − pointerdown.timeStamp ≥ 400` (no `setTimeout`); the buttons cancel `mousedown`
|
|
73
|
+
so a tap never moves focus.
|
|
74
|
+
|
|
75
|
+
Colour (spec §6.3; the operator legend, token table and phone mockup are in [`docs/DESIGN.md`](../docs/DESIGN.md)):
|
|
76
|
+
`mobile.css` uses only the generated `--lanes-*` tokens (legacy names such as `--lanes-bg` stay
|
|
77
|
+
as aliases for one release); `test_web` rejects any colour literal outside the generated block. The terminal palette
|
|
78
|
+
is the phone's own, because Zellij's `web_client.theme` is one server-wide dark palette (DEF-1413): `lanesTheme()`
|
|
79
|
+
builds xterm.js's theme from the CSS tokens (`getComputedStyle(root)`) and the sixteen `LANES_ANSI` slots, which
|
|
80
|
+
`libexec/tokens.py --js` generates into `mobile.js` (never hand-edited). It is applied at three points, all event
|
|
81
|
+
driven: (1) on attach, after `build(); applyFont();`; (2) on every `data-zj-theme` change, through one
|
|
82
|
+
`MutationObserver` on `<html>` (upstream's theme toggle only flips that attribute); (3) after every `SetConfig`, by
|
|
83
|
+
chaining `window.__zjSyncInactiveCursorStyle`, which the pinned `app.js` calls in its `SetConfig` handler right after
|
|
84
|
+
assigning the server theme wholesale (`SetConfig` is sent on each config-file reload). The handler then runs
|
|
85
|
+
`applyTerminalBackground`, which writes the dark background inline on `<body>` and `#terminal`; the stylesheet's
|
|
86
|
+
`!important` `--lanes-surface` rule for those two elements outranks it in either variant. An SRI bump of `app.js`
|
|
87
|
+
re-opens this contract: re-read `startWsControl` and `installSoftKeyboardCapture` before accepting it.
|
|
88
|
+
`index.html` carries one `theme-color` per colour scheme. Bundle budget (spec §8, `test_web.test_bundle_growth`):
|
|
89
|
+
`mobile.css` ≤ 18 982 B and `mobile.js` ≤ 22 174 B; the caps are never raised — the rules sit one per line and the
|
|
90
|
+
explanations live here rather than in the shipped files. MEASURED 2026-10-04 with `wc -c` after LNS-31 (plan Task
|
|
91
|
+
31) met Tasks 13/28: `mobile.js` 21 964 B (23 268 B before its comments moved to Code notes below) and `mobile.css`
|
|
92
|
+
18 945 B. A laptop or tablet also fetches `browser.js` (3 641 B) and `browser.css` (1 947 B); `test_web_view` pins
|
|
93
|
+
that download at `LAPTOP_TABLET_JS_RATCHET` and `LAPTOP_TABLET_CSS_CAP` (DEF-1488).
|
|
94
|
+
|
|
95
|
+
## Code notes
|
|
96
|
+
|
|
97
|
+
`mobile.js` carries no explanations (the spec §8 budget above); each note is keyed to the code it explains, in file
|
|
98
|
+
order.
|
|
99
|
+
|
|
100
|
+
- The file: the Lanes browser shell (phone, tablet, desktop) over the pinned Zellij 0.45.1 client's window hooks.
|
|
101
|
+
- `root.dataset.lanesShell = mode`: read by `browser.js` for the desktop bar and the tablet's full rendering.
|
|
102
|
+
- `if (mode !== 'phone')`: appends the laptop/tablet extras (`browser.css`, `browser.js`); a phone never fetches them.
|
|
103
|
+
- `if (mode === 'tablet')`: iPad Safari's user agent is a desktop one, so the shell asks for the client's mobile UI
|
|
104
|
+
(`zellij:mobile-ui` set to `on`); a stored `off` stays.
|
|
105
|
+
- The `HTMLElement.prototype.focus` guard: only a native tap on an input may open the keyboard; it is installed before
|
|
106
|
+
upstream's first terminal or modal.
|
|
107
|
+
- `viewport()`: browser toolbars alone must not mean keyboard mode (`occluded` needs more than 120 px hidden). Zellij's
|
|
108
|
+
input is in a closed shadow root, so `document.activeElement` is its host; `__zjSoftKbdCapture.isFocused` is read
|
|
109
|
+
first.
|
|
110
|
+
- `buildTypingField()`: upstream's capture input stays in its closed shadow root and becomes the real, tappable field.
|
|
111
|
+
- `GLYPHS` / `parseCounts()`: counts from the picker sidebar's OSC 2 pane title (spec §6.4).
|
|
112
|
+
- `build()`, the `.zj-mobile-modbar` keys: cancel `mousedown`, not `pointerdown` (upstream's `pointerdown` cancel kills
|
|
113
|
+
Safari's touch click on the extra keys).
|
|
114
|
+
- `buildQuickRow()` (spec §6.5): `n` and `^C` send after a 400 ms hold, measured from event timestamps with no timer;
|
|
115
|
+
nothing takes focus.
|
|
116
|
+
- `clearLaneFilter()`: the lone `Esc` leaves the sidebar's filter mode.
|
|
117
|
+
- `lanesTheme()`: the shell's own terminal palette for both variants (spec §6.3, DEF-1413).
|
|
118
|
+
- `connect()`: waits for server state, because an earlier resize throws `InvalidStateError` in 0.45.1. `applyTheme` is
|
|
119
|
+
re-applied after every `SetConfig` (the client calls the chained `__zjSyncInactiveCursorStyle` hook) and on the theme
|
|
120
|
+
toggle.
|
|
121
|
+
- The `zellij:soft-keyboard-visibility` listener: rotation also shrinks the viewport, so the shell sends
|
|
122
|
+
`SoftKeyboardVisibilityChanged` itself to correct upstream's height-only keyboard heuristic.
|
|
123
|
+
- `awaitingClient` (the one `setInterval`, 200 ms, cleared once attached): upstream installs its window hooks just after
|
|
124
|
+
appending its mobile root.
|
|
31
125
|
|
|
32
126
|
Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
|
|
33
127
|
838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* LNS-31 laptop/tablet extras, outside the phone's mobile.css budget (spec §8). No scroll-time paint. */
|
|
2
|
+
/* Tablet (LNS-31): the dock joins the top bar; the workspace sheet is a left panel */
|
|
3
|
+
html.lanes-tablet, html.lanes-tablet.lanes-keyboard { --lanes-dock: 0px; }
|
|
4
|
+
.lanes-tablet .lanes-dock { position: static; display: flex; gap: 4px; width: auto; height: 48px; padding: 0; border: 0; background: none; }
|
|
5
|
+
.lanes-tablet .lanes-dock .lanes-button { flex-direction: row; gap: 6px; min-height: 44px; padding: 0 10px; }
|
|
6
|
+
.lanes-tablet .lanes-dock .lanes-button span { font-size: 13px; }
|
|
7
|
+
.lanes-tablet .lanes-terminal-input { width: 140px; height: 44px; }
|
|
8
|
+
.lanes-tablet .lanes-input-label { flex-direction: row; }
|
|
9
|
+
.lanes-tablet .lanes-filter-bar { flex: 0 1 220px; }
|
|
10
|
+
.lanes-tablet .lanes-sheet { align-items: stretch; padding: 0; }
|
|
11
|
+
.lanes-tablet .lanes-sheet-panel {
|
|
12
|
+
width: min(400px, 85vw); max-height: none; border: 1px solid var(--lanes-border); border-left: 0;
|
|
13
|
+
border-radius: 0 24px 24px 0; padding: calc(20px + var(--lanes-safe-top)) 16px calc(8px + var(--lanes-safe-bottom)) calc(16px + var(--lanes-safe-left));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Desktop (LNS-31): the plain client plus a Lanes chip and its overlay bar; system colours */
|
|
17
|
+
.lanes-chip, .lanes-bar { position: fixed; z-index: 600; color-scheme: dark; font: 13px system-ui, sans-serif; }
|
|
18
|
+
.lanes-chip { top: 4px; right: 8px; padding: 2px 10px; border: 1px solid GrayText; border-radius: 12px; background: Canvas; color: CanvasText; opacity: .7; cursor: pointer; }
|
|
19
|
+
.lanes-chip:hover, .lanes-chip[aria-expanded="true"] { opacity: 1; }
|
|
20
|
+
.lanes-bar { top: 0; left: 0; right: 0; height: 34px; display: flex; gap: 8px; align-items: center; padding: 0 80px 0 8px; background: Canvas; border-bottom: 1px solid GrayText; }
|
|
21
|
+
.lanes-bar[hidden] { display: none; }
|
|
22
|
+
.lanes-bar select { max-width: 40%; height: 26px; border: 1px solid GrayText; border-radius: 6px; background: Canvas; color: CanvasText; font: inherit; }
|