llm-canvas-linux-x64 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.
@@ -0,0 +1,727 @@
1
+ // Canvas workspace client.
2
+ // Responsibilities:
3
+ // - List sessions in the sidebar.
4
+ // - Render the selected session's sections (inline elements injected; document form as iframe).
5
+ // - Listen to WebSocket events: section.updated, section.deleted, section.focus, session.created.
6
+ // - On section.updated, re-fetch and swap the single section node in place.
7
+
8
+ import { initComments, attachIframeComments } from './comments.js';
9
+
10
+ const INBOX_RAIL_COLLAPSED_KEY = 'canvas.inboxRail.collapsed';
11
+ const SIDEBAR_COLLAPSED_KEY = 'canvas.sidebar.collapsed';
12
+ const ZOOM_KEY = 'canvas.sections.zoom';
13
+ const THEME_KEY = 'canvas.theme';
14
+ const ZOOM_STEPS = [0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
15
+
16
+ const state = {
17
+ sessions: [],
18
+ currentSlug: null,
19
+ sections: [], // [{ id, title, order, mode, version }]
20
+ ws: null,
21
+ wsConnected: false,
22
+ inbox: [], // raw InboxEvent[] for the current session
23
+ inboxRailCollapsed: localStorage.getItem(INBOX_RAIL_COLLAPSED_KEY) === 'true',
24
+ inboxDrawerOpen: false,
25
+ sidebarCollapsed: localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true',
26
+ zoom: clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY)) || 1),
27
+ theme: resolveInitialTheme(),
28
+ };
29
+
30
+ function resolveInitialTheme() {
31
+ const stored = localStorage.getItem(THEME_KEY);
32
+ if (stored === 'dark' || stored === 'light') return stored;
33
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
34
+ }
35
+
36
+ function clampZoom(z) {
37
+ if (!Number.isFinite(z)) return 1;
38
+ return Math.min(ZOOM_STEPS[ZOOM_STEPS.length - 1], Math.max(ZOOM_STEPS[0], z));
39
+ }
40
+
41
+ const $ = (sel) => document.querySelector(sel);
42
+ const sectionsRoot = $('#sections');
43
+ const empty = $('#empty');
44
+ const sessionNav = $('#session-nav');
45
+ const sessionTitle = $('#session-title');
46
+ const sessionPurpose = $('#session-purpose');
47
+ const connStatus = $('#connection');
48
+ const sidebar = $('#sidebar');
49
+ const sidebarSpine = $('#sidebar-spine');
50
+ const sidebarCollapseBtn = $('#sidebar-collapse');
51
+ const zoomOutBtn = $('#zoom-out');
52
+ const zoomInBtn = $('#zoom-in');
53
+ const zoomResetBtn = $('#zoom-reset');
54
+ const themeToggle = $('#theme-toggle');
55
+ const themeToggleIcon = themeToggle.querySelector('.theme-toggle-icon');
56
+ const inboxRail = $('#inbox-rail');
57
+ const inboxToggleTopbar = $('#inbox-toggle-topbar');
58
+ const inboxTopbarBadge = $('#inbox-topbar-badge');
59
+ const inboxBadge = $('#inbox-badge');
60
+ const inboxSpine = $('#inbox-rail-spine');
61
+ const inboxSpineBadge = $('#inbox-spine-badge');
62
+ const inboxCollapseBtn = $('#inbox-collapse');
63
+ const inboxDrawerClose = $('#inbox-drawer-close');
64
+ const inboxBackdrop = $('#inbox-backdrop');
65
+ const inboxThread = $('#inbox-thread');
66
+ const inboxEmpty = $('#inbox-empty');
67
+ const inboxTextarea = $('#inbox-text');
68
+ const inboxSend = $('#inbox-send');
69
+ const inboxCounter = $('#inbox-counter');
70
+ const inboxError = $('#inbox-error');
71
+ const INBOX_MAX_BYTES = 4096;
72
+
73
+ async function api(path, opts) {
74
+ const res = await fetch(path, opts);
75
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
76
+ return res;
77
+ }
78
+
79
+ async function loadSessions() {
80
+ const res = await api('/api/sessions');
81
+ state.sessions = await res.json();
82
+ renderSidebar();
83
+ if (state.sessions.length === 0) {
84
+ empty.classList.remove('hidden');
85
+ sectionsRoot.classList.add('hidden');
86
+ inboxRail.classList.add('hidden');
87
+ inboxToggleTopbar.classList.add('hidden');
88
+ sessionTitle.textContent = 'Canvas';
89
+ sessionPurpose.textContent = 'No session';
90
+ return;
91
+ }
92
+ empty.classList.add('hidden');
93
+ sectionsRoot.classList.remove('hidden');
94
+
95
+ // Read /s/:slug from URL or default to most-recent.
96
+ const urlSlug = location.pathname.startsWith('/s/') ? location.pathname.slice(3) : null;
97
+ const target = urlSlug && state.sessions.find((s) => s.slug === urlSlug)
98
+ ? urlSlug
99
+ : state.sessions[0].slug;
100
+ await selectSession(target);
101
+ }
102
+
103
+ function renderSidebar() {
104
+ sessionNav.innerHTML = '';
105
+ for (const s of state.sessions) {
106
+ const el = document.createElement('div');
107
+ el.className = 'nav-session';
108
+ el.textContent = s.title;
109
+ el.dataset.slug = s.slug;
110
+ if (s.slug === state.currentSlug) el.dataset.active = 'true';
111
+ el.addEventListener('click', () => selectSession(s.slug));
112
+ sessionNav.appendChild(el);
113
+
114
+ if (s.slug === state.currentSlug) {
115
+ const sectionList = document.createElement('div');
116
+ sectionList.className = 'mt-1 mb-2 space-y-0.5';
117
+ for (const sec of state.sections) {
118
+ const a = document.createElement('a');
119
+ a.className = 'nav-section';
120
+ a.textContent = sec.title;
121
+ a.href = `#section-${sec.id}`;
122
+ a.dataset.id = sec.id;
123
+ a.addEventListener('click', (e) => {
124
+ e.preventDefault();
125
+ focusLocally(sec.id);
126
+ });
127
+ sectionList.appendChild(a);
128
+ }
129
+ sessionNav.appendChild(sectionList);
130
+ }
131
+ }
132
+ }
133
+
134
+ async function selectSession(slug) {
135
+ state.currentSlug = slug;
136
+ history.replaceState({}, '', `/s/${slug}`);
137
+ const session = state.sessions.find((s) => s.slug === slug);
138
+ sessionTitle.textContent = session?.title ?? slug;
139
+ sessionPurpose.textContent = session?.purpose || `Session · ${slug}`;
140
+
141
+ const res = await api(`/api/sessions/${slug}/sections`);
142
+ state.sections = await res.json();
143
+ renderSidebar();
144
+ sectionsRoot.innerHTML = '';
145
+ for (const meta of state.sections) {
146
+ await renderSection(meta);
147
+ }
148
+ inboxRail.classList.remove('hidden');
149
+ inboxToggleTopbar.classList.remove('hidden');
150
+ renderInboxLayout();
151
+ await loadInbox(slug);
152
+ }
153
+
154
+ let turndownService = null;
155
+ function getTurndown() {
156
+ if (turndownService) return turndownService;
157
+ const td = new window.TurndownService({
158
+ headingStyle: 'atx',
159
+ codeBlockStyle: 'fenced',
160
+ bulletListMarker: '-',
161
+ emDelimiter: '*',
162
+ });
163
+ if (window.turndownPluginGfm) td.use(window.turndownPluginGfm.gfm);
164
+ // Mermaid source -> fenced ```mermaid block.
165
+ td.addRule('mermaid', {
166
+ filter: (node) => node.nodeName === 'DIV' && node.classList?.contains('mermaid'),
167
+ replacement: (_content, node) => `\n\n\`\`\`mermaid\n${node.textContent.trim()}\n\`\`\`\n\n`,
168
+ });
169
+ // Canvas callouts -> blockquote.
170
+ td.addRule('canvasCallout', {
171
+ filter: (node) => node.nodeName === 'DIV' && node.classList?.contains('canvas-callout'),
172
+ replacement: (content) =>
173
+ '\n\n' + content.trim().split('\n').map((l) => `> ${l}`.trimEnd()).join('\n') + '\n\n',
174
+ });
175
+ turndownService = td;
176
+ return td;
177
+ }
178
+
179
+ function flashCopied(btn, ok) {
180
+ const label = btn.querySelector('.section-copy-label');
181
+ label.textContent = ok ? 'Copied' : 'Failed';
182
+ btn.dataset.copied = 'true';
183
+ clearTimeout(btn._copyTimer);
184
+ btn._copyTimer = setTimeout(() => {
185
+ label.textContent = 'Copy';
186
+ delete btn.dataset.copied;
187
+ }, 1400);
188
+ }
189
+
190
+ async function copySectionMarkdown(meta, btn) {
191
+ try {
192
+ // Fetch the raw (pre-render) section HTML so mermaid source and code are clean.
193
+ const url = `/api/sessions/${state.currentSlug}/sections/${meta.id}.html?v=${Date.now()}`;
194
+ const res = await api(url);
195
+ const html = await res.text();
196
+ const doc = new DOMParser().parseFromString(html, 'text/html');
197
+ const root = doc.querySelector('section[data-canvas-title], section') || doc.body;
198
+ const md = getTurndown().turndown(root.innerHTML).trim();
199
+ await navigator.clipboard.writeText(md);
200
+ flashCopied(btn, true);
201
+ } catch (err) {
202
+ console.warn('[canvas] section copy failed:', err);
203
+ flashCopied(btn, false);
204
+ }
205
+ }
206
+
207
+ async function renderSection(meta) {
208
+ const host = document.createElement('article');
209
+ host.id = `section-${meta.id}`;
210
+ host.dataset.sectionId = meta.id;
211
+ host.dataset.mode = meta.mode;
212
+ host.className = 'canvas-section-host';
213
+
214
+ const title = document.createElement('div');
215
+ title.className = 'canvas-section-title';
216
+ title.textContent = meta.title;
217
+ host.appendChild(title);
218
+
219
+ if (meta.mode !== 'document') {
220
+ const copyBtn = document.createElement('button');
221
+ copyBtn.className = 'section-copy-btn';
222
+ copyBtn.type = 'button';
223
+ copyBtn.title = 'Copy section as Markdown';
224
+ copyBtn.innerHTML = '<span aria-hidden="true">⧉</span><span class="section-copy-label">Copy</span>';
225
+ copyBtn.addEventListener('click', () => copySectionMarkdown(meta, copyBtn));
226
+ host.appendChild(copyBtn);
227
+ }
228
+
229
+ await populateSection(host, meta);
230
+ sectionsRoot.appendChild(host);
231
+ }
232
+
233
+ async function populateSection(host, meta) {
234
+ const url = `/api/sessions/${state.currentSlug}/sections/${meta.id}.html?v=${meta.version}`;
235
+ const res = await api(url);
236
+ const html = await res.text();
237
+
238
+ // Drop section content when re-populating, but keep the title chip and copy button.
239
+ Array.from(host.children).forEach((child) => {
240
+ const keep = child.classList?.contains('canvas-section-title')
241
+ || child.classList?.contains('section-copy-btn');
242
+ if (!keep) child.remove();
243
+ });
244
+
245
+ if (meta.mode === 'document') {
246
+ const iframe = document.createElement('iframe');
247
+ iframe.className = 'canvas-section-iframe';
248
+ iframe.addEventListener('load', () => {
249
+ themeIframe(iframe);
250
+ attachIframeComments(iframe, meta.id);
251
+ });
252
+ iframe.src = url;
253
+ host.appendChild(iframe);
254
+ return;
255
+ }
256
+
257
+ // Inline form: parse the file, extract the root <section>, append its children.
258
+ const doc = new DOMParser().parseFromString(html, 'text/html');
259
+ const root = doc.querySelector('section[data-canvas-title], section');
260
+ if (!root) {
261
+ const err = document.createElement('div');
262
+ err.className = 'canvas-callout danger';
263
+ err.textContent = `Section "${meta.id}" missing <section data-canvas-title="…"> root.`;
264
+ host.appendChild(err);
265
+ return;
266
+ }
267
+ const body = document.createElement('div');
268
+ body.innerHTML = root.innerHTML;
269
+ host.appendChild(body);
270
+
271
+ // Mermaid render.
272
+ if (window.mermaid) {
273
+ window.mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() });
274
+ body.querySelectorAll('.mermaid, .language-mermaid').forEach(async (node, idx) => {
275
+ try {
276
+ const code = node.textContent.trim();
277
+ const { svg } = await window.mermaid.render(`mermaid-${meta.id}-${idx}-${Date.now()}`, code);
278
+ const wrap = document.createElement('div');
279
+ wrap.dataset.mermaidSrc = code;
280
+ wrap.innerHTML = svg;
281
+ node.replaceWith(wrap);
282
+ } catch (err) {
283
+ console.warn(`[canvas] mermaid render failed in ${meta.id}:`, err);
284
+ }
285
+ });
286
+ }
287
+
288
+ // Code highlighting.
289
+ if (window.hljs) {
290
+ body.querySelectorAll('pre code').forEach((node) => {
291
+ try {
292
+ window.hljs.highlightElement(node);
293
+ } catch (err) {
294
+ console.warn(`[canvas] hljs failed for ${meta.id}:`, err);
295
+ }
296
+ });
297
+ }
298
+ }
299
+
300
+ async function refreshSection(slug, id) {
301
+ if (slug !== state.currentSlug) return;
302
+ // Re-fetch the section meta from /sections list (cheap; ~ms).
303
+ const listRes = await api(`/api/sessions/${slug}/sections`);
304
+ const list = await listRes.json();
305
+ state.sections = list;
306
+ renderSidebar();
307
+
308
+ const meta = list.find((s) => s.id === id);
309
+ if (!meta) {
310
+ const existing = document.getElementById(`section-${id}`);
311
+ existing?.remove();
312
+ return;
313
+ }
314
+
315
+ let host = document.getElementById(`section-${id}`);
316
+ if (!host) {
317
+ await renderSection(meta);
318
+ return;
319
+ }
320
+ host.dataset.mode = meta.mode;
321
+ host.querySelector('.canvas-section-title').textContent = meta.title;
322
+ await populateSection(host, meta);
323
+ }
324
+
325
+ function removeSection(slug, id) {
326
+ if (slug !== state.currentSlug) return;
327
+ document.getElementById(`section-${id}`)?.remove();
328
+ state.sections = state.sections.filter((s) => s.id !== id);
329
+ renderSidebar();
330
+ }
331
+
332
+ function focusLocally(id) {
333
+ document.querySelectorAll('[data-focused="true"]').forEach((n) => delete n.dataset.focused);
334
+ const host = document.getElementById(`section-${id}`);
335
+ if (!host) return;
336
+ host.dataset.focused = 'true';
337
+ host.scrollIntoView({ behavior: 'smooth', block: 'start' });
338
+ }
339
+
340
+ // --- Inbox: browser→agent note channel ---
341
+
342
+ function relativeTime(iso) {
343
+ const t = new Date(iso).getTime();
344
+ if (!Number.isFinite(t)) return '';
345
+ const ms = Date.now() - t;
346
+ if (ms < 60_000) return 'just now';
347
+ const m = Math.floor(ms / 60_000);
348
+ if (m < 60) return `${m}m ago`;
349
+ const h = Math.floor(m / 60);
350
+ if (h < 24) return `${h}h ago`;
351
+ const d = Math.floor(h / 24);
352
+ if (d < 7) return `${d}d ago`;
353
+ return new Date(iso).toLocaleDateString();
354
+ }
355
+
356
+ function byteLen(s) {
357
+ return new TextEncoder().encode(s).length;
358
+ }
359
+
360
+ async function loadInbox(slug) {
361
+ if (!slug) { state.inbox = []; renderInboxThread(); return; }
362
+ try {
363
+ const res = await fetch(`/api/sessions/${slug}/inbox`);
364
+ state.inbox = res.ok ? await res.json() : [];
365
+ } catch {
366
+ state.inbox = [];
367
+ }
368
+ renderInboxThread();
369
+ }
370
+
371
+ function setInboxBadges(unread) {
372
+ const label = unread > 99 ? '99+' : String(unread);
373
+ for (const el of [inboxBadge, inboxTopbarBadge, inboxSpineBadge]) {
374
+ el.textContent = label;
375
+ el.classList.toggle('hidden', unread === 0);
376
+ }
377
+ }
378
+
379
+ function renderInboxLayout() {
380
+ inboxRail.dataset.collapsed = state.inboxRailCollapsed ? 'true' : 'false';
381
+ inboxRail.dataset.open = state.inboxDrawerOpen ? 'true' : 'false';
382
+ inboxToggleTopbar.setAttribute('aria-expanded', String(state.inboxDrawerOpen));
383
+ inboxBackdrop.classList.toggle('hidden', !state.inboxDrawerOpen);
384
+ }
385
+
386
+ function renderInboxThread() {
387
+ // Map a comment.created id -> the (latest) comment.handled that references it.
388
+ const acks = new Map();
389
+ for (const ev of state.inbox) {
390
+ if (ev.type === 'comment.handled' && ev.ref_id) acks.set(ev.ref_id, ev);
391
+ }
392
+ const created = state.inbox.filter((ev) => ev.type === 'comment.created');
393
+ const unhandled = created.filter((ev) => !acks.has(ev.id)).length;
394
+ setInboxBadges(unhandled);
395
+
396
+ inboxThread.innerHTML = '';
397
+ if (created.length === 0) {
398
+ inboxEmpty.classList.remove('hidden');
399
+ inboxThread.classList.add('hidden');
400
+ return;
401
+ }
402
+ inboxEmpty.classList.add('hidden');
403
+ inboxThread.classList.remove('hidden');
404
+
405
+ for (const ev of created) {
406
+ const card = document.createElement('div');
407
+ card.className = 'inbox-message';
408
+
409
+ const meta = document.createElement('div');
410
+ meta.className = 'inbox-message-meta';
411
+ const pill = document.createElement('span');
412
+ pill.className = 'canvas-pill';
413
+ pill.textContent = ev.source === 'agent' ? 'Agent' : 'You';
414
+ meta.appendChild(pill);
415
+ const when = document.createElement('span');
416
+ when.textContent = relativeTime(ev.timestamp);
417
+ meta.appendChild(when);
418
+ if (ev.section_id) {
419
+ const ref = document.createElement('a');
420
+ ref.className = 'canvas-fileref';
421
+ ref.href = `#section-${ev.section_id}`;
422
+ ref.textContent = ev.section_id;
423
+ ref.addEventListener('click', (e) => { e.preventDefault(); focusLocally(ev.section_id); });
424
+ meta.appendChild(ref);
425
+ }
426
+ card.appendChild(meta);
427
+
428
+ const anchor = ev.payload?.anchor;
429
+ if (anchor && typeof anchor.quote === 'string') {
430
+ const quoteWrap = document.createElement('div');
431
+ quoteWrap.className = 'inbox-message-anchor';
432
+ const kindPill = document.createElement('span');
433
+ kindPill.className = 'canvas-pill';
434
+ kindPill.textContent = anchor.kind || 'text';
435
+ quoteWrap.appendChild(kindPill);
436
+ const quote = document.createElement('blockquote');
437
+ quote.textContent = anchor.quote;
438
+ quoteWrap.appendChild(quote);
439
+ card.appendChild(quoteWrap);
440
+ }
441
+
442
+ const text = document.createElement('div');
443
+ text.className = 'inbox-message-text';
444
+ text.textContent = ev.payload?.text ?? '';
445
+ card.appendChild(text);
446
+
447
+ if (acks.has(ev.id)) {
448
+ const ackEl = document.createElement('div');
449
+ ackEl.className = 'inbox-ack';
450
+ ackEl.textContent = '✓ Acknowledged';
451
+ card.appendChild(ackEl);
452
+ }
453
+ inboxThread.appendChild(card);
454
+ }
455
+ inboxThread.scrollTop = inboxThread.scrollHeight;
456
+ }
457
+
458
+ function showInboxError(msg) {
459
+ inboxError.textContent = msg;
460
+ inboxError.classList.remove('hidden');
461
+ }
462
+ function hideInboxError() {
463
+ inboxError.textContent = '';
464
+ inboxError.classList.add('hidden');
465
+ }
466
+
467
+ function updateInboxCounter() {
468
+ const n = byteLen(inboxTextarea.value);
469
+ inboxCounter.textContent = `${n} / ${INBOX_MAX_BYTES}`;
470
+ const over = n > INBOX_MAX_BYTES;
471
+ inboxCounter.dataset.over = over ? 'true' : 'false';
472
+ inboxSend.disabled = over || !state.wsConnected;
473
+ }
474
+
475
+ function setInboxConnected(connected) {
476
+ state.wsConnected = connected;
477
+ inboxTextarea.placeholder = connected ? 'Leave a note for the agent…' : 'Reconnecting…';
478
+ updateInboxCounter();
479
+ }
480
+
481
+ async function sendInboxMessage() {
482
+ const text = inboxTextarea.value;
483
+ if (!text.trim() || !state.currentSlug) return;
484
+ if (byteLen(text) > INBOX_MAX_BYTES) { showInboxError('Message is over the 4096-byte limit.'); return; }
485
+ const focused = document.querySelector('.canvas-section-host[data-focused="true"]');
486
+ const sectionId = focused?.dataset.sectionId ?? null;
487
+ inboxSend.disabled = true;
488
+ hideInboxError();
489
+ try {
490
+ const res = await fetch(`/api/sessions/${state.currentSlug}/inbox`, {
491
+ method: 'POST',
492
+ headers: { 'content-type': 'application/json' },
493
+ body: JSON.stringify({ text, section_id: sectionId }),
494
+ });
495
+ if (!res.ok) {
496
+ let detail = `HTTP ${res.status}`;
497
+ try { detail = (await res.json()).message || detail; } catch {}
498
+ showInboxError(`Send failed: ${detail}`);
499
+ return;
500
+ }
501
+ inboxTextarea.value = '';
502
+ // No optimistic render — the inbox.updated broadcast triggers loadInbox().
503
+ } catch (err) {
504
+ showInboxError(`Send failed: ${err.message}`);
505
+ } finally {
506
+ updateInboxCounter(); // re-enables Send if still connected and under cap
507
+ }
508
+ }
509
+
510
+ function toggleInboxRailCollapsed() {
511
+ state.inboxRailCollapsed = !state.inboxRailCollapsed;
512
+ localStorage.setItem(INBOX_RAIL_COLLAPSED_KEY, String(state.inboxRailCollapsed));
513
+ renderInboxLayout();
514
+ if (!state.inboxRailCollapsed) inboxThread.scrollTop = inboxThread.scrollHeight;
515
+ }
516
+
517
+ function setInboxDrawerOpen(open) {
518
+ state.inboxDrawerOpen = open;
519
+ renderInboxLayout();
520
+ if (open) {
521
+ inboxThread.scrollTop = inboxThread.scrollHeight;
522
+ inboxTextarea.focus();
523
+ }
524
+ }
525
+
526
+ function renderSidebarLayout() {
527
+ sidebar.dataset.collapsed = state.sidebarCollapsed ? 'true' : 'false';
528
+ }
529
+
530
+ function applyZoom() {
531
+ state.zoom = clampZoom(state.zoom);
532
+ sectionsRoot.style.zoom = String(state.zoom);
533
+ zoomResetBtn.textContent = `${Math.round(state.zoom * 100)}%`;
534
+ zoomOutBtn.disabled = state.zoom <= ZOOM_STEPS[0];
535
+ zoomInBtn.disabled = state.zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1];
536
+ localStorage.setItem(ZOOM_KEY, String(state.zoom));
537
+ }
538
+
539
+ function stepZoom(dir) {
540
+ // dir: +1 zoom in, -1 zoom out. Snap to the next step past the current value.
541
+ if (dir > 0) {
542
+ const next = ZOOM_STEPS.find((s) => s > state.zoom + 1e-6);
543
+ state.zoom = next ?? ZOOM_STEPS[ZOOM_STEPS.length - 1];
544
+ } else {
545
+ const lower = [...ZOOM_STEPS].reverse().find((s) => s < state.zoom - 1e-6);
546
+ state.zoom = lower ?? ZOOM_STEPS[0];
547
+ }
548
+ applyZoom();
549
+ }
550
+
551
+ function setupZoom() {
552
+ zoomInBtn.addEventListener('click', () => stepZoom(1));
553
+ zoomOutBtn.addEventListener('click', () => stepZoom(-1));
554
+ zoomResetBtn.addEventListener('click', () => { state.zoom = 1; applyZoom(); });
555
+ document.addEventListener('keydown', (e) => {
556
+ if (!(e.ctrlKey || e.metaKey)) return;
557
+ if (e.key === '=' || e.key === '+') { e.preventDefault(); stepZoom(1); }
558
+ else if (e.key === '-' || e.key === '_') { e.preventDefault(); stepZoom(-1); }
559
+ else if (e.key === '0') { e.preventDefault(); state.zoom = 1; applyZoom(); }
560
+ });
561
+ applyZoom();
562
+ }
563
+
564
+ function mermaidTheme() {
565
+ return state.theme === 'dark' ? 'dark' : 'neutral';
566
+ }
567
+
568
+ function themeIframe(iframe) {
569
+ try {
570
+ const doc = iframe.contentDocument;
571
+ if (!doc) return;
572
+ doc.documentElement.classList.toggle('dark', state.theme === 'dark');
573
+ if (!doc.querySelector('link[data-canvas-theme]')) {
574
+ const link = doc.createElement('link');
575
+ link.rel = 'stylesheet';
576
+ link.href = '/workspace/theme.css';
577
+ link.dataset.canvasTheme = 'true';
578
+ doc.head.appendChild(link);
579
+ }
580
+ } catch (err) {
581
+ console.warn('[canvas] could not theme iframe:', err);
582
+ }
583
+ }
584
+
585
+ let mermaidRenderGen = 0;
586
+ async function reRenderMermaid() {
587
+ if (!window.mermaid) return;
588
+ const gen = ++mermaidRenderGen;
589
+ window.mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() });
590
+ const nodes = document.querySelectorAll('[data-mermaid-src]');
591
+ for (const [idx, node] of nodes.entries()) {
592
+ if (gen !== mermaidRenderGen) return; // a newer toggle superseded this run
593
+ try {
594
+ const code = node.dataset.mermaidSrc;
595
+ const { svg } = await window.mermaid.render(`mermaid-rerender-${gen}-${idx}`, code);
596
+ if (gen !== mermaidRenderGen) return;
597
+ node.innerHTML = svg;
598
+ } catch (err) {
599
+ console.warn('[canvas] mermaid re-render failed:', err);
600
+ }
601
+ }
602
+ }
603
+
604
+ function applyTheme(persist = false) {
605
+ const dark = state.theme === 'dark';
606
+ document.documentElement.classList.toggle('dark', dark);
607
+ if (persist) {
608
+ try { localStorage.setItem(THEME_KEY, state.theme); } catch {}
609
+ }
610
+ themeToggle.setAttribute('aria-pressed', String(dark));
611
+ themeToggleIcon.textContent = dark ? '☀️' : '🌙';
612
+ document.querySelectorAll('.canvas-section-iframe').forEach(themeIframe);
613
+ reRenderMermaid();
614
+ }
615
+
616
+ function setupTheme() {
617
+ themeToggle.addEventListener('click', () => {
618
+ state.theme = state.theme === 'dark' ? 'light' : 'dark';
619
+ applyTheme(true); // explicit user choice — persist it
620
+ });
621
+ applyTheme(); // initial render — do not persist
622
+ }
623
+
624
+ function toggleSidebarCollapsed() {
625
+ state.sidebarCollapsed = !state.sidebarCollapsed;
626
+ localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(state.sidebarCollapsed));
627
+ renderSidebarLayout();
628
+ }
629
+
630
+ function setupSidebar() {
631
+ sidebarCollapseBtn.addEventListener('click', toggleSidebarCollapsed);
632
+ sidebarSpine.addEventListener('click', () => {
633
+ if (state.sidebarCollapsed) toggleSidebarCollapsed();
634
+ });
635
+ renderSidebarLayout();
636
+ }
637
+
638
+ function setupInbox() {
639
+ // Top-bar toggle (narrow screens): open/close the drawer.
640
+ inboxToggleTopbar.addEventListener('click', () => setInboxDrawerOpen(!state.inboxDrawerOpen));
641
+ // Spine (wide-screen collapsed state): click anywhere to expand.
642
+ inboxSpine.addEventListener('click', () => {
643
+ if (state.inboxRailCollapsed) toggleInboxRailCollapsed();
644
+ });
645
+ // Header chevron (wide-screen expanded): collapse.
646
+ inboxCollapseBtn.addEventListener('click', toggleInboxRailCollapsed);
647
+ // Drawer close X (narrow-screen drawer).
648
+ inboxDrawerClose.addEventListener('click', () => setInboxDrawerOpen(false));
649
+ // Backdrop click closes drawer.
650
+ inboxBackdrop.addEventListener('click', () => setInboxDrawerOpen(false));
651
+ // Esc closes drawer (no-op on wide).
652
+ document.addEventListener('keydown', (e) => {
653
+ if (e.key === 'Escape' && state.inboxDrawerOpen) setInboxDrawerOpen(false);
654
+ // Cmd/Ctrl+/ toggles inbox (drawer on narrow, collapse on wide).
655
+ if (e.key === '/' && (e.ctrlKey || e.metaKey)) {
656
+ e.preventDefault();
657
+ if (window.matchMedia('(min-width: 1280px)').matches) toggleInboxRailCollapsed();
658
+ else setInboxDrawerOpen(!state.inboxDrawerOpen);
659
+ }
660
+ });
661
+ renderInboxLayout();
662
+ inboxTextarea.addEventListener('input', updateInboxCounter);
663
+ inboxTextarea.addEventListener('keydown', (e) => {
664
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendInboxMessage(); }
665
+ });
666
+ inboxSend.addEventListener('click', sendInboxMessage);
667
+ updateInboxCounter();
668
+ }
669
+
670
+ function connectWS() {
671
+ const ws = new WebSocket(`ws://${location.host}/ws`);
672
+ ws.addEventListener('open', () => {
673
+ connStatus.textContent = 'connected';
674
+ connStatus.className = 'mt-1 text-xs text-emerald-600';
675
+ setInboxConnected(true);
676
+ if (state.currentSlug) loadInbox(state.currentSlug);
677
+ });
678
+ ws.addEventListener('close', () => {
679
+ connStatus.textContent = 'disconnected · reconnecting…';
680
+ connStatus.className = 'mt-1 text-xs text-amber-600';
681
+ setInboxConnected(false);
682
+ setTimeout(connectWS, 1500);
683
+ });
684
+ ws.addEventListener('message', (event) => {
685
+ let msg;
686
+ try { msg = JSON.parse(event.data); } catch { return; }
687
+ switch (msg.type) {
688
+ case 'section.updated':
689
+ refreshSection(msg.slug, msg.id);
690
+ break;
691
+ case 'section.deleted':
692
+ removeSection(msg.slug, msg.id);
693
+ break;
694
+ case 'section.focus':
695
+ if (msg.slug === state.currentSlug) {
696
+ focusLocally(msg.id);
697
+ } else {
698
+ selectSession(msg.slug).then(() => focusLocally(msg.id));
699
+ }
700
+ break;
701
+ case 'session.created':
702
+ loadSessions();
703
+ break;
704
+ case 'inbox.updated':
705
+ if (msg.slug === state.currentSlug) loadInbox(msg.slug);
706
+ break;
707
+ }
708
+ });
709
+ state.ws = ws;
710
+ }
711
+
712
+ // Wait for libs (mermaid is module-loaded) before first render.
713
+ function start() {
714
+ setupSidebar();
715
+ setupZoom();
716
+ setupTheme();
717
+ setupInbox();
718
+ initComments({ getSlug: () => state.currentSlug });
719
+ loadSessions().catch((err) => {
720
+ console.error('Initial load failed:', err);
721
+ connStatus.textContent = `load error: ${err.message}`;
722
+ });
723
+ connectWS();
724
+ }
725
+
726
+ if (window.__libs_ready) start();
727
+ else window.addEventListener('libs-ready', start, { once: true });