@claudecollab/cli 0.1.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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/package.json +36 -0
  4. package/packages/cli/bin/claude-share.js +1319 -0
  5. package/packages/cli/package.json +15 -0
  6. package/packages/cli/src/brain/card.js +157 -0
  7. package/packages/cli/src/brain/commands.js +122 -0
  8. package/packages/cli/src/brain/drafts.js +557 -0
  9. package/packages/cli/src/brain/gate.js +134 -0
  10. package/packages/cli/src/brain/knocks.js +27 -0
  11. package/packages/cli/src/brain/log.js +123 -0
  12. package/packages/cli/src/brain/queue.js +81 -0
  13. package/packages/cli/src/brain/state.js +245 -0
  14. package/packages/cli/src/hooks.js +243 -0
  15. package/packages/cli/src/invite.js +43 -0
  16. package/packages/cli/src/known-relays.js +66 -0
  17. package/packages/cli/src/pty.js +175 -0
  18. package/packages/cli/src/relay-client.js +271 -0
  19. package/packages/cli/src/renderer.js +230 -0
  20. package/packages/cli/src/screen-snapshot.js +139 -0
  21. package/packages/cli/src/term-chatter.js +89 -0
  22. package/packages/relay/auth.js +18 -0
  23. package/packages/relay/bin/serve.js +73 -0
  24. package/packages/relay/names.js +27 -0
  25. package/packages/relay/package.json +13 -0
  26. package/packages/relay/public/client.js +1553 -0
  27. package/packages/relay/public/index.html +110 -0
  28. package/packages/relay/public/style.css +952 -0
  29. package/packages/relay/rooms.js +149 -0
  30. package/packages/relay/server.js +652 -0
  31. package/packages/relay/web.js +334 -0
  32. package/packages/relay/wordlists.js +38 -0
  33. package/packages/shared/package.json +8 -0
  34. package/packages/shared/protocol.js +189 -0
@@ -0,0 +1,1553 @@
1
+ // The claude-share browser client — the one multiplayer surface for everyone, the
2
+ // host included (design v2 verdict). It is a plain ESM module, no framework, no
3
+ // build step: index.html loads the vendored xterm bundle (a classic <script> that
4
+ // puts `Terminal` on the global) and then this file as `<script type="module">`.
5
+ //
6
+ // Two halves live here:
7
+ //
8
+ // 1. PURE view-model helpers (exported, DOM-free) — they turn the host's overlay
9
+ // snapshot ({t:'state'} .data) into exactly what the DOM renders, translate a
10
+ // keypress into the terminal bytes the host brain expects, and parse the join
11
+ // URL. These are unit-tested in node (client.test.js) with no browser.
12
+ //
13
+ // 2. The browser app (guarded so importing this module in node runs no DOM code):
14
+ // the join/knock/waiting/live screens, the xterm mirror, the floating-cursor
15
+ // overlay, the draft tray, and the host-only controls.
16
+ //
17
+ // The brain stays the single authority (it lives in the host CLI). This client never
18
+ // edits a draft locally — it mails keystrokes as {t:'key'} and RENDERS the drafts the
19
+ // host sends back. Same for everything else: buttons emit {t:'ui', action} and the
20
+ // role gate in the brain still governs. We are views + labeled input sources.
21
+
22
+ // ── wire constants (must match the host brain / drafts.js keymap) ──────────────
23
+ const PASTE_START = '\x1b[200~';
24
+ const PASTE_END = '\x1b[201~';
25
+ export const NEW_DRAFT_BYTES = '\x0e'; // Ctrl+N — "start a fresh draft"
26
+
27
+ // ════════════════════════════════════════════════════════════════════════════
28
+ // PURE HELPERS (exported; no DOM, no globals — safe to import in node)
29
+ // ════════════════════════════════════════════════════════════════════════════
30
+
31
+ /**
32
+ * Parse the browser location into the fields the join flow needs.
33
+ * @param {{pathname?:string, search?:string}|string} loc window.location (or a URL string)
34
+ * @returns {{code:string, hostToken:string|null, isHostTab:boolean}}
35
+ */
36
+ export function parseLocation(loc) {
37
+ let pathname = '/';
38
+ let search = '';
39
+ if (typeof loc === 'string') {
40
+ const u = new URL(loc, 'http://x');
41
+ pathname = u.pathname;
42
+ search = u.search;
43
+ } else if (loc && typeof loc === 'object') {
44
+ pathname = loc.pathname || '/';
45
+ search = loc.search || '';
46
+ }
47
+ // The room code is the single path segment (/brave-otter). Nothing else is routed
48
+ // to a code, so anything with a slash or a dot (a served asset) yields no code.
49
+ const seg = decodeURIComponent(pathname.replace(/^\/+/, ''));
50
+ const code = seg && !seg.includes('/') && !seg.includes('.') ? seg : '';
51
+ const params = new URLSearchParams(search);
52
+ const hostToken = params.get('host');
53
+ return { code, hostToken: hostToken || null, isHostTab: !!hostToken };
54
+ }
55
+
56
+ /**
57
+ * Build the `/ws` query the web door expects. A host tab carries `host`; a guest
58
+ * carries its browser `token` (returning identity), claimed `name`, and — for a
59
+ * passworded room — the join password `pass` (host tabs never need one).
60
+ * @returns {string} e.g. "/ws?room=brave-otter&name=sid&token=abc"
61
+ */
62
+ export function buildWsPath({ code, name, token, hostToken, pass } = {}) {
63
+ const q = new URLSearchParams();
64
+ if (code) q.set('room', code);
65
+ if (name) q.set('name', name);
66
+ if (hostToken) q.set('host', hostToken);
67
+ else if (token) q.set('token', token);
68
+ if (pass && !hostToken) q.set('pass', pass);
69
+ return '/ws?' + q.toString();
70
+ }
71
+
72
+ /** UTF-8 → base64, in node (Buffer) or the browser (TextEncoder + btoa). */
73
+ export function b64encode(str) {
74
+ const s = String(str);
75
+ if (typeof Buffer !== 'undefined') return Buffer.from(s, 'utf8').toString('base64');
76
+ const bytes = new TextEncoder().encode(s);
77
+ let bin = '';
78
+ for (const b of bytes) bin += String.fromCharCode(b);
79
+ return btoa(bin);
80
+ }
81
+
82
+ /**
83
+ * Translate a keydown into the raw terminal bytes the host's draft editor decodes
84
+ * (drafts.js SEQUENCES). Returns null for keys we don't drive — the caller lets the
85
+ * browser keep those (Ctrl/Cmd shortcuts are never hijacked). We deliberately keep
86
+ * this to a browser-safe subset: Ctrl+N/W/U (new-window/close-tab/view-source) are
87
+ * offered as buttons instead of stolen keystrokes.
88
+ * @param {{key:string, shiftKey?:boolean, altKey?:boolean, ctrlKey?:boolean, metaKey?:boolean}} e
89
+ * @returns {string|null}
90
+ */
91
+ export function keyToBytes(e = {}) {
92
+ const { key } = e;
93
+ if (!key) return null;
94
+ // The mac editing chords, translated to the readline bytes the draft editor
95
+ // decodes: ⌘⌫ kill-to-line-start, ⌘←/→ home/end. Everything else with Ctrl/Cmd
96
+ // stays with the browser (copy/paste/reload are never hijacked).
97
+ if (e.metaKey && !e.ctrlKey && !e.altKey) {
98
+ switch (key) {
99
+ case 'Backspace':
100
+ return '\x15';
101
+ case 'ArrowLeft':
102
+ return '\x1b[H';
103
+ case 'ArrowRight':
104
+ return '\x1b[F';
105
+ default:
106
+ return null;
107
+ }
108
+ }
109
+ if (e.ctrlKey || e.metaKey) return null;
110
+ switch (key) {
111
+ case 'Enter':
112
+ return e.shiftKey ? '\x1b\r' : '\r'; // shift+enter = newline within the draft
113
+ case 'Backspace':
114
+ return e.altKey ? '\x1b\x7f' : '\x7f'; // alt+backspace = delete word
115
+ case 'ArrowLeft':
116
+ return e.altKey ? '\x1b[1;3D' : '\x1b[D';
117
+ case 'ArrowRight':
118
+ return e.altKey ? '\x1b[1;3C' : '\x1b[C';
119
+ case 'ArrowUp':
120
+ return '\x1b[A';
121
+ case 'ArrowDown':
122
+ return '\x1b[B';
123
+ case 'Home':
124
+ return '\x1b[H';
125
+ case 'End':
126
+ return '\x1b[F';
127
+ case 'Escape':
128
+ return '\x1b'; // interrupts Claude's turn (gate: prompter and up)
129
+ default:
130
+ break;
131
+ }
132
+ // A single printable character (no modifier that would make it a shortcut).
133
+ if (!e.altKey && [...key].length === 1) return key;
134
+ return null;
135
+ }
136
+
137
+ /** Wrap pasted text in the bracketed-paste guards the brain collapses to a token. */
138
+ export function pasteBytes(text) {
139
+ return PASTE_START + String(text ?? '') + PASTE_END;
140
+ }
141
+
142
+ /**
143
+ * Roster actions carry the target's PARTICIPANT id, never its claimed display name.
144
+ * Names are guest-claimed and non-unique (a second guest can claim an existing name),
145
+ * and a name with spaces or non-word characters can't be @-mentioned at all — so a
146
+ * name-based /role or /kick could hit the wrong person or nobody. The id is unambiguous
147
+ * and the brain applies it directly, bypassing @-mention resolution (finding 4).
148
+ * @param {string} id the participant id (from the overlay state)
149
+ * @param {string} role prompter | viewer
150
+ */
151
+ export function roleAction(id, role) {
152
+ return { t: 'ui', action: { kind: 'role', id, role } };
153
+ }
154
+ /** @param {string} id the participant id to kick+ban. */
155
+ export function kickAction(id) {
156
+ return { t: 'ui', action: { kind: 'kick', id } };
157
+ }
158
+
159
+ /**
160
+ * The token-free invite link a host copies to share (finding 1). The host tab lives at
161
+ * `<origin>/<code>?host=<token>`; the invite is the same origin + code with the token
162
+ * stripped, so a friend who opens it lands on the normal knock flow — never the host
163
+ * seat. Built from the tab's own location so it works behind any host/port.
164
+ * @param {string} origin e.g. window.location.origin
165
+ * @param {string} code the room code
166
+ */
167
+ export function inviteLink(origin, code) {
168
+ return `${String(origin || '').replace(/\/+$/, '')}/${code}`;
169
+ }
170
+
171
+ /** Clamp a number into [0,1] (NaN → 0). */
172
+ export function clamp01(v) {
173
+ const n = Number(v);
174
+ if (!Number.isFinite(n)) return 0;
175
+ return n < 0 ? 0 : n > 1 ? 1 : n;
176
+ }
177
+
178
+ /** Has enough time passed since `last` to send again at ≤ 1000/interval per second? */
179
+ export function throttleReady(last, now, intervalMs) {
180
+ return now - last >= intervalMs;
181
+ }
182
+
183
+ /** id → {name, role, color} lookup over a participants array. */
184
+ export function byId(participants) {
185
+ const m = new Map();
186
+ for (const p of Array.isArray(participants) ? participants : []) m.set(p.id, p);
187
+ return m;
188
+ }
189
+
190
+ const nameFor = (m, id) => m.get(id)?.name ?? String(id ?? '');
191
+ const colorFor = (m, id) => m.get(id)?.color ?? '#9aa4b2';
192
+
193
+ /** What a role may do in the tray (mirrors gate.js semantics, client side). */
194
+ export function roleCaps(role) {
195
+ const rank = { viewer: 0, prompter: 1, host: 2 }[role] ?? 0;
196
+ return {
197
+ isViewer: rank < 1,
198
+ canCompose: rank >= 1, // prompter+ (typing, asks, slash/bash — all of it)
199
+ isHost: rank >= 2,
200
+ };
201
+ }
202
+
203
+ /** Friendly label + kind for Claude's state chip. */
204
+ export function claudeLabel(state) {
205
+ switch (state) {
206
+ case 'busy':
207
+ return { text: 'Claude is brewing', kind: 'busy' };
208
+ case 'ask':
209
+ return { text: 'permission ask pending', kind: 'ask' };
210
+ case 'idle':
211
+ default:
212
+ return { text: 'Claude is idle', kind: 'idle' };
213
+ }
214
+ }
215
+
216
+ /** The knock's first-seen line: never seen → "first time"; else "seen before as X". */
217
+ export function seenLabel(seen) {
218
+ if (seen === null || seen === undefined || seen === false || seen === '') return 'first time seeing this key';
219
+ return `seen before as ${seen}`;
220
+ }
221
+
222
+ /** A compact, safe rendering of a key fingerprint for a knock card. */
223
+ export function shortFp(fp) {
224
+ const s = String(fp ?? '');
225
+ if (!s) return '';
226
+ const body = s.includes(':') ? s.slice(s.indexOf(':') + 1) : s;
227
+ return body.length > 12 ? body.slice(0, 12) + '…' : body;
228
+ }
229
+
230
+ /**
231
+ * Split a draft's display text into ordered segments with caret blocks inserted at
232
+ * their character offsets. Names are NEVER inline — only bare colored caret blocks
233
+ * (spec §draft lines); author names live on the box border. Offsets are UTF-16
234
+ * indices into `text` (they always fall on a code-point boundary — see drafts.js).
235
+ * @returns {Array<{type:'text',value:string}|{type:'caret',color:string,name:string,self:boolean,id:string}>}
236
+ */
237
+ export function segmentBox(text, carets) {
238
+ const t = String(text ?? '');
239
+ const list = (Array.isArray(carets) ? carets : [])
240
+ .map((c) => ({ ...c, offset: Math.max(0, Math.min(t.length, Math.trunc(c.offset) || 0)) }))
241
+ .sort((a, b) => a.offset - b.offset);
242
+ const segs = [];
243
+ let pos = 0;
244
+ for (const c of list) {
245
+ if (c.offset > pos) {
246
+ segs.push({ type: 'text', value: t.slice(pos, c.offset) });
247
+ pos = c.offset;
248
+ }
249
+ segs.push({ type: 'caret', color: c.color, name: c.name, self: !!c.self, id: c.id });
250
+ }
251
+ if (pos < t.length) segs.push({ type: 'text', value: t.slice(pos) });
252
+ if (segs.length === 0) segs.push({ type: 'text', value: '' });
253
+ return segs;
254
+ }
255
+
256
+ /** Build a single draft box's view model (authors on the border, caret blocks in text). */
257
+ export function draftView(box, pById, selfId) {
258
+ const text = box?.text ?? '';
259
+ const authors = (box?.authors ?? []).map((uid) => ({
260
+ id: uid,
261
+ name: nameFor(pById, uid),
262
+ color: colorFor(pById, uid),
263
+ isSelf: uid === selfId,
264
+ }));
265
+ const caretOffsets = box?.caretOffsets ?? {};
266
+ const carets = Object.entries(caretOffsets).map(([uid, offset]) => ({
267
+ id: uid,
268
+ offset,
269
+ color: colorFor(pById, uid),
270
+ name: nameFor(pById, uid),
271
+ self: uid === selfId,
272
+ }));
273
+ const focusedBySelf = selfId != null && Object.prototype.hasOwnProperty.call(caretOffsets, selfId);
274
+ return {
275
+ id: box?.id,
276
+ text,
277
+ authors,
278
+ carets,
279
+ focusedBySelf,
280
+ segments: segmentBox(text, carets),
281
+ // Shared placement (stage fractions) — everyone sees a moved box in the same spot.
282
+ place: box?.place ?? null,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * The whole client view model: the single pure function the render layer consumes.
288
+ * @param {object} state the host's {t:'state'} .data snapshot
289
+ * @param {string|null} selfId this client's participant id (from the 'joined' frame)
290
+ */
291
+ export function overlayView(state, selfId) {
292
+ const s = state && typeof state === 'object' ? state : {};
293
+ const participants = Array.isArray(s.participants) ? s.participants : [];
294
+ const pById = byId(participants);
295
+ const me = pById.get(selfId) || null;
296
+ const role = me?.role || 'viewer';
297
+ const caps = roleCaps(role);
298
+
299
+ const rawPtr = s.pointers && typeof s.pointers === 'object' ? s.pointers : {};
300
+ const othersPointers = Object.entries(rawPtr)
301
+ .filter(([id]) => id !== selfId) // never draw my own cursor
302
+ .map(([id, p]) => ({
303
+ id,
304
+ x: clamp01(p?.x),
305
+ y: clamp01(p?.y),
306
+ name: p?.name || nameFor(pById, id),
307
+ color: p?.color || colorFor(pById, id),
308
+ }));
309
+
310
+ const boxes = s.drafts && Array.isArray(s.drafts.boxes) ? s.drafts.boxes : [];
311
+ const drafts = boxes.map((b) => draftView(b, pById, selfId));
312
+
313
+ const queue = (Array.isArray(s.queue) ? s.queue : []).map((it) => ({
314
+ n: it.n,
315
+ text: it.text ?? '',
316
+ author: it.author,
317
+ isMine: it.author === selfId,
318
+ name: nameFor(pById, it.author),
319
+ color: colorFor(pById, it.author),
320
+ }));
321
+
322
+ const knocks = (Array.isArray(s.knocks) ? s.knocks : []).map((k) => ({
323
+ id: k.id,
324
+ name: k.name,
325
+ fp: k.fp,
326
+ shortFp: shortFp(k.fp),
327
+ seen: k.seen,
328
+ seenLabel: seenLabel(k.seen),
329
+ }));
330
+
331
+ return {
332
+ room: s.room || '',
333
+ selfId: selfId || null,
334
+ me: me ? { id: me.id, name: me.name, role, color: me.color } : null,
335
+ role,
336
+ ...caps,
337
+ participants: participants.map((p) => ({ ...p, isSelf: p.id === selfId })),
338
+ othersPointers,
339
+ drafts,
340
+ queue,
341
+ claudeState: s.claudeState || 'idle',
342
+ claude: claudeLabel(s.claudeState),
343
+ paused: !!s.paused,
344
+ knocks,
345
+ // The latest addressed notice ({id, msg, seq}) — shown as a toast when it's mine.
346
+ notice: s.notice && typeof s.notice === 'object' ? s.notice : null,
347
+ // The shared clamp every mirrored frame is painted at; the xterm mirror must
348
+ // be exactly this size (null on an older host → fall back to local fitting).
349
+ view: s.view && typeof s.view === 'object' ? s.view : null,
350
+ };
351
+ }
352
+
353
+ // ════════════════════════════════════════════════════════════════════════════
354
+ // BROWSER APP (guarded: nothing below runs when imported in node)
355
+ // ════════════════════════════════════════════════════════════════════════════
356
+
357
+ function main() {
358
+ const TOKEN_KEY = 'claude-share:token';
359
+ const NAME_KEY = 'claude-share:name';
360
+ // Accepted room password, stored per room so a reload glides straight back in
361
+ // (same promise as the token). Cleared the moment the relay rejects it.
362
+ const passKey = (code) => 'claude-share:pass:' + code;
363
+ const POINTER_HZ = 30; // send own pointer ≤ 30/s (spec)
364
+ const POINTER_MS = Math.ceil(1000 / POINTER_HZ);
365
+
366
+ const $ = (sel) => document.querySelector(sel);
367
+ const el = (tag, cls, text) => {
368
+ const n = document.createElement(tag);
369
+ if (cls) n.className = cls;
370
+ if (text != null) n.textContent = text;
371
+ return n;
372
+ };
373
+
374
+ const store = {
375
+ get(k) {
376
+ try {
377
+ return localStorage.getItem(k);
378
+ } catch {
379
+ return null;
380
+ }
381
+ },
382
+ set(k, v) {
383
+ try {
384
+ localStorage.setItem(k, v);
385
+ } catch {
386
+ /* private mode: identity is session-only, that's fine */
387
+ }
388
+ },
389
+ };
390
+ const uuid = () =>
391
+ (crypto.randomUUID && crypto.randomUUID()) ||
392
+ 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
393
+ const r = (Math.random() * 16) | 0;
394
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
395
+ });
396
+
397
+ // ── app state ───────────────────────────────────────────────────────────────
398
+ const loc = parseLocation(window.location);
399
+ let ws = null;
400
+ let term = null;
401
+ let selfId = null;
402
+ let lastState = null;
403
+ let phase = 'join'; // join | knocking | live | error | closed
404
+ let intentionalClose = false;
405
+ let endStep = 0; // host End-Session confirm: 0 idle · 1 "end?" · 2 "save?"
406
+ const cursorEls = new Map(); // participant id -> floating-cursor DOM node
407
+ let ptrLast = 0;
408
+ // Mirror sizing: screen bytes buffered until state.view has sized the terminal.
409
+ let mirrorSized = false;
410
+ let pendingFrames = [];
411
+ let lastViewKey = '';
412
+ let retuneTries = 0; // bounded font-retune attempts while xterm re-measures
413
+ // Direct mode is DERIVED, not toggled: anyone who can type, with no draft
414
+ // focused, types straight into Claude. Updated from every state frame.
415
+ let directOn = false;
416
+ // Draft placement is SHARED (brain-owned, stage fractions). Client state here is
417
+ // only for interaction smoothness: the box being dragged and the spawn point of
418
+ // a double-clicked draft (mailed as a place action once the box exists).
419
+ let pendingSpawn = null; // stage-fraction {x,y} for the next box I author (dblclick)
420
+ let draggingDraft = false; // renders skip while a draft is mid-drag
421
+ let placeLast = 0; // throttle for streaming place actions during a drag
422
+
423
+ // ── DOM handles ──────────────────────────────────────────────────────────────
424
+ const joinScreen = $('#join');
425
+ const liveScreen = $('#live');
426
+ const roomLabel = $('#join-room');
427
+ const codeInput = $('#code-input');
428
+ const nameInput = $('#name-input');
429
+ const passField = $('#pass-field');
430
+ const passInput = $('#pass-input');
431
+ const knockBtn = $('#knock-btn');
432
+ const joinStatus = $('#join-status');
433
+ const joinForm = $('#join-form');
434
+ const waiting = $('#waiting');
435
+ const stage = $('#stage');
436
+ const termEl = $('#term');
437
+ const cursorLayer = $('#cursors');
438
+ const pausedCard = $('#paused');
439
+ const claudeChip = $('#claude-chip');
440
+ const draftsLayer = $('#drafts');
441
+ const queueChip = $('#queue-chip');
442
+ const queuePop = $('#queue-pop');
443
+ const composer = $('#composer');
444
+ const newDraftBtn = $('#new-draft');
445
+ const hostControls = $('#host-controls');
446
+ const knocksBox = $('#knocks');
447
+ const avatarsBox = $('#avatars');
448
+ const popover = $('#popover');
449
+ const copyInviteBtn = $('#copy-invite');
450
+ const pauseBtn = $('#pause-btn');
451
+ const endBtn = $('#end-btn');
452
+ const endConfirm = $('#end-confirm');
453
+ const sbRoom = $('#sb-room');
454
+ const sbRole = $('#sb-role');
455
+ const toastEl = $('#toast');
456
+ toastEl.addEventListener('click', () => {
457
+ toastEl.hidden = true;
458
+ });
459
+
460
+ // ── join screen ──────────────────────────────────────────────────────────────
461
+ function showJoin() {
462
+ phase = 'join';
463
+ joinScreen.hidden = false;
464
+ liveScreen.hidden = true;
465
+ waiting.hidden = true;
466
+ joinForm.hidden = false;
467
+ if (loc.code) {
468
+ roomLabel.textContent = loc.code;
469
+ roomLabel.hidden = false;
470
+ codeInput.hidden = true;
471
+ codeInput.value = loc.code;
472
+ } else {
473
+ roomLabel.hidden = true;
474
+ codeInput.hidden = false;
475
+ }
476
+ nameInput.value = store.get(NAME_KEY) || '';
477
+ setJoinStatus('');
478
+ nameInput.focus();
479
+ }
480
+
481
+ function setJoinStatus(msg, isError) {
482
+ joinStatus.textContent = msg || '';
483
+ joinStatus.classList.toggle('error', !!isError);
484
+ }
485
+
486
+ const ERRORS = {
487
+ 'no-room': 'That room does not exist (or has expired).',
488
+ 'host-gone': 'The host is offline right now. Try again in a moment.',
489
+ banned: 'You were removed from this room.',
490
+ lockout: 'Too many attempts. Wait a bit and try again.',
491
+ timeout: 'The host did not answer in time. Try again when they are around.',
492
+ denied: 'The host declined your request.',
493
+ closed: 'The connection closed before you were let in — try again.',
494
+ // password gets its own copy in fail(): the message depends on whether we
495
+ // had one to send ("needs a password" vs "wrong password").
496
+ password: 'This room is password-protected.',
497
+ };
498
+
499
+ let sentPass = null; // what the last attempt presented (drives the fail() copy)
500
+
501
+ function beginKnock() {
502
+ const code = (loc.code || codeInput.value || '').trim();
503
+ if (!code) return setJoinStatus('Enter a room code to join.', true);
504
+ const name = (nameInput.value || '').trim().slice(0, 24);
505
+ if (!loc.isHostTab && !name) return setJoinStatus('Pick a name so the host knows who you are.', true);
506
+ if (name) store.set(NAME_KEY, name);
507
+ let token = store.get(TOKEN_KEY);
508
+ if (!token) {
509
+ token = uuid();
510
+ store.set(TOKEN_KEY, token);
511
+ }
512
+ // A freshly typed password wins; otherwise reuse the one this room accepted
513
+ // before (reload glide-back). Open rooms send none and never see the field.
514
+ const typed = (passInput.value || '').trim();
515
+ const pass = typed || store.get(passKey(code)) || '';
516
+ sentPass = pass || null;
517
+ if (typed) store.set(passKey(code), typed);
518
+ connect({ code, name, token, hostToken: loc.hostToken, pass });
519
+ }
520
+
521
+ // ── connection ───────────────────────────────────────────────────────────────
522
+ function connect({ code, name, token, hostToken, pass }) {
523
+ // Supersede any previous socket COMPLETELY. A zombie from an earlier attempt
524
+ // still has live handlers — when the brain dedupes its knock, its deny/close
525
+ // would splash "declined" into a UI that belongs to the NEW attempt.
526
+ const old = ws;
527
+ if (old) {
528
+ old.onmessage = old.onclose = old.onerror = null;
529
+ try {
530
+ old.close();
531
+ } catch {}
532
+ }
533
+ phase = 'knocking';
534
+ joinForm.hidden = true;
535
+ waiting.hidden = false;
536
+ $('#waiting-text').textContent = hostToken
537
+ ? 'Opening your room…'
538
+ : `Waiting for the host to let you in…`;
539
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
540
+ const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken, pass });
541
+ let sock;
542
+ try {
543
+ sock = new WebSocket(url);
544
+ } catch {
545
+ return fail('no-room');
546
+ }
547
+ ws = sock;
548
+ sock.binaryType = 'arraybuffer';
549
+ sock.onopen = () => {}; // nothing to send until admitted
550
+ sock.onmessage = (ev) => {
551
+ if (sock !== ws) return; // superseded mid-flight
552
+ onMessage(ev);
553
+ };
554
+ sock.onclose = () => {
555
+ if (sock !== ws || intentionalClose) return;
556
+ if (phase === 'removed') return; // the kicked panel owns the screen now
557
+ if (phase === 'live') showDisconnected();
558
+ // A silent close mid-knock is NOT evidence the host is offline (a superseded
559
+ // knock or a network blip closes the same way) — never claim that it is.
560
+ else if (phase !== 'error') fail('closed');
561
+ };
562
+ sock.onerror = () => {};
563
+ }
564
+
565
+ function onMessage(ev) {
566
+ if (typeof ev.data !== 'string') {
567
+ // Binary = screen bytes → the xterm mirror. Until the first state frame has
568
+ // told us the shared size, buffer: the join snapshot was painted at that size,
569
+ // and writing it into a wrong-sized terminal wraps every line into garbage.
570
+ const bytes = new Uint8Array(ev.data);
571
+ if (term && mirrorSized) term.write(bytes);
572
+ else pendingFrames.push(bytes);
573
+ return;
574
+ }
575
+ let msg;
576
+ try {
577
+ msg = JSON.parse(ev.data);
578
+ } catch {
579
+ return;
580
+ }
581
+ if (!msg || typeof msg !== 'object') return;
582
+ switch (msg.t) {
583
+ case 'joined':
584
+ // A host tab IS the host (the brain maps it to HOST_ID and never adds it as a
585
+ // second participant — finding 4), so adopt the host's roster identity: the
586
+ // view then finds "me" as the host, grants host caps, and shows one host entry.
587
+ selfId = loc.isHostTab ? 'host' : msg.id;
588
+ goLive();
589
+ break;
590
+ case 'state':
591
+ lastState = msg.data;
592
+ if (phase === 'live') render();
593
+ break;
594
+ case 'error':
595
+ // Kicked mid-session: the mirror disappears behind a full-screen panel —
596
+ // never the old "removed" bytes smeared into a still-visible terminal.
597
+ if (msg.reason === 'kicked') showRemoved();
598
+ else fail(msg.reason);
599
+ break;
600
+ default:
601
+ break;
602
+ }
603
+ }
604
+
605
+ function fail(reason) {
606
+ phase = 'error';
607
+ joinScreen.hidden = false;
608
+ liveScreen.hidden = true;
609
+ waiting.hidden = true;
610
+ joinForm.hidden = false;
611
+ if (loc.code) {
612
+ roomLabel.hidden = false;
613
+ codeInput.hidden = true;
614
+ }
615
+ if (reason === 'password') {
616
+ // Reveal the field (open rooms never see it) and forget a rejected value —
617
+ // a stale stored password must not silently retry forever.
618
+ const code = (loc.code || codeInput.value || '').trim();
619
+ if (code) store.set(passKey(code), '');
620
+ passField.hidden = false;
621
+ passInput.value = '';
622
+ setJoinStatus(sentPass ? 'Wrong password — try again.' : 'This room is password-protected.', true);
623
+ passInput.focus();
624
+ return;
625
+ }
626
+ setJoinStatus(ERRORS[reason] || 'Could not connect. Try again.', true);
627
+ }
628
+
629
+ function showDisconnected() {
630
+ phase = 'closed';
631
+ const banner = $('#dc-banner');
632
+ banner.hidden = false;
633
+ }
634
+
635
+ function showRemoved() {
636
+ phase = 'removed';
637
+ intentionalClose = true; // the close that follows is expected — no reconnect UI
638
+ liveScreen.hidden = true;
639
+ joinScreen.hidden = true;
640
+ $('#removed').hidden = false;
641
+ }
642
+
643
+ // ── go live ──────────────────────────────────────────────────────────────────
644
+ function goLive() {
645
+ phase = 'live';
646
+ joinScreen.hidden = true;
647
+ liveScreen.hidden = false;
648
+ if (!term) createTerm();
649
+ fit();
650
+ render();
651
+ // Keep the composer ready to type the moment the room is live.
652
+ focusComposer();
653
+ }
654
+
655
+ function createTerm() {
656
+ const Term = window.Terminal;
657
+ if (!Term) {
658
+ // xterm bundle failed to load — degrade to a plain message rather than crash.
659
+ termEl.textContent = 'terminal unavailable (xterm did not load)';
660
+ return;
661
+ }
662
+ term = new Term({
663
+ convertEol: false,
664
+ // Claude's real cursor IS the "you type here" indicator — the mirror never
665
+ // holds browser focus, so render the inactive cursor as a full block (CSS
666
+ // adds the blink).
667
+ cursorBlink: true,
668
+ cursorInactiveStyle: 'block',
669
+ disableStdin: true, // the drafts own composed input; the mirror is read-only
670
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
671
+ fontSize: 13,
672
+ lineHeight: 1.1,
673
+ scrollback: 5000,
674
+ // Transparent over the stage glass: the mirror has no visible edge of its
675
+ // own, so a mirror narrower than the tab never reads as an empty side panel.
676
+ allowTransparency: true,
677
+ theme: {
678
+ background: 'rgba(0,0,0,0)',
679
+ foreground: '#e8ecf4',
680
+ cursor: '#e8a33d',
681
+ selectionBackground: 'rgba(232,163,61,0.28)',
682
+ black: '#0a0c12',
683
+ brightBlack: '#5b6472',
684
+ },
685
+ });
686
+ term.open(termEl);
687
+ // The mirror is read-only, but xterm still focuses its hidden helper textarea on
688
+ // click and its keydown handler swallows every key it gets — which silently ate
689
+ // all direct-mode input. Make the helper unfocusable: keys then land on the
690
+ // composer/document, where OUR handlers route them (drafts or direct-to-Claude).
691
+ // One wrinkle: xterm draws NO cursor until it has been focused once, and Claude's
692
+ // cursor is our "you type here" indicator — prime it with a silent focus/blur
693
+ // cycle BEFORE locking the helper.
694
+ if (term.textarea) {
695
+ term.textarea.tabIndex = -1;
696
+ term.textarea.focus();
697
+ term.textarea.blur();
698
+ term.textarea.disabled = true;
699
+ }
700
+ // The focus/blur prime is inert in a background window (Chrome defers focus
701
+ // events until the window is foreground), so a guest tab opened behind another
702
+ // window never drew a cursor. Flip xterm's init flag directly — the same thing
703
+ // its _showCursor() does on first focus, minus the need for real focus.
704
+ try {
705
+ const cs = term._core?._coreService;
706
+ if (cs && !cs.isCursorInitialized) {
707
+ cs.isCursorInitialized = true;
708
+ term.refresh(0, term.rows - 1);
709
+ }
710
+ } catch {
711
+ /* private API moved — the focus/blur prime still covers foreground tabs */
712
+ }
713
+ const ro = new ResizeObserver(() => fit());
714
+ ro.observe(stage);
715
+ window.addEventListener('resize', fit);
716
+ // Pointer capture over the terminal (the floating-cursor "Figma feel").
717
+ // Pointer events, not mouse events: cancelling a pointerdown (the draft drag)
718
+ // suppresses the compatibility MOUSE stream, which froze cursor broadcasting
719
+ // for the whole drag. Pointermove keeps flowing regardless.
720
+ stage.addEventListener('pointermove', onStageMove);
721
+ stage.addEventListener('pointerleave', () => sendPointer(-1, -1)); // park off-canvas
722
+ // Wheel = Claude's own transcript scroll. Claude enables mouse tracking and
723
+ // scrolls internally on wheel reports — the terminal never receives scrollback
724
+ // lines, so xterm's viewport has nothing to scroll and would swallow the wheel
725
+ // as a dead mouse report (stdin is disabled). Capture it before xterm and mail
726
+ // it to the brain, which types the real report into the pty.
727
+ stage.addEventListener('wheel', onStageWheel, { passive: false, capture: true });
728
+ }
729
+
730
+ let wheelAcc = 0;
731
+ function onStageWheel(e) {
732
+ if (phase !== 'live') return;
733
+ if (e.target.closest?.('.fdraft, .popover')) return; // drafts/popovers scroll themselves
734
+ e.preventDefault();
735
+ e.stopPropagation();
736
+ wheelAcc += e.deltaY;
737
+ const lines = Math.trunc(wheelAcc / 40); // ~one report per wheel notch
738
+ if (!lines) return;
739
+ wheelAcc -= lines * 40;
740
+ sendMsg({ t: 'ui', action: { kind: 'scroll', lines: Math.max(-8, Math.min(8, lines)) } });
741
+ }
742
+
743
+ // The tab's cell size, from the renderer's own measurement (what addon-fit reads),
744
+ // with a conservative fallback if the private field is unavailable.
745
+ function cellSize() {
746
+ try {
747
+ const cell = term?._core?._renderService?.dimensions?.css?.cell;
748
+ if (cell && cell.width && cell.height) return { cw: cell.width, ch: cell.height };
749
+ } catch {
750
+ /* fall through to estimate */
751
+ }
752
+ return { cw: 13 * 0.6, ch: 13 * 1.1 }; // rough advance for the 13px mono font
753
+ }
754
+
755
+ // Report this tab's CAPACITY (how many cells its container could show) — the brain
756
+ // folds it into the shared clamp. Always measured at the REFERENCE font (13px):
757
+ // scaleTerm retunes the actual font to fill the stage, and capacity tracking the
758
+ // live font would feed back into the clamp (bigger font → smaller clamp → bigger
759
+ // font …). The reference cell is measured ONCE, before the first retune — deriving
760
+ // it from a retuned font (cw/font*13) picks up per-size rounding, so the derived
761
+ // reference drifts a little each retune and the clamp oscillates (erase/repaint
762
+ // churn on every join). The mirror itself never sizes from the container: it
763
+ // renders at exactly state.view (see applyView) and scales visually.
764
+ let refCell = null;
765
+ function refCellSize() {
766
+ if (refCell) return refCell;
767
+ const { cw, ch } = cellSize();
768
+ const font = term.options.fontSize || 13;
769
+ const cell = { cw: (cw / font) * 13, ch: (ch / font) * 13 };
770
+ if (font === 13) refCell = cell; // only cache the un-retuned measurement
771
+ return cell;
772
+ }
773
+ function fit() {
774
+ if (!term) return;
775
+ const rect = stage.getBoundingClientRect();
776
+ if (rect.width < 2 || rect.height < 2) return;
777
+ const { cw, ch } = refCellSize();
778
+ const cols = Math.max(2, Math.floor((rect.width - 24) / cw));
779
+ const rows = Math.max(1, Math.floor((rect.height - 20) / ch));
780
+ sendMsg({ t: 'resize', cols, rows });
781
+ scaleTerm();
782
+ }
783
+
784
+ // Render the mirror at the shared size the frames were painted at; on the first
785
+ // state frame, flush the buffered join snapshot into the now-correctly-sized term.
786
+ function applyView(v) {
787
+ if (!term) return;
788
+ const vw = v.view;
789
+ if (vw && vw.cols >= 2 && vw.rows >= 1) {
790
+ const key = vw.cols + 'x' + vw.rows;
791
+ if (key !== lastViewKey) {
792
+ lastViewKey = key;
793
+ try {
794
+ term.resize(vw.cols, vw.rows);
795
+ } catch {
796
+ /* xterm not ready */
797
+ }
798
+ }
799
+ }
800
+ if (!mirrorSized) {
801
+ mirrorSized = true; // no view field (older host) → degrade: just start writing
802
+ for (const chunk of pendingFrames) term.write(chunk);
803
+ pendingFrames = [];
804
+ setTimeout(scaleTerm, 350); // warm-up: re-fit once the first paint has settled
805
+ }
806
+ scaleTerm();
807
+ }
808
+
809
+ // Make the mirror FILL the tab: pick the font size that best fits the shared
810
+ // cols×rows into the stage (crisp text beats transform-zoom), then a clamped
811
+ // non-uniform residual scale closes the remaining gap. termEl's rect stays equal
812
+ // to the VISUAL content box, so pointer math and the cursor overlay read true
813
+ // positions (getBoundingClientRect reflects the transform, per-axis included).
814
+ function scaleTerm() {
815
+ if (!term) return;
816
+ // .xterm-screen is the true cols×rows content box (.xterm just fills its parent)
817
+ const inner = termEl.querySelector('.xterm-screen');
818
+ if (!inner) return;
819
+ const w = inner.offsetWidth;
820
+ const h = inner.offsetHeight;
821
+ if (w < 2 || h < 2) return;
822
+ const rect = stage.getBoundingClientRect();
823
+ const availW = rect.width - 24;
824
+ const availH = rect.height - 20;
825
+ const fit = Math.min(availW / w, availH / h);
826
+ if (!Number.isFinite(fit) || fit <= 0) return;
827
+ // Pass 1: font size by the BINDING axis (crisp text beats transform-zoom).
828
+ // xterm re-measures ASYNCHRONOUSLY after an option change — a rAF often reads
829
+ // the stale size (the "doesn't span until the first message" bug), so retry on
830
+ // a timer until the measurement settles, bounded so it can never loop.
831
+ const cur = term.options.fontSize || 13;
832
+ const ideal = Math.max(9, Math.min(28, Math.floor(cur * fit)));
833
+ if (ideal !== cur && retuneTries < 8) {
834
+ retuneTries += 1;
835
+ term.options.fontSize = ideal;
836
+ setTimeout(scaleTerm, 90);
837
+ return;
838
+ }
839
+ // Pass 2: close the residual gap (integer font steps + a foreign aspect in the
840
+ // shared size) with a mildly NON-uniform scale — each axis may stretch up to 8%
841
+ // past the uniform fit. The whole rendered picture scales together, so
842
+ // box-drawing lines stay CONNECTED (per-cell padding via letter-spacing /
843
+ // line-height broke every solid line into dashes). Residue past the cap stays
844
+ // as a small quiet gap rather than visible glyph distortion.
845
+ retuneTries = 0;
846
+ const k = Math.min(fit, 1);
847
+ const kx = Math.min(availW / w, k * 1.08);
848
+ const ky = Math.min(availH / h, k * 1.08);
849
+ termEl.style.width = w + 'px';
850
+ termEl.style.height = h + 'px';
851
+ termEl.style.transform = `scale(${kx}, ${ky})`;
852
+ // Center horizontally; anchor to the BOTTOM so Claude's input line hugs the
853
+ // bottom of the page like a real terminal — vertical slack collects above.
854
+ termEl.style.left = Math.max(12, (rect.width - w * kx) / 2) + 'px';
855
+ termEl.style.top = Math.max(10, rect.height - 10 - h * ky) + 'px';
856
+ }
857
+
858
+ // ── outbound ───────────────────────────────────────────────────────────────
859
+ function sendMsg(obj) {
860
+ if (ws && ws.readyState === WebSocket.OPEN) {
861
+ try {
862
+ ws.send(JSON.stringify(obj));
863
+ } catch {
864
+ /* socket closing */
865
+ }
866
+ }
867
+ }
868
+ const sendKey = (bytes) => sendMsg({ t: 'key', data: b64encode(bytes) });
869
+ const sendCommand = (text) => sendMsg({ t: 'ui', action: { kind: 'command', text } });
870
+ function sendPointer(x, y) {
871
+ sendMsg({ t: 'pointer', x, y });
872
+ }
873
+
874
+ function onStageMove(e) {
875
+ if (phase !== 'live') return;
876
+ const now = Date.now();
877
+ if (!throttleReady(ptrLast, now, POINTER_MS)) return;
878
+ ptrLast = now;
879
+ const rect = termEl.getBoundingClientRect();
880
+ if (rect.width < 2 || rect.height < 2) return;
881
+ sendPointer(clamp01((e.clientX - rect.left) / rect.width), clamp01((e.clientY - rect.top) / rect.height));
882
+ }
883
+
884
+ // ── composer (a transparent key/paste catcher; text shows in the draft boxes) ──
885
+ function focusComposer() {
886
+ if (directOn) return; // keys belong to Claude until the user leaves direct mode
887
+ const v = lastState ? overlayView(lastState, selfId) : null;
888
+ if (v && v.canCompose && !composer.disabled) composer.focus();
889
+ }
890
+ composer.addEventListener('keydown', (e) => {
891
+ const bytes = keyToBytes(e); // maps the ⌘ editing chords too, so ask it first
892
+ if (bytes != null) {
893
+ e.preventDefault();
894
+ sendKey(bytes);
895
+ return;
896
+ }
897
+ if (e.ctrlKey || e.metaKey) return; // let the browser own its shortcuts
898
+ if (e.key.length === 1) {
899
+ // an unhandled printable with a modifier we don't map — swallow so the
900
+ // catcher stays empty, but send nothing.
901
+ e.preventDefault();
902
+ }
903
+ });
904
+ composer.addEventListener('input', () => {
905
+ composer.value = ''; // never echo locally; the brain is the authority
906
+ });
907
+ composer.addEventListener('paste', (e) => {
908
+ e.preventDefault();
909
+ const text = (e.clipboardData || window.clipboardData)?.getData('text') || '';
910
+ if (text) sendKey(pasteBytes(text));
911
+ });
912
+ // Direct mode: translate a keydown to the raw bytes Claude's own UI expects —
913
+ // a superset of the draft keymap (Tab, Ctrl+letter) since nothing is off-limits
914
+ // for someone typing at the "real" terminal. ⌘ stays with the browser.
915
+ function directKeyBytes(e) {
916
+ if (e.metaKey) return null;
917
+ if (e.ctrlKey) {
918
+ if (!e.altKey && /^[a-z]$/i.test(e.key)) return String.fromCharCode(e.key.toLowerCase().charCodeAt(0) - 96);
919
+ return null;
920
+ }
921
+ if (e.key === 'Tab') return e.shiftKey ? '\x1b[Z' : '\t';
922
+ return keyToBytes(e);
923
+ }
924
+ // Click the bare terminal while composing → step out of the draft (Esc). The
925
+ // brain sees the Esc as "leave the box" (never an interrupt while composing);
926
+ // the next keystroke is then raw to Claude.
927
+ stage.addEventListener('mousedown', (e) => {
928
+ if (e.target.closest('.fdraft')) return;
929
+ const v = lastState ? overlayView(lastState, selfId) : null;
930
+ if (v && v.drafts.some((d) => d.focusedBySelf)) sendKey('\x1b');
931
+ });
932
+ // Double-click an empty spot → a new draft spawns right there (explicit creation).
933
+ stage.addEventListener('dblclick', (e) => {
934
+ if (e.target.closest('.fdraft')) return;
935
+ const v = lastState ? overlayView(lastState, selfId) : null;
936
+ if (!v?.canCompose) return;
937
+ const r = stage.getBoundingClientRect();
938
+ pendingSpawn = { x: clamp01((e.clientX - r.left) / r.width), y: clamp01((e.clientY - r.top) / r.height) };
939
+ sendKey(NEW_DRAFT_BYTES);
940
+ setTimeout(() => composer.focus(), 0);
941
+ });
942
+
943
+ // Keys over a standing drag-selection (the catcher is blurred then, so these land
944
+ // on the document): delete removes the range; typing replaces it — the range is
945
+ // deleted and the character follows as an ordinary draft keystroke.
946
+ document.addEventListener('keydown', (e) => {
947
+ if (phase !== 'live') return;
948
+ if (directOn) {
949
+ // Straight to Claude. A key that landed on the still-focused catcher was
950
+ // already mailed by its own handler — don't send it twice.
951
+ if (e.target === composer) return;
952
+ const b = directKeyBytes(e);
953
+ if (b != null) {
954
+ e.preventDefault();
955
+ sendKey(b);
956
+ }
957
+ return;
958
+ }
959
+ if (e.metaKey || e.ctrlKey) return; // ⌘C copy of the highlight stays native
960
+ const ds = selectionInDraft();
961
+ if (!ds) return;
962
+ const printable = !e.altKey && [...e.key].length === 1;
963
+ if (e.key === 'Backspace' || e.key === 'Delete' || printable) {
964
+ e.preventDefault();
965
+ sendMsg({ t: 'ui', action: { kind: 'delrange', id: ds.boxId, start: ds.start, end: ds.end } });
966
+ if (printable) sendKey(e.key);
967
+ } else if (e.key !== 'Escape') {
968
+ return; // arrows etc: just collapse the selection below
969
+ }
970
+ window.getSelection()?.removeAllRanges();
971
+ setTimeout(focusComposer, 0);
972
+ });
973
+ newDraftBtn.addEventListener('click', () => {
974
+ sendKey(NEW_DRAFT_BYTES);
975
+ composer.focus(); // straight to the catcher — the new box is already yours
976
+ });
977
+
978
+ // ── host controls ────────────────────────────────────────────────────────────
979
+ // Copy the SAFE, token-free invite link — never this tab's own host URL (finding 1).
980
+ copyInviteBtn?.addEventListener('click', async () => {
981
+ const link = inviteLink(window.location.origin, loc.code);
982
+ const label = copyInviteBtn.textContent;
983
+ let ok = false;
984
+ try {
985
+ await navigator.clipboard.writeText(link);
986
+ ok = true;
987
+ } catch {
988
+ // Clipboard API unavailable/denied — fall back to a prompt so the host can copy.
989
+ try {
990
+ window.prompt('Copy this invite link to share:', link);
991
+ ok = true;
992
+ } catch {
993
+ /* nothing more we can do */
994
+ }
995
+ }
996
+ copyInviteBtn.textContent = ok ? 'Invite copied ✓' : 'Copy failed';
997
+ setTimeout(() => {
998
+ copyInviteBtn.textContent = label;
999
+ }, 1600);
1000
+ });
1001
+ pauseBtn.addEventListener('click', () => {
1002
+ const v = overlayView(lastState, selfId);
1003
+ sendCommand(v.paused ? '/resume' : '/pause');
1004
+ });
1005
+ endBtn.addEventListener('click', () => {
1006
+ endStep = 1;
1007
+ renderEndConfirm();
1008
+ });
1009
+ // End confirms in place: the ■ chip swaps to its confirm steps right in the strip.
1010
+ function renderEndConfirm() {
1011
+ endConfirm.innerHTML = '';
1012
+ if (endStep === 0) {
1013
+ endConfirm.hidden = true;
1014
+ endBtn.hidden = false;
1015
+ return;
1016
+ }
1017
+ endBtn.hidden = true;
1018
+ endConfirm.hidden = false;
1019
+ if (endStep === 1) {
1020
+ endConfirm.append(el('span', null, 'End for everyone?'));
1021
+ const go = el('button', 'kbtn yes', 'End');
1022
+ const cancel = el('button', 'kbtn no', 'Cancel');
1023
+ go.onclick = () => {
1024
+ endStep = 2;
1025
+ renderEndConfirm();
1026
+ };
1027
+ cancel.onclick = () => {
1028
+ endStep = 0;
1029
+ renderEndConfirm();
1030
+ };
1031
+ endConfirm.append(go, cancel);
1032
+ } else {
1033
+ endConfirm.append(el('span', null, 'Save session.md?'));
1034
+ const save = el('button', 'kbtn yes', 'Save & end');
1035
+ const skip = el('button', 'kbtn no', 'Just end');
1036
+ // The save choice is resolved by the host CLI's /end confirmation; the browser
1037
+ // fires /end either way (the two-step gate lives here so the host never has to
1038
+ // touch the terminal). Both buttons end the room.
1039
+ save.onclick = () => finishEnd();
1040
+ skip.onclick = () => finishEnd();
1041
+ endConfirm.append(save, skip);
1042
+ }
1043
+ }
1044
+ function finishEnd() {
1045
+ endStep = 0;
1046
+ renderEndConfirm();
1047
+ sendCommand('/end');
1048
+ }
1049
+
1050
+ // ── render ─────────────────────────────────────────────────────────────────
1051
+ function render() {
1052
+ if (!lastState) return;
1053
+ const v = overlayView(lastState, selfId);
1054
+ // Direct is derived: anyone who can prompt, with no draft focused, types
1055
+ // straight into Claude.
1056
+ const selfComposing = v.drafts.some((d) => d.focusedBySelf);
1057
+ directOn = v.canCompose && !selfComposing;
1058
+ // The "keys go into Claude" indicator is Claude's own blinking cursor in the
1059
+ // mirror (forced filled+blinking via CSS) — no chip needed.
1060
+ stage.classList.toggle('direct', directOn);
1061
+ // Composing → the key catcher must hold focus, so typing lands in the draft
1062
+ // without a manual click (unless a drag-selection is standing).
1063
+ if (selfComposing && !composer.disabled && document.activeElement !== composer) {
1064
+ const sel = window.getSelection();
1065
+ if (!sel || sel.isCollapsed) composer.focus();
1066
+ }
1067
+ ghostHint(v);
1068
+ applyView(v);
1069
+ renderStatusbar(v);
1070
+ renderClaudeChip(v);
1071
+ renderPaused(v);
1072
+ renderDrafts(v);
1073
+ renderQueue(v);
1074
+ renderComposer(v);
1075
+ renderCursors(v);
1076
+ renderHeader(v);
1077
+ renderNotice(v);
1078
+ }
1079
+
1080
+ // An addressed notice from the brain ("you can't use slash commands — …"): show
1081
+ // it as a toast pill when it's for me and I haven't shown that seq yet.
1082
+ let noticeSeq = 0;
1083
+ let noticeTimer = null;
1084
+ function renderNotice(v) {
1085
+ const n = v.notice;
1086
+ if (!n || n.id !== selfId || n.seq === noticeSeq) return;
1087
+ noticeSeq = n.seq;
1088
+ toastEl.textContent = n.msg;
1089
+ toastEl.hidden = false;
1090
+ clearTimeout(noticeTimer);
1091
+ noticeTimer = setTimeout(() => {
1092
+ toastEl.hidden = true;
1093
+ }, 4000);
1094
+ }
1095
+
1096
+ function renderStatusbar(v) {
1097
+ sbRoom.textContent = v.room ? v.room : '—';
1098
+ sbRole.innerHTML = '';
1099
+ sbRole.append('you: ');
1100
+ const b = el('b', null, v.role);
1101
+ b.style.setProperty('--c', v.me?.color || '#9aa4b2');
1102
+ sbRole.append(b);
1103
+ }
1104
+
1105
+ function renderClaudeChip(v) {
1106
+ claudeChip.className = 'chip claude ' + v.claude.kind;
1107
+ claudeChip.textContent = v.claude.text;
1108
+ // The host's unstick: a missed hook can wedge busy/ask — click forces idle.
1109
+ claudeChip.style.cursor = v.isHost ? 'pointer' : 'default';
1110
+ claudeChip.title = v.isHost ? 'stuck? click to reset to idle and drain the queue' : '';
1111
+ claudeChip.onclick = v.isHost ? () => sendMsg({ t: 'ui', action: { kind: 'resync' } }) : null;
1112
+ }
1113
+
1114
+ // Once, when the room first has two people who can type: say how composing works
1115
+ // (drafts are explicit now — nothing on screen would otherwise hint they exist).
1116
+ let hinted = false;
1117
+ function ghostHint(v) {
1118
+ if (hinted) return;
1119
+ if (v.participants.filter((p) => p.role !== 'viewer').length < 2) return;
1120
+ hinted = true;
1121
+ localToast('compose together: + draft, or double-click the terminal');
1122
+ }
1123
+ function localToast(msg, ms = 6000) {
1124
+ toastEl.textContent = msg;
1125
+ toastEl.hidden = false;
1126
+ clearTimeout(noticeTimer);
1127
+ noticeTimer = setTimeout(() => {
1128
+ toastEl.hidden = true;
1129
+ }, ms);
1130
+ }
1131
+
1132
+ function renderPaused(v) {
1133
+ pausedCard.hidden = !v.paused;
1134
+ stage.classList.toggle('paused', v.paused);
1135
+ }
1136
+
1137
+ let lastDraftsJson = '';
1138
+ let knownBoxIds = new Set(); // boxes already on screen — only NEW ones animate in
1139
+ function renderDrafts(v) {
1140
+ // Rebuild only when the drafts actually changed: every rebuild would destroy a
1141
+ // native drag-selection, and state frames arrive for unrelated reasons
1142
+ // (pointers). Mid-drag renders are skipped too — pointerup re-syncs.
1143
+ const json = JSON.stringify(v.drafts);
1144
+ if (json === lastDraftsJson || draggingDraft) return;
1145
+ lastDraftsJson = json;
1146
+ draftsLayer.innerHTML = '';
1147
+ const nextIds = new Set();
1148
+ for (const [i, d] of v.drafts.entries()) {
1149
+ nextIds.add(d.id);
1150
+ // A box I just spawned by double-click lands where I clicked — for everyone.
1151
+ if (pendingSpawn && d.focusedBySelf && !d.place && d.text === '') {
1152
+ sendMsg({ t: 'ui', action: { kind: 'place', id: d.id, ...pendingSpawn } });
1153
+ d.place = pendingSpawn; // optimistic, until the next state frame confirms
1154
+ pendingSpawn = null;
1155
+ }
1156
+ // Entrance animation only for a box that just APPEARED — this render runs on
1157
+ // every keystroke (full rebuild), and replaying it would strobe. Same idea
1158
+ // for the border beam: phase it by wall-clock so a rebuild never resets it.
1159
+ const box = el(
1160
+ 'div',
1161
+ 'fdraft' + (d.focusedBySelf ? ' focused' : '') + (knownBoxIds.has(d.id) ? '' : ' anim-in'),
1162
+ );
1163
+ box.style.setProperty('--beam-delay', -(Date.now() % 1960) + 'ms');
1164
+ // Someone ELSE is writing in it → the ring glows in their color.
1165
+ if (!d.focusedBySelf && d.carets.length > 0) {
1166
+ box.classList.add('edited');
1167
+ box.style.setProperty('--ec', d.carets[0].color);
1168
+ }
1169
+ box.dataset.boxId = d.id;
1170
+ placeDraft(box, d, i);
1171
+
1172
+ const bar = el('div', 'fbar');
1173
+ bar.append(el('span', 'grip', '⠿'));
1174
+ for (const a of d.authors) {
1175
+ const pill = el('span', 'author-pill', a.name + (a.isSelf ? ' (you)' : ''));
1176
+ pill.style.setProperty('--c', a.color);
1177
+ bar.append(pill);
1178
+ }
1179
+ if (v.canCompose) {
1180
+ const del = el('button', 'draft-x', '✕');
1181
+ del.type = 'button';
1182
+ del.title = 'delete this draft';
1183
+ del.onclick = () => sendMsg({ t: 'ui', action: { kind: 'deldraft', id: d.id } });
1184
+ bar.append(del);
1185
+ }
1186
+
1187
+ const body = el('div', 'draft-body');
1188
+ for (const seg of d.segments) {
1189
+ if (seg.type === 'text') {
1190
+ body.append(document.createTextNode(seg.value));
1191
+ } else {
1192
+ const caret = el('span', 'caret' + (seg.self ? ' self' : ''));
1193
+ caret.style.setProperty('--c', seg.color);
1194
+ caret.title = seg.name;
1195
+ body.append(caret);
1196
+ }
1197
+ }
1198
+
1199
+ const rsz = el('span', 'rsz', '◢');
1200
+ box.append(bar, body, rsz);
1201
+
1202
+ // Move: drag the ⠿ bar. Resize: drag the ◢ corner. Both are SHARED — the box
1203
+ // travels on everyone's screen. Double-click the bar snaps it back home.
1204
+ bar.addEventListener('pointerdown', (e) => beginDraftDrag(e, box, d.id, 'move'));
1205
+ rsz.addEventListener('pointerdown', (e) => beginDraftDrag(e, box, d.id, 'size'));
1206
+ bar.addEventListener('dblclick', () => {
1207
+ sendMsg({ t: 'ui', action: { kind: 'place', id: d.id, home: true } });
1208
+ });
1209
+ // Clicks never fall through to the terminal beneath.
1210
+ box.addEventListener('mousedown', (e) => e.stopPropagation());
1211
+ box.addEventListener('dblclick', (e) => e.stopPropagation());
1212
+
1213
+ // Mouseup, not mousedown: a click places your caret at that spot (blank space
1214
+ // → the end); a DRAG leaves the native selection standing — the document-level
1215
+ // key handler turns delete/typing over it into brain edits.
1216
+ body.addEventListener('mouseup', (e) => {
1217
+ const sel = window.getSelection();
1218
+ if (sel && !sel.isCollapsed) return; // keep the highlight
1219
+ const off = clickCharOffset(body, e.clientX, e.clientY);
1220
+ sendMsg({ t: 'ui', action: { kind: 'caret', id: d.id, offset: off == null ? d.text.length : off } });
1221
+ setTimeout(focusComposer, 0);
1222
+ });
1223
+ draftsLayer.append(box);
1224
+ }
1225
+ knownBoxIds = nextIds;
1226
+ }
1227
+
1228
+ // Apply a draft's SHARED placement (stage fractions from the brain), or the home
1229
+ // slot (pinned above Claude's input line, stacking upward by index).
1230
+ function placeDraft(box, d, i) {
1231
+ const p = d.place;
1232
+ if (!p) {
1233
+ box.classList.add('home');
1234
+ box.style.setProperty('--stack', String(i));
1235
+ return;
1236
+ }
1237
+ const r = stage.getBoundingClientRect();
1238
+ box.classList.remove('home');
1239
+ box.style.left = (p.x * r.width).toFixed(1) + 'px';
1240
+ box.style.top = (p.y * r.height).toFixed(1) + 'px';
1241
+ box.style.bottom = 'auto';
1242
+ box.style.transform = 'none';
1243
+ if (p.w) box.style.width = Math.max(220, p.w * r.width).toFixed(1) + 'px';
1244
+ }
1245
+
1246
+ function beginDraftDrag(e, box, id, mode) {
1247
+ if (e.target.closest('button')) return;
1248
+ e.preventDefault();
1249
+ draggingDraft = true;
1250
+ const stageRect = stage.getBoundingClientRect();
1251
+ const boxRect = box.getBoundingClientRect();
1252
+ const start = { x: e.clientX, y: e.clientY };
1253
+ const orig = { x: boxRect.left - stageRect.left, y: boxRect.top - stageRect.top, w: boxRect.width };
1254
+ let last = null;
1255
+ const spot = (ev) => {
1256
+ const dx = ev.clientX - start.x;
1257
+ const dy = ev.clientY - start.y;
1258
+ // Width always travels with the spot — a move must not reset a prior resize
1259
+ // (the brain stores the whole placement object).
1260
+ const px = mode === 'move' ? { x: orig.x + dx, y: orig.y + dy, w: orig.w } : { x: orig.x, y: orig.y, w: Math.max(220, orig.w + dx) };
1261
+ return { x: clamp01(px.x / stageRect.width), y: clamp01(px.y / stageRect.height), w: clamp01(px.w / stageRect.width) };
1262
+ };
1263
+ const move = (ev) => {
1264
+ last = spot(ev);
1265
+ // Local styling immediately (smooth), streamed to the room at ≤30/s.
1266
+ box.classList.remove('home');
1267
+ box.style.left = (last.x * stageRect.width).toFixed(1) + 'px';
1268
+ box.style.top = (last.y * stageRect.height).toFixed(1) + 'px';
1269
+ box.style.bottom = 'auto';
1270
+ box.style.transform = 'none';
1271
+ if (last.w) box.style.width = (last.w * stageRect.width).toFixed(1) + 'px';
1272
+ const now = Date.now();
1273
+ if (throttleReady(placeLast, now, 33)) {
1274
+ placeLast = now;
1275
+ sendMsg({ t: 'ui', action: { kind: 'place', id, ...last } });
1276
+ }
1277
+ };
1278
+ const up = () => {
1279
+ window.removeEventListener('pointermove', move);
1280
+ window.removeEventListener('pointerup', up);
1281
+ draggingDraft = false;
1282
+ if (last) sendMsg({ t: 'ui', action: { kind: 'place', id, ...last } }); // final spot
1283
+ lastDraftsJson = ''; // re-sync with the latest state now that the drag ended
1284
+ render();
1285
+ };
1286
+ window.addEventListener('pointermove', move);
1287
+ window.addEventListener('pointerup', up);
1288
+ }
1289
+
1290
+ // (node, offset) inside a draft body → display-character offset. Caret spans hold
1291
+ // no text, so the body's text nodes concatenate to exactly the draft's display
1292
+ // text. Returns null when the point isn't on the body's text.
1293
+ function domOffset(bodyEl, node, off) {
1294
+ if (!node || !bodyEl.contains(node)) return null;
1295
+ if (node.nodeType !== Node.TEXT_NODE) {
1296
+ // An element hit: `off` counts childNodes — sum the text of the ones before it.
1297
+ if (node !== bodyEl) return null;
1298
+ let total = 0;
1299
+ let i = 0;
1300
+ for (const c of node.childNodes) {
1301
+ if (i >= off) break;
1302
+ total += c.textContent.length;
1303
+ i += 1;
1304
+ }
1305
+ return total;
1306
+ }
1307
+ let total = 0;
1308
+ const walker = document.createTreeWalker(bodyEl, NodeFilter.SHOW_TEXT);
1309
+ for (let n = walker.nextNode(); n && n !== node; n = walker.nextNode()) total += n.nodeValue.length;
1310
+ return total + off;
1311
+ }
1312
+
1313
+ // Translate a click inside a draft body into a display-character offset.
1314
+ function clickCharOffset(bodyEl, x, y) {
1315
+ const p = document.caretPositionFromPoint
1316
+ ? document.caretPositionFromPoint(x, y)
1317
+ : document.caretRangeFromPoint
1318
+ ? document.caretRangeFromPoint(x, y)
1319
+ : null;
1320
+ if (!p) return null;
1321
+ const node = 'offsetNode' in p ? p.offsetNode : p.startContainer;
1322
+ const off = 'offsetNode' in p ? p.offset : p.startOffset;
1323
+ if (!node || node.nodeType !== Node.TEXT_NODE) return null; // blank area → caller uses the end
1324
+ return domOffset(bodyEl, node, off);
1325
+ }
1326
+
1327
+ // The current drag-selection if it lives inside ONE draft body, as display offsets.
1328
+ function selectionInDraft() {
1329
+ const sel = window.getSelection();
1330
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
1331
+ const r = sel.getRangeAt(0);
1332
+ const anchor = r.commonAncestorContainer;
1333
+ const bodyEl = (anchor.nodeType === Node.TEXT_NODE ? anchor.parentElement : anchor)?.closest?.('.draft-body');
1334
+ if (!bodyEl) return null;
1335
+ const start = domOffset(bodyEl, r.startContainer, r.startOffset);
1336
+ const end = domOffset(bodyEl, r.endContainer, r.endOffset);
1337
+ if (start == null || end == null || start === end) return null;
1338
+ const boxId = bodyEl.closest('.draft')?.dataset.boxId;
1339
+ if (!boxId) return null;
1340
+ return { boxId, start: Math.min(start, end), end: Math.max(start, end) };
1341
+ }
1342
+
1343
+ // The queue lives in the header: a quiet chip (amber ring while anything waits,
1344
+ // invisible when empty) that opens a popover of the attributed waiting line.
1345
+ function renderQueue(v) {
1346
+ queueChip.hidden = v.queue.length === 0;
1347
+ queueChip.textContent = `queue ${v.queue.length}`;
1348
+ if (v.queue.length === 0) {
1349
+ queuePop.hidden = true;
1350
+ return;
1351
+ }
1352
+ if (!queuePop.hidden) fillQueuePop(v);
1353
+ }
1354
+ function fillQueuePop(v) {
1355
+ queuePop.innerHTML = '';
1356
+ queuePop.append(el('div', 'queue-label', 'Waiting for Claude — fires one per idle'));
1357
+ for (const q of v.queue) {
1358
+ const chip = el('div', 'queue-chip');
1359
+ const who = el('span', 'queue-who', q.name);
1360
+ who.style.setProperty('--c', q.color);
1361
+ chip.append(el('span', 'queue-n', '#' + q.n), who, el('span', 'queue-text', q.text));
1362
+ const act = el('span', 'queue-act');
1363
+ if (q.isMine) {
1364
+ // Edit = pull the prompt out of the queue and back into a draft box,
1365
+ // focused and ready to type — no browser dialogs.
1366
+ const edit = el('button', 'ctrl tiny', 'edit');
1367
+ edit.onclick = () => {
1368
+ sendMsg({ t: 'ui', action: { kind: 'unqueue', n: q.n } });
1369
+ queuePop.hidden = true;
1370
+ composer.focus();
1371
+ };
1372
+ act.append(edit);
1373
+ }
1374
+ if (q.isMine || v.isHost) {
1375
+ const del = el('button', 'ctrl tiny end', '✕');
1376
+ del.onclick = () => sendCommand(`/queue del ${q.n}`);
1377
+ act.append(del);
1378
+ }
1379
+ chip.append(act);
1380
+ queuePop.append(chip);
1381
+ }
1382
+ }
1383
+ queueChip.addEventListener('click', () => {
1384
+ if (!queuePop.hidden) {
1385
+ queuePop.hidden = true;
1386
+ return;
1387
+ }
1388
+ const v = lastState ? overlayView(lastState, selfId) : null;
1389
+ if (!v || v.queue.length === 0) return;
1390
+ fillQueuePop(v);
1391
+ const r = queueChip.getBoundingClientRect();
1392
+ queuePop.style.left = Math.max(12, Math.min(r.left, window.innerWidth - 480)) + 'px';
1393
+ queuePop.style.top = r.bottom + 8 + 'px';
1394
+ queuePop.hidden = false;
1395
+ });
1396
+ document.addEventListener('mousedown', (e) => {
1397
+ if (!queuePop.hidden && !queuePop.contains(e.target) && e.target !== queueChip) queuePop.hidden = true;
1398
+ });
1399
+
1400
+ function renderComposer(v) {
1401
+ const can = v.canCompose;
1402
+ composer.disabled = !can;
1403
+ newDraftBtn.disabled = !can;
1404
+ }
1405
+
1406
+ function renderCursors(v) {
1407
+ const seen = new Set();
1408
+ const rect = termEl.getBoundingClientRect(); // post-scale: the visual content box
1409
+ const base = cursorLayer.getBoundingClientRect();
1410
+ const ox = rect.left - base.left;
1411
+ const oy = rect.top - base.top;
1412
+ for (const p of v.othersPointers) {
1413
+ seen.add(p.id);
1414
+ let node = cursorEls.get(p.id);
1415
+ if (!node) {
1416
+ node = el('div', 'cursor');
1417
+ node.innerHTML =
1418
+ '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">' +
1419
+ '<path d="M1 1 L1 12 L4.2 9 L6.4 14 L8.6 13 L6.4 8 L11 8 Z"/></svg>';
1420
+ const pill = el('span', 'cursor-pill');
1421
+ node.append(pill);
1422
+ cursorLayer.append(node);
1423
+ cursorEls.set(p.id, node);
1424
+ }
1425
+ const svg = node.querySelector('svg path');
1426
+ if (svg) svg.setAttribute('fill', p.color);
1427
+ const pill = node.querySelector('.cursor-pill');
1428
+ pill.textContent = p.name;
1429
+ pill.style.background = p.color;
1430
+ pill.style.setProperty('--c', p.color);
1431
+ node.style.transform = `translate(${(ox + p.x * rect.width).toFixed(1)}px, ${(oy + p.y * rect.height).toFixed(1)}px)`;
1432
+ }
1433
+ for (const [id, node] of cursorEls) {
1434
+ if (!seen.has(id)) {
1435
+ node.remove();
1436
+ cursorEls.delete(id);
1437
+ }
1438
+ }
1439
+ }
1440
+
1441
+ // The header owns the roster (avatar cluster) and, for the host, knock chips +
1442
+ // the Invite/Pause/End controls. There is no sidebar (design v2.1).
1443
+ function renderHeader(v) {
1444
+ // Avatar cluster: one colored identity dot per participant. Click → popover.
1445
+ avatarsBox.innerHTML = '';
1446
+ for (const p of v.participants) {
1447
+ const av = el('button', 'av', (p.name || '?').slice(0, 1).toUpperCase());
1448
+ av.type = 'button';
1449
+ av.style.setProperty('--c', p.color);
1450
+ av.title = p.name + (p.isSelf ? ' (you)' : '') + ' · ' + p.role;
1451
+ av.onclick = (e) => {
1452
+ e.stopPropagation();
1453
+ openPopover(av, p, v);
1454
+ };
1455
+ avatarsBox.append(av);
1456
+ }
1457
+
1458
+ const host = loc.isHostTab || v.isHost;
1459
+ hostControls.hidden = !host;
1460
+
1461
+ // Knock chips: amber-ringed, inline Admit/Deny — the one loud thing on screen.
1462
+ knocksBox.innerHTML = '';
1463
+ if (host) {
1464
+ for (const k of v.knocks) {
1465
+ const chip = el('span', 'knock-chip');
1466
+ chip.append('🚪 ');
1467
+ chip.append(el('span', 'kname', k.name));
1468
+ chip.append(el('span', null, ' wants to join'));
1469
+ chip.append(el('span', 'kmeta', k.seenLabel));
1470
+ const admit = el('button', 'kbtn yes', 'Admit');
1471
+ const deny = el('button', 'kbtn no', 'Deny');
1472
+ admit.title = deny.title = `${k.seenLabel} · ${k.shortFp}`;
1473
+ admit.onclick = () => sendMsg({ t: 'ui', action: { kind: 'admit', id: k.id } });
1474
+ deny.onclick = () => sendMsg({ t: 'ui', action: { kind: 'deny', id: k.id } });
1475
+ chip.append(admit, deny);
1476
+ knocksBox.append(chip);
1477
+ }
1478
+ }
1479
+
1480
+ pauseBtn.title = v.paused ? 'Resume sharing' : 'Pause sharing';
1481
+ pauseBtn.classList.toggle('active', v.paused);
1482
+ }
1483
+
1484
+ // Avatar popover: the host manages a person here (role chips + kick); everyone
1485
+ // else just sees who the dot is. One popover at a time; outside click closes.
1486
+ function openPopover(anchor, p, v) {
1487
+ popover.innerHTML = '';
1488
+ const head = el('div');
1489
+ const nm = el('span', 'pop-name', p.name + (p.isSelf ? ' (you)' : ''));
1490
+ nm.style.setProperty('--c', p.color);
1491
+ head.append(nm, el('span', 'pop-role', p.role));
1492
+ popover.append(head);
1493
+ const canManage = (loc.isHostTab || v.isHost) && p.role !== 'host' && !p.isSelf;
1494
+ if (canManage) {
1495
+ const row = el('div', 'pop-row');
1496
+ for (const r of ['viewer', 'prompter']) {
1497
+ const b = el('button', 'ctrl tiny' + (r === p.role ? ' active' : ''), r);
1498
+ // Target by id, not @name: names are guest-claimed and non-unique (finding 4).
1499
+ b.onclick = () => {
1500
+ sendMsg(roleAction(p.id, r));
1501
+ closePopover();
1502
+ };
1503
+ row.append(b);
1504
+ }
1505
+ popover.append(row);
1506
+ const kick = el('button', 'ctrl tiny end pop-kick', 'Kick from room');
1507
+ kick.onclick = () => {
1508
+ sendMsg(kickAction(p.id));
1509
+ closePopover();
1510
+ };
1511
+ popover.append(kick);
1512
+ }
1513
+ const r = anchor.getBoundingClientRect();
1514
+ popover.hidden = false;
1515
+ popover.style.left = Math.min(r.left, window.innerWidth - 200) + 'px';
1516
+ popover.style.top = r.bottom + 8 + 'px';
1517
+ }
1518
+ function closePopover() {
1519
+ popover.hidden = true;
1520
+ }
1521
+ document.addEventListener('mousedown', (e) => {
1522
+ if (!popover.hidden && !popover.contains(e.target)) closePopover();
1523
+ });
1524
+
1525
+ // ── wire the join form and boot ────────────────────────────────────────────
1526
+ knockBtn.addEventListener('click', beginKnock);
1527
+ joinForm.addEventListener('submit', (e) => {
1528
+ e.preventDefault();
1529
+ beginKnock();
1530
+ });
1531
+ $('#dc-retry')?.addEventListener('click', () => location.reload());
1532
+
1533
+ if (loc.isHostTab) {
1534
+ // Host tab: no name prompt — connect straight to its own room (name comes from
1535
+ // the shared state once live).
1536
+ joinScreen.hidden = false;
1537
+ connect({ code: loc.code, name: '', token: null, hostToken: loc.hostToken });
1538
+ joinForm.hidden = true;
1539
+ waiting.hidden = false;
1540
+ } else {
1541
+ showJoin();
1542
+ // A returning guest (room in the URL, name + identity token remembered) knocks
1543
+ // automatically — with the brain's auto-readmit, a reload glides straight back
1544
+ // into the room instead of parking on the join form.
1545
+ if (loc.code && store.get(NAME_KEY) && store.get(TOKEN_KEY)) beginKnock();
1546
+ }
1547
+ }
1548
+
1549
+ // Run only in a browser; importing this module in node (tests) touches no DOM.
1550
+ if (typeof document !== 'undefined' && typeof window !== 'undefined') {
1551
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', main);
1552
+ else main();
1553
+ }