@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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +36 -0
- package/packages/cli/bin/claude-share.js +1319 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/brain/card.js +157 -0
- package/packages/cli/src/brain/commands.js +122 -0
- package/packages/cli/src/brain/drafts.js +557 -0
- package/packages/cli/src/brain/gate.js +134 -0
- package/packages/cli/src/brain/knocks.js +27 -0
- package/packages/cli/src/brain/log.js +123 -0
- package/packages/cli/src/brain/queue.js +81 -0
- package/packages/cli/src/brain/state.js +245 -0
- package/packages/cli/src/hooks.js +243 -0
- package/packages/cli/src/invite.js +43 -0
- package/packages/cli/src/known-relays.js +66 -0
- package/packages/cli/src/pty.js +175 -0
- package/packages/cli/src/relay-client.js +271 -0
- package/packages/cli/src/renderer.js +230 -0
- package/packages/cli/src/screen-snapshot.js +139 -0
- package/packages/cli/src/term-chatter.js +89 -0
- package/packages/relay/auth.js +18 -0
- package/packages/relay/bin/serve.js +73 -0
- package/packages/relay/names.js +27 -0
- package/packages/relay/package.json +13 -0
- package/packages/relay/public/client.js +1553 -0
- package/packages/relay/public/index.html +110 -0
- package/packages/relay/public/style.css +952 -0
- package/packages/relay/rooms.js +149 -0
- package/packages/relay/server.js +652 -0
- package/packages/relay/web.js +334 -0
- package/packages/relay/wordlists.js +38 -0
- package/packages/shared/package.json +8 -0
- 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
|
+
}
|