@bakapiano/ccsm 0.17.11 → 0.18.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.
package/lib/devices.js ADDED
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ // Remote-device approval store. Each browser that arrives via the tunnel
4
+ // generates a UUID client-side (`ccsm.deviceId` in localStorage) and sends
5
+ // it as `X-Device-Id` on every API call. server.js' middleware feeds those
6
+ // arrivals through record() — known devices get their lastSeen bumped,
7
+ // new ones get inserted as `pending`. Until the host explicitly Approves
8
+ // from the Remote page, every non-loopback request returns 403 with the
9
+ // pending status.
10
+ //
11
+ // Stored at ~/.ccsm/devices.json:
12
+ // {
13
+ // "<uuid>": {
14
+ // id, status: 'pending'|'approved'|'rejected',
15
+ // userAgent, ip,
16
+ // firstSeen, lastSeen, approvedAt,
17
+ // label // user-set or auto-derived from UA
18
+ // },
19
+ // ...
20
+ // }
21
+
22
+ const { DATA_DIR } = require('./config');
23
+ const { createKeyedJsonStore } = require('./jsonStore');
24
+ const { withFileLock } = require('./atomicJson');
25
+
26
+ const store = createKeyedJsonStore({
27
+ dataDir: DATA_DIR,
28
+ filename: 'devices.json',
29
+ });
30
+
31
+ // `record()` runs on EVERY non-loopback API request. Two side effects
32
+ // without these guards:
33
+ // 1. Concurrent calls each do load→mutate→save independently; the
34
+ // parallel rename(tmp → target) collides on Windows and surfaces
35
+ // as `EPERM: operation not permitted`.
36
+ // 2. Even when serialized, writing on every request hammers the disk
37
+ // for a value that only needs ~minute-grained accuracy (lastSeen
38
+ // drives "seen 5m ago" labels in the UI).
39
+ // Fix: serialize all mutators through the shared per-file lock, and
40
+ // short-circuit lastSeen-only updates that landed within MIN_FLUSH_MS
41
+ // of the last persisted write for the same id.
42
+ const MIN_FLUSH_MS = 15_000;
43
+ const lastFlushAt = new Map(); // id → ms timestamp of last save
44
+
45
+ // Pending entries older than 24h are auto-pruned on each list() so a
46
+ // drive-by scanner doesn't grow the file forever. Rejected entries kept
47
+ // 1h so the host can see what got bounced and rename / un-reject if
48
+ // they realize it was legit.
49
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
50
+ const REJECTED_TTL_MS = 60 * 60 * 1000;
51
+
52
+ // Quick UA → human-readable label. We keep this tiny on purpose — full
53
+ // UA parsing libraries are huge and the only consumer is one line in
54
+ // the approval UI. Order matters: Edge UA includes "Chrome" so detect
55
+ // Edge first.
56
+ function describeUA(ua) {
57
+ ua = String(ua || '');
58
+ const device =
59
+ /iPhone/.test(ua) ? 'iPhone'
60
+ : /iPad/.test(ua) ? 'iPad'
61
+ : /Android/.test(ua) ? 'Android'
62
+ : /Mac OS X/.test(ua) ? 'Mac'
63
+ : /Windows/.test(ua) ? 'Windows'
64
+ : /Linux/.test(ua) ? 'Linux'
65
+ : null;
66
+ const browser =
67
+ /Edg\//.test(ua) ? 'Edge'
68
+ : /OPR\//.test(ua) ? 'Opera'
69
+ : /Chrome\//.test(ua) ? 'Chrome'
70
+ : /Firefox\//.test(ua) ? 'Firefox'
71
+ : /Safari\//.test(ua) ? 'Safari'
72
+ : null;
73
+ if (device && browser) return `${device} · ${browser}`;
74
+ if (device) return device;
75
+ if (browser) return browser;
76
+ return 'Unknown device';
77
+ }
78
+
79
+ async function pruneStale(map) {
80
+ const now = Date.now();
81
+ let dirty = false;
82
+ for (const [id, d] of Object.entries(map)) {
83
+ if (d.status === 'pending' && now - d.firstSeen > PENDING_TTL_MS) { delete map[id]; dirty = true; }
84
+ if (d.status === 'rejected' && now - (d.rejectedAt || d.lastSeen) > REJECTED_TTL_MS) { delete map[id]; dirty = true; }
85
+ }
86
+ if (dirty) await store.save(map);
87
+ return map;
88
+ }
89
+
90
+ // Upsert. Returns the (possibly newly-created) device record. Caller
91
+ // uses .status to decide whether to gate further work.
92
+ async function record(id, { userAgent, ip } = {}) {
93
+ if (!id) throw new Error('device id required');
94
+ return withFileLock(store.filePath, async () => {
95
+ const map = await store.load();
96
+ const now = Date.now();
97
+ const existing = map[id];
98
+ if (existing) {
99
+ // Throttled lastSeen update: if the only thing that would change
100
+ // is lastSeen and we flushed for this id within MIN_FLUSH_MS,
101
+ // return the in-memory copy without touching disk. Saves a
102
+ // disk write per request when remote pages poll at 2.5s.
103
+ const onlyLastSeen = (!userAgent || existing.userAgent) && (!ip || existing.ip === ip);
104
+ const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
105
+ existing.lastSeen = now;
106
+ if (userAgent && !existing.userAgent) existing.userAgent = userAgent;
107
+ if (ip) existing.ip = ip;
108
+ if (onlyLastSeen && recentlyFlushed) return existing;
109
+ await store.save(map);
110
+ lastFlushAt.set(id, now);
111
+ return existing;
112
+ }
113
+ map[id] = {
114
+ id,
115
+ status: 'pending',
116
+ userAgent: userAgent || null,
117
+ ip: ip || null,
118
+ firstSeen: now,
119
+ lastSeen: now,
120
+ approvedAt: null,
121
+ rejectedAt: null,
122
+ label: describeUA(userAgent),
123
+ };
124
+ await store.save(map);
125
+ lastFlushAt.set(id, now);
126
+ return map[id];
127
+ });
128
+ }
129
+
130
+ async function get(id) {
131
+ const map = await store.load();
132
+ return map[id] || null;
133
+ }
134
+
135
+ async function isApproved(id) {
136
+ const d = await get(id);
137
+ return !!(d && d.status === 'approved');
138
+ }
139
+
140
+ async function approve(id, label) {
141
+ return withFileLock(store.filePath, async () => {
142
+ const map = await store.load();
143
+ const d = map[id];
144
+ if (!d) return null;
145
+ d.status = 'approved';
146
+ d.approvedAt = Date.now();
147
+ d.rejectedAt = null;
148
+ if (label) d.label = String(label);
149
+ await store.save(map);
150
+ lastFlushAt.set(id, Date.now());
151
+ return d;
152
+ });
153
+ }
154
+
155
+ async function reject(id) {
156
+ return withFileLock(store.filePath, async () => {
157
+ const map = await store.load();
158
+ const d = map[id];
159
+ if (!d) return null;
160
+ d.status = 'rejected';
161
+ d.rejectedAt = Date.now();
162
+ d.approvedAt = null;
163
+ await store.save(map);
164
+ lastFlushAt.set(id, Date.now());
165
+ return d;
166
+ });
167
+ }
168
+
169
+ // Identical to reject in storage terms, but separate API name so the UI
170
+ // can distinguish "I'm declining a new request" from "I'm taking back
171
+ // access from someone I'd previously approved". Both end up status:
172
+ // 'rejected' and clear approvedAt — once cleared, the device must
173
+ // request again from scratch.
174
+ async function revoke(id) {
175
+ return reject(id);
176
+ }
177
+
178
+ async function rename(id, label) {
179
+ return withFileLock(store.filePath, async () => {
180
+ const map = await store.load();
181
+ const d = map[id];
182
+ if (!d) return null;
183
+ d.label = String(label || '').slice(0, 60);
184
+ await store.save(map);
185
+ return d;
186
+ });
187
+ }
188
+
189
+ async function remove(id) {
190
+ return store.remove(id);
191
+ }
192
+
193
+ async function list() {
194
+ const map = await pruneStale(await store.load());
195
+ return Object.values(map).sort((a, b) => {
196
+ // Pending first (so the host sees them at the top), then approved
197
+ // by approvedAt desc, then rejected by rejectedAt desc.
198
+ const order = { pending: 0, approved: 1, rejected: 2 };
199
+ if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
200
+ return (b.lastSeen || 0) - (a.lastSeen || 0);
201
+ });
202
+ }
203
+
204
+ module.exports = {
205
+ record,
206
+ get,
207
+ isApproved,
208
+ approve,
209
+ reject,
210
+ revoke,
211
+ rename,
212
+ remove,
213
+ list,
214
+ describeUA,
215
+ };
package/lib/tunnel.js ADDED
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ // Tunnel manager · spawns and supervises a cloudflared or devtunnel
4
+ // child to expose the local ccsm backend over a public URL. Captures
5
+ // the URL from stdout, exposes state to the API, and tears down the
6
+ // child on stop / server shutdown.
7
+ //
8
+ // Two providers, each with their own CLI quirk:
9
+ // cloudflared · `cloudflared tunnel --url http://localhost:<port>`
10
+ // Prints `https://*.trycloudflare.com` somewhere in
11
+ // the boot banner. No login required for quick tunnels.
12
+ // devtunnel · `devtunnel host -p <port> --allow-anonymous`
13
+ // Prints `Connect via browser: https://*.devtunnels.ms`.
14
+ // Host must be logged in (`devtunnel user login`).
15
+ //
16
+ // Discovery: scan PATH first via `where.exe`, then known winget install
17
+ // dirs. Returns the absolute path so we can spawn the child regardless
18
+ // of whether the post-install PATH refresh has reached this Node process.
19
+
20
+ const { spawn, execFileSync } = require('node:child_process');
21
+ const path = require('node:path');
22
+ const fs = require('node:fs');
23
+ const os = require('node:os');
24
+
25
+ const PROVIDERS = {
26
+ cloudflared: {
27
+ id: 'cloudflared',
28
+ label: 'Cloudflare Tunnel',
29
+ wingetId: 'Cloudflare.cloudflared',
30
+ binary: 'cloudflared.exe',
31
+ knownPaths: [
32
+ path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'cloudflared', 'cloudflared.exe'),
33
+ path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'cloudflared', 'cloudflared.exe'),
34
+ ],
35
+ args: (port) => ['tunnel', '--url', `http://localhost:${port}`],
36
+ urlRegex: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i,
37
+ },
38
+ devtunnel: {
39
+ id: 'devtunnel',
40
+ label: 'Microsoft Dev Tunnel',
41
+ wingetId: 'Microsoft.devtunnel',
42
+ binary: 'devtunnel.exe',
43
+ knownPaths: [
44
+ path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages',
45
+ 'Microsoft.devtunnel_Microsoft.Winget.Source_8wekyb3d8bbwe', 'devtunnel.exe'),
46
+ ],
47
+ args: (port) => ['host', '-p', String(port), '--allow-anonymous'],
48
+ urlRegex: /https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.devtunnels\.ms/i,
49
+ needsLogin: true,
50
+ },
51
+ };
52
+
53
+ // In-memory state. Single tunnel at a time — switching providers tears
54
+ // down the old one first.
55
+ let current = null; // { provider, child, url, startedAt, log: string[] }
56
+ let token = null; // Remote-access bearer token. Null = no remote
57
+ // access enforced. Set via setToken() or by the
58
+ // start() call. Server.js middleware reads via
59
+ // getToken().
60
+
61
+ function getToken() { return token; }
62
+ function setToken(t) { token = t ? String(t) : null; return token; }
63
+
64
+ function findBinary(provider) {
65
+ const p = PROVIDERS[provider];
66
+ if (!p) return null;
67
+ // PATH lookup via where.exe — works regardless of how the CLI got
68
+ // installed (winget, choco, manual, in-tree).
69
+ try {
70
+ const out = execFileSync('where.exe', [p.binary], { stdio: ['ignore', 'pipe', 'ignore'] })
71
+ .toString().trim().split(/\r?\n/)[0];
72
+ if (out && fs.existsSync(out)) return out;
73
+ } catch { /* not on PATH */ }
74
+ // Fall back to known install locations (winget's PATH update doesn't
75
+ // reach the already-running Node process).
76
+ for (const candidate of p.knownPaths) {
77
+ if (candidate && fs.existsSync(candidate)) return candidate;
78
+ }
79
+ // For devtunnel: winget's package dir has a version suffix that
80
+ // changes between releases. Glob it.
81
+ if (provider === 'devtunnel') {
82
+ const base = path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages');
83
+ try {
84
+ for (const entry of fs.readdirSync(base)) {
85
+ if (entry.startsWith('Microsoft.devtunnel_')) {
86
+ const candidate = path.join(base, entry, 'devtunnel.exe');
87
+ if (fs.existsSync(candidate)) return candidate;
88
+ }
89
+ }
90
+ } catch {}
91
+ }
92
+ return null;
93
+ }
94
+
95
+ function getVersion(provider, exe) {
96
+ try {
97
+ const out = execFileSync(exe, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
98
+ .toString().trim().split(/\r?\n/)[0];
99
+ return out || null;
100
+ } catch { return null; }
101
+ }
102
+
103
+ function checkDevtunnelLogin(exe) {
104
+ try {
105
+ const out = execFileSync(exe, ['user', 'show'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 })
106
+ .toString().trim();
107
+ // "Logged in as <email> using <provider>." vs "Not logged in"
108
+ const m = out.match(/Logged in as (\S+)/);
109
+ if (m) return { loggedIn: true, user: m[1] };
110
+ return { loggedIn: false, user: null };
111
+ } catch {
112
+ return { loggedIn: false, user: null };
113
+ }
114
+ }
115
+
116
+ // Probe is cached. Each cold call shells out 4-6 sync execs (where.exe,
117
+ // --version per provider, `devtunnel user show`) — cumulatively ~1s of
118
+ // blocked event loop on Windows. The Remote page polls /api/tunnel/status
119
+ // every 2.5s, and tunnel.start() returns a fresh status() — without this
120
+ // cache, the loop is frozen ~40% of the time during normal operation,
121
+ // and /api/health probes from other clients time out.
122
+ const PROBE_TTL_MS = 30_000;
123
+ let probeCache = null;
124
+ let probeCacheAt = 0;
125
+
126
+ function probe(force = false) {
127
+ if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
128
+ return probeCache;
129
+ }
130
+ const out = {};
131
+ for (const id of Object.keys(PROVIDERS)) {
132
+ const exe = findBinary(id);
133
+ const p = { installed: !!exe, exe, version: exe ? getVersion(id, exe) : null };
134
+ if (id === 'devtunnel' && exe) {
135
+ Object.assign(p, checkDevtunnelLogin(exe));
136
+ }
137
+ out[id] = p;
138
+ }
139
+ probeCache = out;
140
+ probeCacheAt = Date.now();
141
+ return out;
142
+ }
143
+
144
+ // Invalidate the cache when callers know the on-disk state likely changed
145
+ // (post-install, post-login, etc.). Next probe() re-shells.
146
+ function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
147
+
148
+ function status() {
149
+ return {
150
+ providers: probe(),
151
+ running: !!current,
152
+ provider: current?.provider || null,
153
+ url: current?.url || null,
154
+ startedAt: current?.startedAt || null,
155
+ pid: current?.child?.pid || null,
156
+ log: current?.log?.slice(-50) || [],
157
+ token,
158
+ // Token is echoed back so the Remote page can render the
159
+ // pre-built share URL. The route itself is token-protected
160
+ // (the middleware blocks non-loopback callers without it), so
161
+ // anyone reaching this endpoint already knows the token.
162
+ };
163
+ }
164
+
165
+ // Spawn the tunnel CLI. Resolves once we've parsed the public URL out
166
+ // of stdout (with a timeout safety net). Throws if the CLI isn't
167
+ // installed, the provider is unknown, or another tunnel is running.
168
+ async function start({ provider, port }) {
169
+ if (current) throw new Error('tunnel already running');
170
+ const p = PROVIDERS[provider];
171
+ if (!p) throw new Error(`unknown provider: ${provider}`);
172
+ const exe = findBinary(provider);
173
+ if (!exe) throw new Error(`${p.label} is not installed`);
174
+ if (provider === 'devtunnel') {
175
+ const { loggedIn } = checkDevtunnelLogin(exe);
176
+ if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
177
+ }
178
+
179
+ const args = p.args(port);
180
+ const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
181
+ const entry = { provider, child, url: null, startedAt: Date.now(), log: [] };
182
+ current = entry;
183
+
184
+ const pushLog = (line) => {
185
+ entry.log.push(line);
186
+ if (entry.log.length > 200) entry.log.shift();
187
+ if (!entry.url) {
188
+ const m = line.match(p.urlRegex);
189
+ if (m) entry.url = m[0];
190
+ }
191
+ };
192
+ child.stdout.setEncoding('utf8');
193
+ child.stderr.setEncoding('utf8');
194
+ child.stdout.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
195
+ child.stderr.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
196
+
197
+ child.on('exit', (code, signal) => {
198
+ if (current === entry) current = null;
199
+ console.log(`[tunnel] ${provider} exited · code=${code} signal=${signal || ''}`);
200
+ });
201
+ child.on('error', (err) => {
202
+ if (current === entry) current = null;
203
+ console.error(`[tunnel] ${provider} spawn error`, err);
204
+ });
205
+
206
+ // Wait up to 25s for the URL to show up in stdout.
207
+ const deadline = Date.now() + 25_000;
208
+ while (Date.now() < deadline) {
209
+ if (entry.url) return status();
210
+ if (!current || current !== entry) {
211
+ throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
212
+ }
213
+ await new Promise((r) => setTimeout(r, 200));
214
+ }
215
+ // Timed out — keep the child alive (the URL might appear later) but
216
+ // tell the caller we don't have one yet.
217
+ return status();
218
+ }
219
+
220
+ function stop() {
221
+ if (!current) return false;
222
+ try { current.child.kill(); } catch {}
223
+ current = null;
224
+ return true;
225
+ }
226
+
227
+ // Background install via winget. Returns immediately with the spawned
228
+ // pid; the actual install completes asynchronously. Caller polls
229
+ // probe() to learn when the binary appears on disk.
230
+ function installViaWinget(provider) {
231
+ const p = PROVIDERS[provider];
232
+ if (!p) throw new Error(`unknown provider: ${provider}`);
233
+ if (process.platform !== 'win32') throw new Error('winget install only supported on Windows');
234
+ const child = spawn('winget', [
235
+ 'install', p.wingetId,
236
+ '--accept-source-agreements',
237
+ '--accept-package-agreements',
238
+ '--silent',
239
+ ], { stdio: 'ignore', detached: true, windowsHide: true });
240
+ child.unref();
241
+ return { provider, pid: child.pid };
242
+ }
243
+
244
+ module.exports = {
245
+ PROVIDERS,
246
+ probe,
247
+ status,
248
+ start,
249
+ stop,
250
+ installViaWinget,
251
+ getToken,
252
+ setToken,
253
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.17.11",
3
+ "version": "0.18.0",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -32,6 +32,13 @@ body.is-resizing-sidebar .app {
32
32
  min-height: 0;
33
33
  padding: 0 var(--s-4);
34
34
  gap: var(--s-2);
35
+ /* .settings-scroll inside uses `margin-right: -var(--s-4)` to bleed
36
+ past .main's padding so its scrollbar sits flush with the window
37
+ edge. Without this clip that 16-px bleed propagates up to <body>
38
+ and shows a permanent horizontal scrollbar on every page. Vertical
39
+ scrolling stays inside .settings-scroll itself (overflow-y:auto)
40
+ so this clip doesn't suppress anything legitimate. */
41
+ overflow-x: hidden;
35
42
  }
36
43
 
37
44
  .page-head {
@@ -1,10 +1,130 @@
1
1
  /* Narrow viewports: force-collapse sidebar, single-col config grid */
2
2
 
3
+ /* Narrow desktop / tablet viewports: stack the page-head, single-column
4
+ config grids. Sidebar stays at full width — user can still toggle it
5
+ manually via the collapse button. Phone-sized (≤ 640px) gets the FAB
6
+ drawer treatment below. */
3
7
  @media (max-width: 900px) {
4
- .app { grid-template-columns: var(--sidebar-w-collapsed) 1fr !important; }
5
- .sidebar { padding: 0 var(--s-2); }
6
- .brand-name, .nav-label, .nav-badge { opacity: 0; pointer-events: none; }
7
8
  .main { padding: 0 var(--s-4); }
8
9
  .page-head { flex-direction: column; gap: var(--s-3); }
9
10
  .config-grid { grid-template-columns: 1fr; }
10
11
  }
12
+
13
+ /* Phone viewports (≤ 640px): sidebar disappears entirely from the grid
14
+ layout; a circular floating button bottom-left toggles a full-screen
15
+ drawer that re-mounts the sidebar over everything else. */
16
+ @media (max-width: 640px) {
17
+ .app.is-mobile { grid-template-columns: 1fr !important; }
18
+ .app.is-mobile .sidebar {
19
+ /* Collapsed (drawer closed): out of the flow + invisible. */
20
+ position: fixed;
21
+ inset: 0;
22
+ z-index: 200;
23
+ width: 100%;
24
+ height: 100%;
25
+ transform: translateX(-100%);
26
+ transition: transform .22s cubic-bezier(.4, 0, .2, 1);
27
+ padding: var(--s-3);
28
+ background: var(--bg);
29
+ border-right: 0;
30
+ overflow-y: auto;
31
+ }
32
+ .app.is-mobile.drawer-open .sidebar { transform: translateX(0); }
33
+ /* Brand + nav labels come BACK on mobile — they were hidden by the
34
+ 900px collapse rule above. */
35
+ .app.is-mobile .brand-name,
36
+ .app.is-mobile .nav-label,
37
+ .app.is-mobile .nav-badge { opacity: 1; pointer-events: auto; }
38
+ /* Sidebar drag handle isn't useful at full-screen; close button isn't
39
+ either since the FAB doubles as a toggle. */
40
+ .app.is-mobile .sidebar [aria-label="resize sidebar"] { display: none; }
41
+ .app.is-mobile .collapse-toggle { display: none; }
42
+
43
+ /* Main pane occupies full width — no sidebar gutter. */
44
+ .app.is-mobile .main { padding: 0 var(--s-3); }
45
+ /* Session pane edge-to-edge margin needs to match the new padding. */
46
+ .app.is-mobile .session-pane { margin: 0 calc(-1 * var(--s-3)); }
47
+
48
+ /* The bottom-left FAB (52px circle + 16px margin) overlaps content
49
+ near the bottom-left corner. Reserve room inside scroll containers
50
+ so the last button / row isn't trapped under it. */
51
+ .app.is-mobile .settings-scroll { padding-bottom: 80px; }
52
+ .app.is-mobile .remote-page { padding-bottom: 80px; }
53
+ /* Launch page is its own block (no scroll wrapper) — push it up too
54
+ so the Launch button doesn't end up under the FAB. */
55
+ .app.is-mobile [data-panel="launch"] { padding-bottom: 80px; }
56
+ .app.is-mobile [data-panel="about"] { padding-bottom: 80px; }
57
+ /* Sessions terminal: the .session-actions footer / tabs at top means
58
+ the FAB sits over the terminal corner — leave it; the user can
59
+ scroll the terminal independently. */
60
+
61
+ /* Long inline code (URLs, paths) in the About / Remote bodies break
62
+ out of card edges on a narrow viewport. Let them wrap on any
63
+ character instead of stretching the line. */
64
+ .settings-section code,
65
+ .card code,
66
+ .remote-fact code {
67
+ word-break: break-all;
68
+ white-space: normal;
69
+ }
70
+
71
+ /* iOS Safari + Edge auto-zoom the viewport when the user taps an
72
+ <input>/<textarea> whose font-size is < 16px — "terminal suddenly
73
+ goes huge" was almost certainly this. xterm.js's hidden
74
+ .xterm-helper-textarea sits at 12px CSS by default; bumping it to
75
+ 16px stops the auto-zoom without affecting visible text (the
76
+ textarea is positioned at the cursor and overlapped by canvas
77
+ glyphs). Same defensive bump on every actual form input. */
78
+ .xterm-helper-textarea,
79
+ .input,
80
+ input[type="text"],
81
+ input[type="search"],
82
+ input[type="email"],
83
+ input[type="number"],
84
+ input[type="password"],
85
+ input[type="url"],
86
+ textarea,
87
+ select { font-size: 16px !important; }
88
+ }
89
+
90
+ /* FAB + backdrop · sit above page content but BELOW dialogs. */
91
+ .mobile-nav-fab {
92
+ position: fixed;
93
+ bottom: max(16px, env(safe-area-inset-bottom));
94
+ left: max(16px, env(safe-area-inset-left));
95
+ z-index: 210;
96
+ width: 52px;
97
+ height: 52px;
98
+ border-radius: 50%;
99
+ border: 1px solid var(--border-strong);
100
+ background: var(--bg-elev);
101
+ color: var(--ink);
102
+ display: inline-flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ box-shadow:
106
+ 0 10px 24px -6px rgba(0,0,0,.28),
107
+ 0 2px 4px rgba(0,0,0,.10);
108
+ cursor: pointer;
109
+ transition: transform .15s, box-shadow .15s, background .15s;
110
+ }
111
+ .mobile-nav-fab:hover { transform: translateY(-1px); background: var(--bg); }
112
+ .mobile-nav-fab:active { transform: translateY(0); }
113
+ .mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; }
114
+ /* Open state stays the same paper white — only the icon swaps to X.
115
+ Keeping the colour consistent reads as "same button, different
116
+ mode" instead of two different controls. */
117
+ .mobile-nav-fab.is-open {
118
+ background: var(--bg-elev);
119
+ color: var(--ink);
120
+ border-color: var(--ink);
121
+ }
122
+
123
+ .mobile-nav-backdrop {
124
+ position: fixed;
125
+ inset: 0;
126
+ z-index: 199; /* below sidebar (200) + fab (210), above content */
127
+ background: rgba(26, 24, 21, 0.45);
128
+ backdrop-filter: blur(2px);
129
+ animation: panel-in .15s ease-out;
130
+ }
@@ -418,11 +418,25 @@
418
418
  height: 100%;
419
419
  }
420
420
  .session-pane-body .terminal-empty {
421
- background: var(--bg);
421
+ background: #1a1815;
422
+ color: #e8e3d5;
422
423
  display: flex;
423
424
  flex-direction: column;
424
425
  align-items: center;
425
426
  justify-content: center;
426
427
  gap: var(--s-3);
427
428
  height: 100%;
429
+ font-size: 13px;
430
+ }
431
+ .session-pane-body .terminal-empty .mono {
432
+ color: #e07b6e;
433
+ }
434
+ .session-pane-body .terminal-empty .action.primary {
435
+ background: #e8e3d5;
436
+ color: #1a1815;
437
+ border-color: #e8e3d5;
438
+ }
439
+ .session-pane-body .terminal-empty .action.primary:hover {
440
+ background: #faf9f5;
441
+ border-color: #faf9f5;
428
442
  }