pmrinal2005 commited on
Commit
4fe2c55
·
verified ·
1 Parent(s): 071e3db

Upload folder using huggingface_hub

Browse files
frontend/dist/assets/api.js CHANGED
@@ -1,20 +1,53 @@
1
- /* Tiny client for /api/* endpoints.
2
- Supports multiple attachments (max 2 enforced on UI side too). */
 
 
 
 
3
  window.ElysiumAPI = {
4
  async turn(text, files) {
5
  const fd = new FormData();
6
  fd.append('user_text', text || '');
7
- (files || []).slice(0, 2).forEach(f => fd.append('attachments', f));
8
  const r = await fetch('/api/turn', { method: 'POST', body: fd });
9
- if (!r.ok) throw new Error('HTTP ' + r.status);
 
 
 
 
10
  return r.json();
11
  },
12
- async health() { return (await fetch('/api/health')).json(); },
13
- async hypergraph() { return (await fetch('/api/hypergraph')).json(); },
14
- async nodeDetail(id){
15
- const r = await fetch('/api/node/' + encodeURIComponent(id));
16
- if (!r.ok) return null;
17
- return r.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  },
19
- async reset() { return (await fetch('/api/reset',{method:'POST'})).json(); },
20
  };
 
1
+ /* ============================================================
2
+ Elysium API client talks to FastAPI routes on gr.Server.
3
+ - Sends multipart/form-data with optional attachments
4
+ - Hard-caps at 2 files (server also enforces)
5
+ - Surfaces friendly errors so boot.js can toast them
6
+ ============================================================ */
7
  window.ElysiumAPI = {
8
  async turn(text, files) {
9
  const fd = new FormData();
10
  fd.append('user_text', text || '');
11
+ (files || []).slice(0, 2).forEach(f => fd.append('attachments', f, f.name));
12
  const r = await fetch('/api/turn', { method: 'POST', body: fd });
13
+ if (!r.ok) {
14
+ let detail = '';
15
+ try { detail = (await r.text()).slice(0, 200); } catch {}
16
+ throw new Error(`HTTP ${r.status}${detail ? ': ' + detail : ''}`);
17
+ }
18
  return r.json();
19
  },
20
+
21
+ async health() {
22
+ try {
23
+ const r = await fetch('/api/health');
24
+ if (!r.ok) return { status: 'error' };
25
+ return r.json();
26
+ } catch { return { status: 'offline' }; }
27
+ },
28
+
29
+ async hypergraph() {
30
+ try {
31
+ const r = await fetch('/api/hypergraph');
32
+ if (!r.ok) return { nodes: [], edges: [], node_count: 0, edge_count: 0 };
33
+ return r.json();
34
+ } catch {
35
+ return { nodes: [], edges: [], node_count: 0, edge_count: 0 };
36
+ }
37
+ },
38
+
39
+ async nodeDetail(id) {
40
+ try {
41
+ const r = await fetch('/api/node/' + encodeURIComponent(id));
42
+ if (!r.ok) return null;
43
+ return r.json();
44
+ } catch { return null; }
45
+ },
46
+
47
+ async reset() {
48
+ try {
49
+ const r = await fetch('/api/reset', { method: 'POST' });
50
+ return r.ok ? r.json() : { status: 'error' };
51
+ } catch { return { status: 'offline' }; }
52
  },
 
53
  };
frontend/dist/assets/boot.js CHANGED
@@ -1,45 +1,83 @@
1
- /* Main app boot — wires UI ↔ /api/turn.
2
- Critical responsibilities:
3
- • NEVER render raw JSON on the canvas. JSON is parsed and routed:
4
- hypergraph_delta → canvas nodes/edges
5
- council_deliberation → Council overlay + TTS
6
- ui_directives → pulses, focus, alert level
7
- direct_answer optional toast
8
- metrics bottom stats bar
9
- Disable input + paperclip + send while model is thinking (busy lock)
10
- File preview strip above textbox (image thumbs + PDF tiles)
11
- Max 2 attachments enforced client-side
12
- Legend updated live from real type counts
13
- */
 
 
 
 
 
 
 
 
14
  (() => {
15
- const input = document.getElementById('q-input');
16
- const send = document.getElementById('q-send');
17
- const fileEl = document.getElementById('q-file');
18
- const upBtn = document.getElementById('q-upload');
19
- const strip = document.getElementById('attach-strip');
20
- const seedHint = document.getElementById('seed-hint');
21
-
22
- // ── Toast helper ──
 
 
 
 
 
 
 
 
 
 
23
  window.toast = function (msg, kind = '') {
 
 
24
  const t = document.createElement('div');
25
- t.className = 'toast ' + kind;
26
- t.textContent = msg;
27
- document.getElementById('toasts').appendChild(t);
28
- setTimeout(() => t.remove(), 5200);
29
  };
30
 
31
- // ── File attachment state (max 2) ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  const MAX_FILES = 2;
33
- let attachments = []; // {file, name, kind, previewUrl}
 
34
 
35
  function renderStrip() {
 
36
  if (attachments.length === 0) {
37
- strip.classList.add('hidden');
38
  strip.innerHTML = '';
39
  return;
40
  }
41
- strip.classList.remove('hidden');
42
- const hintHtml = attachments.length < MAX_FILES
43
  ? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
44
  : `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
45
  strip.innerHTML = attachments.map((a, i) => {
@@ -48,23 +86,30 @@
48
  : `<div class="pdf-ico">PDF</div>`;
49
  const sizeKb = (a.file.size / 1024).toFixed(0);
50
  return `
51
- <div class="preview-tile">
52
  ${preview}
53
  <span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
54
- <button class="x" data-i="${i}" title="Remove">×</button>
55
  </div>`;
56
- }).join('') + hintHtml;
 
 
57
  strip.querySelectorAll('.x').forEach(b => {
58
- b.onclick = () => {
 
 
59
  const i = +b.dataset.i;
60
  const removed = attachments.splice(i, 1)[0];
61
- if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
 
 
62
  renderStrip();
63
  };
64
  });
65
  }
66
 
67
  function addFiles(files) {
 
68
  const list = Array.from(files);
69
  for (const f of list) {
70
  if (attachments.length >= MAX_FILES) {
@@ -75,10 +120,10 @@
75
  const isImg = mime.startsWith('image/');
76
  const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
77
  if (!isImg && !isPdf) {
78
- window.toast(`Unsupported file: ${f.name} (only images & PDFs)`, 'warn');
79
  continue;
80
  }
81
- if (f.size > 12 * 1024 * 1024) {
82
  window.toast(`${f.name} is too large (>12MB)`, 'warn');
83
  continue;
84
  }
@@ -90,183 +135,292 @@
90
  });
91
  }
92
  renderStrip();
93
- // reset native input so the same file can be re-selected if user removes it
94
- fileEl.value = '';
95
  }
96
 
97
- fileEl.addEventListener('change', e => addFiles(e.target.files));
 
 
98
 
99
- // ── Drag-and-drop onto the whole window (bonus polish) ──
100
  ['dragenter', 'dragover'].forEach(ev =>
101
  window.addEventListener(ev, e => { e.preventDefault(); }));
102
  window.addEventListener('drop', e => {
103
- if (!e.dataTransfer || !e.dataTransfer.files.length) return;
104
  e.preventDefault();
105
  addFiles(e.dataTransfer.files);
106
  });
107
 
108
- // ── Busy lock (disable input while model thinks) ──
 
 
109
  function setBusy(b) {
110
  document.body.dataset.busy = b ? '1' : '0';
111
- input.disabled = b;
112
- send.disabled = b;
113
  if (b) {
114
- send.classList.add('loading');
115
- upBtn.classList.add('disabled');
116
- fileEl.disabled = true;
117
- input.dataset.prev = input.placeholder;
118
- input.placeholder = 'Council deliberating…';
 
 
119
  } else {
120
- send.classList.remove('loading');
121
- upBtn.classList.remove('disabled');
122
- fileEl.disabled = false;
123
- input.placeholder = input.dataset.prev || 'Speak to your civilization seed…';
 
 
124
  }
125
  }
126
 
127
- // ── Restore civilization on load ──
 
 
128
  async function restore() {
129
  try {
130
  const h = await ElysiumAPI.hypergraph();
131
  (h.nodes || []).forEach(n => {
132
- if (n.node_id !== 'CORE') window.elysiumAddNode(n);
 
 
 
 
 
133
  });
134
- (h.edges || []).forEach(e => window.elysiumAddEdge(e));
135
- if ((h.nodes || []).length > 0) seedHint.classList.add('hidden');
136
  updateLegend();
137
  updateMetrics({
138
- nodes: h.node_count, edges: h.edge_count,
139
- council_active: 0, knowledge_growth: 0,
 
 
140
  civilization_age_min: 0,
141
- mycelium_density_pct: h.node_count ? Math.round(Math.min(1, h.edge_count / Math.max(1, h.node_count * 1.4)) * 100) : 0,
 
 
142
  coherence_pct: 70,
143
  });
144
  } catch (e) {
145
- // first boot offline — fine
 
146
  }
147
  }
148
  restore();
149
 
150
- // ── SUBMIT ──
 
 
151
  async function submit() {
152
  if (document.body.dataset.busy === '1') return;
153
- const text = input.value.trim();
154
  if (!text && attachments.length === 0) return;
155
 
156
  setBusy(true);
157
- seedHint.classList.add('hidden');
158
- input.value = '';
159
 
160
  const files = attachments.map(a => a.file);
161
- // clear preview strip
162
- attachments.forEach(a => a.previewUrl && URL.revokeObjectURL(a.previewUrl));
 
 
163
  attachments = [];
164
  renderStrip();
165
 
166
  try {
167
  const data = await ElysiumAPI.turn(text, files);
168
- handleResponse(data);
169
  } catch (e) {
170
- window.toast('Inference failed: ' + e.message, 'error');
 
171
  } finally {
172
  setBusy(false);
173
  }
174
  }
175
 
176
- send.onclick = submit;
177
- input.addEventListener('keydown', e => {
178
- if (e.key === 'Enter' && !e.shiftKey) {
179
- e.preventDefault();
180
- submit();
181
- }
182
- });
 
 
183
 
184
- // ── HANDLE MODEL RESPONSE ──
185
- // Parses JSON envelope and routes data to the right UI subsystem.
186
- // NEVER renders the JSON itself on the canvas.
 
 
187
  function handleResponse(payload) {
188
- const resp = payload.elysium_response || {};
189
- const rt = payload._runtime || {};
 
 
 
 
 
 
 
 
 
190
 
191
- // 0. Attachment errors → toast
192
- (rt.attachment_errors || []).forEach(e =>
193
  window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
194
 
195
- // 1. Hypergraph delta → canvas
196
  const delta = resp.hypergraph_delta || {};
197
- (delta.nodes_added || []).forEach(n => {
198
- // Find a parent hint: first edge whose target is this node
199
- let parent = null;
200
- for (const e of (delta.edges_added || [])) {
201
- if (e.target_node_id === n.node_id) { parent = e.source_node_id; break; }
202
- if (e.source_node_id === n.node_id) { parent = e.target_node_id; break; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  }
204
- window.elysiumAddNode(n, parent);
205
  });
206
- (delta.edges_added || []).forEach(e => window.elysiumAddEdge(e));
207
 
208
- // 2. UI directives
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  const ui = resp.ui_directives || {};
210
- (ui.bioluminescence_pulse_nodes || []).forEach(id => window.elysiumPulse(id, 1500));
 
 
211
  document.body.dataset.alert = ui.alert_level || 'CALM';
212
- if (ui.camera_focus_node_id) {
213
- // Pulse + focus for emphasis
214
- window.elysiumPulse(ui.camera_focus_node_id, 1800);
215
- setTimeout(() => window.elysiumFocus(ui.camera_focus_node_id), 350);
 
 
216
  }
217
 
218
  // 3. Council overlay + TTS
219
- window.renderCouncil(resp, rt);
 
 
 
 
 
 
220
 
221
- // 4. Metrics bar (REAL civilization metrics, not fake)
222
  updateMetrics(rt.metrics || {});
223
 
224
  // 5. Agent count badge
225
- const ag = (resp.council_deliberation?.agent_outputs || []).length;
226
- document.getElementById('agent-count').textContent = `+${ag}`;
 
227
 
228
- // 6. Legend live update
229
  updateLegend();
230
 
231
  // 7. Tool toasts
232
- (rt.tool_results || []).forEach(tr => {
 
233
  const ok = tr.result && !tr.result.error;
234
- window.toast(`🔧 ${tr.tool_name}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
235
- ok ? 'info' : 'warn');
 
 
236
  });
237
 
238
- // 8. Direct answer toast (only if no council, otherwise it's already in synthesis)
239
- if (!ag && resp.direct_answer) {
240
- window.toast(resp.direct_answer);
 
241
  }
242
 
243
- // 9. Attachment processed confirmation
244
- if ((rt.attachments_processed || []).length) {
245
- const names = rt.attachments_processed.map(a => a.name).join(', ');
246
- window.toast(`📎 Analyzed: ${names}`, 'info');
 
247
  }
248
  }
249
 
 
 
 
 
 
250
  function updateMetrics(m) {
251
- document.getElementById('s-nodes').textContent = (m.nodes ?? window.ELYSIUM?.nodes.size ?? 1);
252
- document.getElementById('s-edges').textContent = (m.edges ?? window.ELYSIUM?.edges.length ?? 0);
253
- document.getElementById('s-council').textContent = (m.council_active ?? 0);
254
- document.getElementById('s-growth').textContent = (m.knowledge_growth ?? 0);
255
- document.getElementById('s-age').textContent = (m.civilization_age_min ?? 0);
256
- document.getElementById('m-density').textContent = (m.mycelium_density_pct ?? 0) + '%';
257
- document.getElementById('m-coherence').textContent = (m.coherence_pct ?? 70) + '%';
 
 
 
 
 
 
 
 
258
  }
259
 
260
  function updateLegend() {
 
 
261
  const counts = {};
262
- window.ELYSIUM.nodes.forEach(n => counts[n.type] = (counts[n.type] || 0) + 1);
 
 
 
263
  const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
264
  'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
265
  const seen = new Set();
266
  const parts = [];
267
- order.filter(t => counts[t] && !seen.has(t)).forEach(t => {
 
 
268
  seen.add(t);
269
- const c = window.colorFor(t);
270
  parts.push(`
271
  <div class="legend-item" data-type="${t}">
272
  <span class="dot" style="background:${c};color:${c}"></span>
@@ -274,42 +428,46 @@
274
  <span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
275
  </div>`);
276
  });
277
- // any types not in canonical order, append
278
  Object.keys(counts).forEach(t => {
279
  if (seen.has(t)) return;
280
- const c = window.colorFor(t);
281
- parts.push(`<div class="legend-item" data-type="${t}">
 
282
  <span class="dot" style="background:${c};color:${c}"></span>
283
  <span class="badge">${counts[t]}</span>
284
  <span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
285
  </div>`);
286
  });
287
- document.getElementById('legend-items').innerHTML = parts.join('');
288
- // wire legend filter clicks
289
- document.querySelectorAll('#legend-items .legend-item').forEach(el => {
290
  el.onclick = () => {
291
  const t = el.dataset.type;
292
- window.elysiumFilterType?.(t);
293
- // visual feedback
294
- document.querySelectorAll('#legend-items .legend-item').forEach(x =>
295
- x.style.opacity = window.ELYSIUM.filterType
296
- ? (x.dataset.type === window.ELYSIUM.filterType ? '1' : '.45')
297
- : '1');
 
298
  };
299
  });
300
  }
301
 
302
- // RI Analysis = fit-to-view + summary toast
303
- document.getElementById('ri-analysis').onclick = () => {
304
- window.elysiumFitAll();
305
- const n = window.ELYSIUM.nodes.size;
306
- const e = window.ELYSIUM.edges.length;
307
- window.toast(`🔍 Civilization snapshot: ${n} nodes · ${e} threads`, 'info');
308
- };
309
-
310
- function escapeHtml(s) {
311
- return String(s || '').replace(/[&<>"']/g, c => ({
312
- '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
313
- }[c]));
314
  }
 
 
 
 
315
  })();
 
1
+ /* ============================================================
2
+ ELYSIUM — boot.js
3
+ ------------------------------------------------------------
4
+ Wires UI /api/turn. Critical guarantees:
5
+
6
+ The JSON envelope returned by the model is NEVER rendered
7
+ as raw text anywhere on screen. It is parsed and ROUTED to:
8
+ hypergraph_delta canvas nodes / edges
9
+ council_deliberation council overlay + TTS audio
10
+ ui_directives → bioluminescence pulses, focus, alert
11
+ direct_answer → optional small toast
12
+ metrics → bottom stats bar + legend densities
13
+
14
+ • File uploads are previewed above the textbox with X removal
15
+ and only sent when the user presses Send.
16
+
17
+ • Every DOM lookup is defensive — a single missing element
18
+ never throws an unhandled "Cannot read properties of null".
19
+ This was the root cause of the previous "classList of null"
20
+ toast that appeared on top.
21
+ ============================================================ */
22
  (() => {
23
+ 'use strict';
24
+
25
+ // ── Safe element accessor (logs once if missing) ──
26
+ const _missing = new Set();
27
+ const $ = (id) => {
28
+ const el = document.getElementById(id);
29
+ if (!el && !_missing.has(id)) {
30
+ _missing.add(id);
31
+ console.warn('[elysium] missing element:', id);
32
+ }
33
+ return el;
34
+ };
35
+
36
+ // Safe wrappers — no-op if element missing
37
+ const addCls = (el, c) => { if (el && el.classList) el.classList.add(c); };
38
+ const rmCls = (el, c) => { if (el && el.classList) el.classList.remove(c); };
39
+
40
+ // ── Toast helper (always returns; never throws) ──
41
  window.toast = function (msg, kind = '') {
42
+ const host = $('toasts');
43
+ if (!host) { console.log('[toast]', msg); return; }
44
  const t = document.createElement('div');
45
+ t.className = 'toast ' + (kind || '');
46
+ t.textContent = String(msg).slice(0, 280);
47
+ host.appendChild(t);
48
+ setTimeout(() => { try { t.remove(); } catch {} }, 5200);
49
  };
50
 
51
+ function escapeHtml(s) {
52
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
53
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
54
+ }[c]));
55
+ }
56
+
57
+ // ── DOM references (all may be null — handled defensively) ──
58
+ const input = $('q-input');
59
+ const send = $('q-send');
60
+ const fileEl = $('q-file');
61
+ const upBtn = $('q-upload');
62
+ const strip = $('attach-strip');
63
+ const seedHint = $('seed-hint');
64
+
65
+ /* =========================================================
66
+ PART A — FILE ATTACHMENTS (preview above textbox, max 2)
67
+ ========================================================= */
68
  const MAX_FILES = 2;
69
+ const MAX_BYTES = 12 * 1024 * 1024;
70
+ let attachments = []; // [{file, name, kind:'image'|'pdf', previewUrl}]
71
 
72
  function renderStrip() {
73
+ if (!strip) return;
74
  if (attachments.length === 0) {
75
+ addCls(strip, 'hidden');
76
  strip.innerHTML = '';
77
  return;
78
  }
79
+ rmCls(strip, 'hidden');
80
+ const hint = attachments.length < MAX_FILES
81
  ? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
82
  : `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
83
  strip.innerHTML = attachments.map((a, i) => {
 
86
  : `<div class="pdf-ico">PDF</div>`;
87
  const sizeKb = (a.file.size / 1024).toFixed(0);
88
  return `
89
+ <div class="preview-tile" data-i="${i}">
90
  ${preview}
91
  <span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
92
+ <button class="x" data-i="${i}" title="Remove attachment" aria-label="Remove">×</button>
93
  </div>`;
94
+ }).join('') + hint;
95
+
96
+ // Wire remove buttons
97
  strip.querySelectorAll('.x').forEach(b => {
98
+ b.onclick = (ev) => {
99
+ ev.preventDefault();
100
+ ev.stopPropagation();
101
  const i = +b.dataset.i;
102
  const removed = attachments.splice(i, 1)[0];
103
+ if (removed && removed.previewUrl) {
104
+ try { URL.revokeObjectURL(removed.previewUrl); } catch {}
105
+ }
106
  renderStrip();
107
  };
108
  });
109
  }
110
 
111
  function addFiles(files) {
112
+ if (!files) return;
113
  const list = Array.from(files);
114
  for (const f of list) {
115
  if (attachments.length >= MAX_FILES) {
 
120
  const isImg = mime.startsWith('image/');
121
  const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
122
  if (!isImg && !isPdf) {
123
+ window.toast(`Unsupported: ${f.name} (only images & PDFs)`, 'warn');
124
  continue;
125
  }
126
+ if (f.size > MAX_BYTES) {
127
  window.toast(`${f.name} is too large (>12MB)`, 'warn');
128
  continue;
129
  }
 
135
  });
136
  }
137
  renderStrip();
138
+ if (fileEl) fileEl.value = ''; // allow re-selecting same file after removal
 
139
  }
140
 
141
+ if (fileEl) {
142
+ fileEl.addEventListener('change', e => addFiles(e.target.files));
143
+ }
144
 
145
+ // Optional polish: drag-and-drop files anywhere on the window
146
  ['dragenter', 'dragover'].forEach(ev =>
147
  window.addEventListener(ev, e => { e.preventDefault(); }));
148
  window.addEventListener('drop', e => {
149
+ if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
150
  e.preventDefault();
151
  addFiles(e.dataTransfer.files);
152
  });
153
 
154
+ /* =========================================================
155
+ PART B — BUSY LOCK (disables input while model thinks)
156
+ ========================================================= */
157
  function setBusy(b) {
158
  document.body.dataset.busy = b ? '1' : '0';
159
+ if (input) input.disabled = b;
160
+ if (send) send.disabled = b;
161
  if (b) {
162
+ if (send) send.classList.add('loading');
163
+ if (upBtn) upBtn.classList.add('disabled');
164
+ if (fileEl) fileEl.disabled = true;
165
+ if (input) {
166
+ input.dataset.prev = input.placeholder || '';
167
+ input.placeholder = 'Council deliberating…';
168
+ }
169
  } else {
170
+ if (send) send.classList.remove('loading');
171
+ if (upBtn) upBtn.classList.remove('disabled');
172
+ if (fileEl) fileEl.disabled = false;
173
+ if (input) {
174
+ input.placeholder = input.dataset.prev || 'Speak to your civilization seed…';
175
+ }
176
  }
177
  }
178
 
179
+ /* =========================================================
180
+ PART C — RESTORE existing civilization on page load
181
+ ========================================================= */
182
  async function restore() {
183
  try {
184
  const h = await ElysiumAPI.hypergraph();
185
  (h.nodes || []).forEach(n => {
186
+ if (n.node_id !== 'CORE' && typeof window.elysiumAddNode === 'function') {
187
+ window.elysiumAddNode(n);
188
+ }
189
+ });
190
+ (h.edges || []).forEach(e => {
191
+ if (typeof window.elysiumAddEdge === 'function') window.elysiumAddEdge(e);
192
  });
193
+ if ((h.nodes || []).length > 1) addCls(seedHint, 'hidden');
 
194
  updateLegend();
195
  updateMetrics({
196
+ nodes: h.node_count || (h.nodes || []).length || 1,
197
+ edges: h.edge_count || (h.edges || []).length || 0,
198
+ council_active: 0,
199
+ knowledge_growth: 0,
200
  civilization_age_min: 0,
201
+ mycelium_density_pct: h.node_count
202
+ ? Math.round(Math.min(1, (h.edge_count || 0) / Math.max(1, h.node_count * 1.4)) * 100)
203
+ : 0,
204
  coherence_pct: 70,
205
  });
206
  } catch (e) {
207
+ console.warn('[restore] failed', e);
208
+ updateLegend();
209
  }
210
  }
211
  restore();
212
 
213
+ /* =========================================================
214
+ PART D — SUBMIT a turn
215
+ ========================================================= */
216
  async function submit() {
217
  if (document.body.dataset.busy === '1') return;
218
+ const text = input ? input.value.trim() : '';
219
  if (!text && attachments.length === 0) return;
220
 
221
  setBusy(true);
222
+ addCls(seedHint, 'hidden');
223
+ if (input) input.value = '';
224
 
225
  const files = attachments.map(a => a.file);
226
+ // Clear preview strip (response will show separate "Analyzed:" toast)
227
+ attachments.forEach(a => {
228
+ if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl); } catch {} }
229
+ });
230
  attachments = [];
231
  renderStrip();
232
 
233
  try {
234
  const data = await ElysiumAPI.turn(text, files);
235
+ handleResponse(data || {});
236
  } catch (e) {
237
+ console.error('[submit] failed', e);
238
+ window.toast('Inference failed: ' + (e.message || e), 'error');
239
  } finally {
240
  setBusy(false);
241
  }
242
  }
243
 
244
+ if (send) send.onclick = submit;
245
+ if (input) {
246
+ input.addEventListener('keydown', e => {
247
+ if (e.key === 'Enter' && !e.shiftKey) {
248
+ e.preventDefault();
249
+ submit();
250
+ }
251
+ });
252
+ }
253
 
254
+ /* =========================================================
255
+ PART E HANDLE RESPONSE
256
+ Parses the JSON envelope and routes it. NEVER renders raw
257
+ JSON to the canvas or any visible element.
258
+ ========================================================= */
259
  function handleResponse(payload) {
260
+ // payload schema: { user_msg, elysium_response, _runtime }
261
+ let resp = {};
262
+ let rt = {};
263
+ try {
264
+ resp = payload.elysium_response || {};
265
+ rt = payload._runtime || {};
266
+ } catch (e) {
267
+ console.error('[handleResponse] malformed payload', e);
268
+ window.toast('Malformed response from model', 'error');
269
+ return;
270
+ }
271
 
272
+ // 0. Attachment errors (server-side validation failures) → toast
273
+ safeArr(rt.attachment_errors).forEach(e =>
274
  window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
275
 
276
+ // 1. Hypergraph delta → canvas (NODES then EDGES; build parent map)
277
  const delta = resp.hypergraph_delta || {};
278
+ const edgesAdded = safeArr(delta.edges_added);
279
+ const nodesAdded = safeArr(delta.nodes_added);
280
+
281
+ // Build a parent-hint map: for each new node, what's its closest source
282
+ // (used so spawn animation grows OUT FROM the parent, not the centre).
283
+ const parentHint = {};
284
+ edgesAdded.forEach(e => {
285
+ if (!e) return;
286
+ if (e.target_node_id && !parentHint[e.target_node_id]) {
287
+ parentHint[e.target_node_id] = e.source_node_id;
288
+ }
289
+ if (e.source_node_id && !parentHint[e.source_node_id]) {
290
+ // weaker hint (reverse direction) — only if not set
291
+ if (!parentHint[e.source_node_id]) {
292
+ parentHint[e.source_node_id] = e.target_node_id;
293
+ }
294
+ }
295
+ });
296
+
297
+ nodesAdded.forEach(n => {
298
+ if (!n || !n.node_id) return;
299
+ try {
300
+ if (typeof window.elysiumAddNode === 'function') {
301
+ window.elysiumAddNode(n, parentHint[n.node_id]);
302
+ }
303
+ } catch (err) {
304
+ console.warn('[handleResponse] addNode failed', n, err);
305
  }
 
306
  });
 
307
 
308
+ edgesAdded.forEach(e => {
309
+ if (!e || !e.source_node_id || !e.target_node_id) return;
310
+ try {
311
+ if (typeof window.elysiumAddEdge === 'function') window.elysiumAddEdge(e);
312
+ } catch (err) {
313
+ console.warn('[handleResponse] addEdge failed', e, err);
314
+ }
315
+ });
316
+
317
+ // Apply node updates (fields_changed)
318
+ safeArr(delta.nodes_updated).forEach(u => {
319
+ if (!u || !u.node_id) return;
320
+ const node = window.elysiumGetNode && window.elysiumGetNode(u.node_id);
321
+ if (node && u.fields_changed) {
322
+ Object.assign(node.payload || (node.payload = {}), u.fields_changed);
323
+ }
324
+ });
325
+
326
+ // 2. UI directives — pulses, focus, alert
327
  const ui = resp.ui_directives || {};
328
+ safeArr(ui.bioluminescence_pulse_nodes).forEach(id => {
329
+ if (typeof window.elysiumPulse === 'function') window.elysiumPulse(id, 1600);
330
+ });
331
  document.body.dataset.alert = ui.alert_level || 'CALM';
332
+ if (ui.camera_focus_node_id && typeof window.elysiumFocus === 'function') {
333
+ if (typeof window.elysiumPulse === 'function')
334
+ window.elysiumPulse(ui.camera_focus_node_id, 1800);
335
+ setTimeout(() => {
336
+ try { window.elysiumFocus(ui.camera_focus_node_id); } catch {}
337
+ }, 380);
338
  }
339
 
340
  // 3. Council overlay + TTS
341
+ try {
342
+ if (typeof window.renderCouncil === 'function') {
343
+ window.renderCouncil(resp, rt);
344
+ }
345
+ } catch (e) {
346
+ console.error('[council] render failed', e);
347
+ }
348
 
349
+ // 4. Metrics bar (REAL civilization metrics)
350
  updateMetrics(rt.metrics || {});
351
 
352
  // 5. Agent count badge
353
+ const ag = safeArr(resp.council_deliberation && resp.council_deliberation.agent_outputs).length;
354
+ const badge = $('agent-count');
355
+ if (badge) badge.textContent = `+${ag}`;
356
 
357
+ // 6. Legend live update (counts per type)
358
  updateLegend();
359
 
360
  // 7. Tool toasts
361
+ safeArr(rt.tool_results).forEach(tr => {
362
+ if (!tr) return;
363
  const ok = tr.result && !tr.result.error;
364
+ window.toast(
365
+ `🔧 ${tr.tool_name || 'tool'}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
366
+ ok ? 'info' : 'warn'
367
+ );
368
  });
369
 
370
+ // 8. Direct answer toast ONLY when no council (otherwise it's already
371
+ // shown in the overlay synthesis). NEVER dumps raw JSON.
372
+ if (!ag && resp.direct_answer && typeof resp.direct_answer === 'string') {
373
+ window.toast(resp.direct_answer.slice(0, 240));
374
  }
375
 
376
+ // 9. Confirm attachment analysis
377
+ const processed = safeArr(rt.attachments_processed);
378
+ if (processed.length) {
379
+ const names = processed.map(a => a && a.name).filter(Boolean).join(', ');
380
+ if (names) window.toast(`📎 Analyzed: ${names}`, 'info');
381
  }
382
  }
383
 
384
+ function safeArr(x) { return Array.isArray(x) ? x : []; }
385
+
386
+ /* =========================================================
387
+ PART F — Metrics & Legend updates
388
+ ========================================================= */
389
  function updateMetrics(m) {
390
+ m = m || {};
391
+ const nodes = m.nodes ?? (window.ELYSIUM ? window.ELYSIUM.nodes.size : 1);
392
+ const edges = m.edges ?? (window.ELYSIUM ? window.ELYSIUM.edges.length : 0);
393
+ setText('s-nodes', nodes);
394
+ setText('s-edges', edges);
395
+ setText('s-council', m.council_active ?? 0);
396
+ setText('s-growth', m.knowledge_growth ?? 0);
397
+ setText('s-age', m.civilization_age_min ?? 0);
398
+ setText('m-density', (m.mycelium_density_pct ?? 0) + '%');
399
+ setText('m-coherence', (m.coherence_pct ?? 70) + '%');
400
+ }
401
+
402
+ function setText(id, value) {
403
+ const el = $(id);
404
+ if (el) el.textContent = value;
405
  }
406
 
407
  function updateLegend() {
408
+ const host = $('legend-items');
409
+ if (!host || !window.ELYSIUM) return;
410
  const counts = {};
411
+ window.ELYSIUM.nodes.forEach(n => {
412
+ const t = n.type || 'CONCEPT';
413
+ counts[t] = (counts[t] || 0) + 1;
414
+ });
415
  const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
416
  'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
417
  const seen = new Set();
418
  const parts = [];
419
+ const colorFn = window.colorFor || (() => '#a76bff');
420
+ order.forEach(t => {
421
+ if (!counts[t]) return;
422
  seen.add(t);
423
+ const c = colorFn(t);
424
  parts.push(`
425
  <div class="legend-item" data-type="${t}">
426
  <span class="dot" style="background:${c};color:${c}"></span>
 
428
  <span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
429
  </div>`);
430
  });
 
431
  Object.keys(counts).forEach(t => {
432
  if (seen.has(t)) return;
433
+ const c = colorFn(t);
434
+ parts.push(`
435
+ <div class="legend-item" data-type="${t}">
436
  <span class="dot" style="background:${c};color:${c}"></span>
437
  <span class="badge">${counts[t]}</span>
438
  <span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
439
  </div>`);
440
  });
441
+ host.innerHTML = parts.join('');
442
+ // Wire legend filter clicks
443
+ host.querySelectorAll('.legend-item').forEach(el => {
444
  el.onclick = () => {
445
  const t = el.dataset.type;
446
+ if (typeof window.elysiumFilterType === 'function') window.elysiumFilterType(t);
447
+ const active = window.ELYSIUM && window.ELYSIUM.filterType;
448
+ host.querySelectorAll('.legend-item').forEach(x => {
449
+ x.style.opacity = active
450
+ ? (x.dataset.type === active ? '1' : '.45')
451
+ : '1';
452
+ });
453
  };
454
  });
455
  }
456
 
457
+ /* =========================================================
458
+ PART G — RI Analysis button (fit + summary)
459
+ ========================================================= */
460
+ const ri = $('ri-analysis');
461
+ if (ri) {
462
+ ri.onclick = () => {
463
+ if (typeof window.elysiumFitAll === 'function') window.elysiumFitAll();
464
+ const n = window.ELYSIUM ? window.ELYSIUM.nodes.size : 0;
465
+ const e = window.ELYSIUM ? window.ELYSIUM.edges.length : 0;
466
+ window.toast(`🔍 Civilization snapshot: ${n} nodes · ${e} threads`, 'info');
467
+ };
 
468
  }
469
+
470
+ // Expose for debugging (read-only-ish)
471
+ window.elysiumUpdateLegend = updateLegend;
472
+ window.elysiumUpdateMetrics = updateMetrics;
473
  })();
frontend/dist/assets/canvas.js CHANGED
@@ -1,7 +1,22 @@
1
- /* Google-Maps-style infinite-pan canvas with bioluminescent hypergraph.
2
- Handles: pan / zoom / pinch / inertia / minimap drag / click-to-detail. */
 
 
 
 
 
 
 
 
 
 
 
 
3
  (() => {
 
 
4
  const canvas = document.getElementById('elysium-canvas');
 
5
  const ctx = canvas.getContext('2d', { alpha: false });
6
 
7
  const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
@@ -21,16 +36,17 @@
21
  edges: [],
22
  hover: null,
23
  selected: null,
24
- filterType: null, // legend click filters
25
  };
26
 
27
- // Seed CORE
28
  E.nodes.set('CORE', {
29
  node_id: 'CORE', x: 0, y: 0,
30
  type: 'CORE', label: 'ELYSIUM',
31
  radius: 36, color: '#ffb840',
32
  phase: 0, born: performance.now() - 1000,
33
  payload: { description: 'The seed of your civilization. Ask anything to grow new nodes.' },
 
34
  });
35
 
36
  // ──────── COORDS ────────
@@ -46,19 +62,28 @@
46
  let best = null, bestD = Infinity;
47
  E.nodes.forEach(n => {
48
  const d = Math.hypot(n.x - w.x, n.y - w.y);
49
- if (d < n.radius * 1.25 && d < bestD) { best = n; bestD = d; }
 
 
 
50
  });
51
  return best;
52
  }
53
 
54
  // ──────── PAN / TAP ────────
55
- let lastX = 0, lastY = 0, downX = 0, downY = 0, downT = 0;
 
 
 
 
56
  canvas.addEventListener('pointerdown', e => {
57
  downX = e.clientX; downY = e.clientY; downT = performance.now();
 
 
58
  E.cam.drag = true;
59
  canvas.classList.add('dragging');
60
  lastX = e.clientX; lastY = e.clientY;
61
- canvas.setPointerCapture(e.pointerId);
62
  });
63
 
64
  canvas.addEventListener('pointermove', e => {
@@ -68,8 +93,11 @@
68
  canvas.style.cursor = n ? 'pointer' : '';
69
  return;
70
  }
71
- const dx = (e.clientX - lastX) / E.cam.z;
72
- const dy = (e.clientY - lastY) / E.cam.z;
 
 
 
73
  E.cam.x -= dx; E.cam.y -= dy;
74
  E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
75
  E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
@@ -77,25 +105,31 @@
77
  });
78
 
79
  function endPan(e) {
80
- const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
81
- const dt = performance.now() - downT;
82
  E.cam.drag = false;
83
  canvas.classList.remove('dragging');
84
  try { canvas.releasePointerCapture(e.pointerId); } catch {}
85
- // Treat as click if minimal movement + short duration
86
- if (moved < 6 && dt < 350) {
87
- const n = hitTest(e.clientX, e.clientY);
88
- if (n) {
89
- if (window.showNodeDetail) window.showNodeDetail(n, e.clientX, e.clientY);
 
 
 
 
 
 
90
  } else {
91
- if (window.hideNodeDetail) window.hideNodeDetail();
92
  }
93
  }
 
94
  }
95
  canvas.addEventListener('pointerup', endPan);
96
  canvas.addEventListener('pointercancel', endPan);
97
 
98
- // wheel zoom (zoom to cursor)
99
  canvas.addEventListener('wheel', e => {
100
  e.preventDefault();
101
  const factor = e.deltaY > 0 ? 0.9 : 1.1;
@@ -111,7 +145,7 @@
111
  E.cam.tx = w.x; E.cam.ty = w.y;
112
  });
113
 
114
- // ──────── PINCH ZOOM ────────
115
  let touchDist = 0, touchMid = null;
116
  canvas.addEventListener('touchstart', e => {
117
  if (e.touches.length === 2) {
@@ -135,10 +169,11 @@
135
  }, { passive: false });
136
  canvas.addEventListener('touchend', () => { touchDist = 0; });
137
 
138
- // ──────── ZOOM BTNS ────────
139
- document.getElementById('z-in').onclick = () => E.cam.tz = Math.min(4, E.cam.z * 1.35);
140
- document.getElementById('z-out').onclick = () => E.cam.tz = Math.max(0.15, E.cam.z / 1.35);
141
- document.getElementById('z-fit').onclick = fitAll;
 
142
 
143
  function fitAll() {
144
  if (E.nodes.size === 0) return;
@@ -147,7 +182,7 @@
147
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
148
  mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
149
  });
150
- const pad = 220;
151
  E.cam.tx = (mnX + mxX) / 2;
152
  E.cam.ty = (mnY + mxY) / 2;
153
  if (mxX - mnX < 1 && mxY - mnY < 1) {
@@ -160,16 +195,17 @@
160
  }
161
  window.elysiumFitAll = fitAll;
162
 
163
- // ──────── MINIMAP (interactive) ────────
164
  const mini = document.getElementById('minimap');
165
- const mctx = mini.getContext('2d');
166
 
167
- function computeBounds(pad = 100) {
168
  let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
169
  E.nodes.forEach(n => {
170
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
171
  mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
172
  });
 
173
  if (mxX - mnX < 100) { mnX -= 200; mxX += 200; }
174
  if (mxY - mnY < 100) { mnY -= 200; mxY += 200; }
175
  return { mnX: mnX - pad, mnY: mnY - pad,
@@ -177,26 +213,31 @@
177
  }
178
 
179
  function drawMinimap() {
180
- const w = mini.clientWidth, h = mini.clientHeight;
 
 
181
  if (mini.width !== w * DPR || mini.height !== h * DPR) {
182
  mini.width = w * DPR; mini.height = h * DPR;
183
  }
184
  mctx.save();
185
  mctx.scale(DPR, DPR);
186
- // bg
187
  mctx.fillStyle = 'rgba(2,16,22,1)';
188
  mctx.fillRect(0, 0, w, h);
189
- // grid
 
190
  mctx.strokeStyle = 'rgba(0,229,200,.08)';
191
  mctx.lineWidth = .5;
192
- for (let i = 0; i < w; i += 12) { mctx.beginPath(); mctx.moveTo(i, 0); mctx.lineTo(i, h); mctx.stroke(); }
193
- for (let j = 0; j < h; j += 12) { mctx.beginPath(); mctx.moveTo(0, j); mctx.lineTo(w, j); mctx.stroke(); }
 
 
 
 
194
 
195
- if (E.nodes.size === 0) { mctx.restore(); return; }
196
  const { mnX, mnY, mxX, mxY } = computeBounds();
197
  const sx = w / (mxX - mnX), sy = h / (mxY - mnY);
198
 
199
- // edges
200
  mctx.lineWidth = .6;
201
  E.edges.forEach(ed => {
202
  const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
@@ -208,62 +249,93 @@
208
  mctx.stroke();
209
  });
210
 
211
- // nodes
212
  E.nodes.forEach(n => {
213
  const px = (n.x - mnX) * sx, py = (n.y - mnY) * sy;
214
  mctx.shadowColor = n.color;
215
  mctx.shadowBlur = 6;
216
  mctx.fillStyle = n.color;
 
 
217
  mctx.beginPath();
218
- mctx.arc(px, py, n.type === 'CORE' ? 3.5 : 2.2, 0, Math.PI * 2);
219
  mctx.fill();
220
  });
221
  mctx.shadowBlur = 0;
222
 
223
- // viewport rect
224
  const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
225
  const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
226
  const vw = innerWidth / E.cam.z * sx;
227
  const vh = innerHeight / E.cam.z * sy;
228
- mctx.strokeStyle = 'rgba(25,214,255,.9)';
229
  mctx.lineWidth = 1.2;
230
  mctx.setLineDash([3, 3]);
231
  mctx.strokeRect(vx, vy, vw, vh);
232
  mctx.setLineDash([]);
 
 
 
 
 
 
 
 
233
  mctx.restore();
234
  }
235
 
236
- // minimap drag / click to pan
237
- let miniDrag = false;
238
- function miniPan(e) {
239
- const rect = mini.getBoundingClientRect();
240
- const fx = (e.clientX - rect.left) / rect.width;
241
- const fy = (e.clientY - rect.top) / rect.height;
242
- const { mnX, mnY, mxX, mxY } = computeBounds();
243
- E.cam.tx = mnX + fx * (mxX - mnX);
244
- E.cam.ty = mnY + fy * (mxY - mnY);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  }
246
- mini.addEventListener('pointerdown', e => { miniDrag = true; miniPan(e); mini.setPointerCapture(e.pointerId); });
247
- mini.addEventListener('pointermove', e => { if (miniDrag) miniPan(e); });
248
- mini.addEventListener('pointerup', e => { miniDrag = false; try { mini.releasePointerCapture(e.pointerId); } catch{} });
249
- mini.addEventListener('pointercancel', () => { miniDrag = false; });
250
 
251
- // ──────── PUBLIC API ────────
252
  const SPAWN_RADIUS = 280;
253
 
254
  window.elysiumAddNode = function (node, parentHint) {
255
- if (E.nodes.has(node.node_id)) return E.nodes.get(node.node_id);
 
 
 
 
 
 
 
 
256
  const parentId = parentHint || 'CORE';
257
  const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
258
  const idx = E.nodes.size;
259
- // golden-angle spiral around parent for organic feel
260
  const golden = 2.39996;
261
  const angle = (idx * golden) % (Math.PI * 2);
262
  const dist = SPAWN_RADIUS + ((idx * 17) % 220);
263
  const tx = parent.x + Math.cos(angle) * dist;
264
  const ty = parent.y + Math.sin(angle) * dist;
265
  const type = node.node_type || node.type || 'DOMAIN';
266
- const radius = type === 'CORE' || type === 'CIVILIZATION' ? 36
267
  : type === 'AGENT' ? 22
268
  : type === 'TOOL' ? 14
269
  : 18;
@@ -273,21 +345,21 @@
273
  type,
274
  label: node.label || node.node_id,
275
  radius,
276
- color: window.colorFor(type),
277
  phase: Math.random() * Math.PI * 2,
278
  born: performance.now(),
279
  payload: node.payload || {},
280
  embedding_hint: node.embedding_hint || '',
281
  };
282
  E.nodes.set(node.node_id, n);
283
- // gentle auto-pan toward new node
284
  E.cam.tx = E.cam.x * 0.86 + tx * 0.14;
285
  E.cam.ty = E.cam.y * 0.86 + ty * 0.14;
286
  return n;
287
  };
288
 
289
  window.elysiumAddEdge = function (edge) {
290
- // de-dupe
291
  if (E.edges.some(e =>
292
  e.src === edge.source_node_id &&
293
  e.dst === edge.target_node_id &&
@@ -295,12 +367,12 @@
295
  E.edges.push({
296
  src: edge.source_node_id,
297
  dst: edge.target_node_id,
298
- type: edge.edge_type,
299
  weight: edge.weight ?? 0.5,
300
- color: edge.edge_type === 'CONFLICT' ? 'rgba(255,84,105,.6)' :
301
  edge.edge_type === 'COALITION' ? 'rgba(167,107,255,.65)' :
302
- edge.edge_type === 'CAUSAL' ? 'rgba(25,214,255,.55)' :
303
- edge.edge_type === 'SUPPORTS' ? 'rgba(92,255,174,.55)' :
304
  'rgba(255,184,64,.55)',
305
  born: performance.now(),
306
  });
@@ -326,16 +398,15 @@
326
 
327
  window.elysiumGetNode = (id) => E.nodes.get(id);
328
 
329
- // legend filter
330
  window.elysiumFilterType = function (type) {
331
  E.filterType = (E.filterType === type) ? null : type;
332
  };
333
 
334
- // ──────── RENDER LOOP ────────
335
  function lerp(a, b, t) { return a + (b - a) * t; }
336
 
337
  function loop(t) {
338
- // animate spawn-to-target
339
  E.nodes.forEach(n => {
340
  if (n.tx != null) {
341
  n.x = lerp(n.x, n.tx, 0.12);
@@ -346,7 +417,7 @@
346
  }
347
  });
348
 
349
- // smooth camera
350
  E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
351
  if (!E.cam.drag) {
352
  E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
@@ -357,7 +428,7 @@
357
  if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
358
  }
359
 
360
- // background
361
  ctx.fillStyle = '#02080c';
362
  ctx.fillRect(0, 0, canvas.width, canvas.height);
363
  const grad = ctx.createRadialGradient(
@@ -370,14 +441,14 @@
370
 
371
  drawAmbient(ctx, t);
372
 
373
- // world transform
374
  ctx.save();
375
  ctx.scale(DPR, DPR);
376
  ctx.translate(innerWidth / 2, innerHeight / 2);
377
  ctx.scale(E.cam.z, E.cam.z);
378
  ctx.translate(-E.cam.x, -E.cam.y);
379
 
380
- // edges (under nodes)
381
  E.edges.forEach(e => {
382
  const s = E.nodes.get(e.src), d = E.nodes.get(e.dst);
383
  if (!s || !d) return;
@@ -402,11 +473,12 @@
402
  });
403
  ctx.globalAlpha = 1;
404
 
405
- // nodes
406
  E.nodes.forEach(n => {
407
  const dim = E.filterType && n.type !== E.filterType ? 0.22 : 1;
408
  ctx.globalAlpha = dim;
409
- window.drawNode(ctx, n, t, E.cam.z);
 
410
  });
411
  ctx.globalAlpha = 1;
412
  ctx.restore();
@@ -415,7 +487,7 @@
415
  requestAnimationFrame(loop);
416
  }
417
 
418
- // ambient particles
419
  const PARTICLES = Array.from({ length: 60 }, () => ({
420
  x: Math.random() * innerWidth,
421
  y: Math.random() * innerHeight,
 
1
+ /* ============================================================
2
+ ELYSIUM canvas.js
3
+ Google-Maps-style infinite pan/zoom canvas with bioluminescent
4
+ hypergraph rendering, particles, and a synced minimap.
5
+
6
+ Critical behaviours fixed in this version:
7
+ • Click vs pan is detected with BOTH movement-distance AND
8
+ elapsed-time thresholds AND a final hit-test on the up
9
+ location (so even a slight finger jitter still opens
10
+ the node detail).
11
+ • The minimap is fully interactive (click + drag to pan).
12
+ • Every public function is wrapped to be no-op if called
13
+ before the canvas exists.
14
+ ============================================================ */
15
  (() => {
16
+ 'use strict';
17
+
18
  const canvas = document.getElementById('elysium-canvas');
19
+ if (!canvas) { console.warn('[canvas] #elysium-canvas missing'); return; }
20
  const ctx = canvas.getContext('2d', { alpha: false });
21
 
22
  const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
 
36
  edges: [],
37
  hover: null,
38
  selected: null,
39
+ filterType: null,
40
  };
41
 
42
+ // Seed CORE (always present)
43
  E.nodes.set('CORE', {
44
  node_id: 'CORE', x: 0, y: 0,
45
  type: 'CORE', label: 'ELYSIUM',
46
  radius: 36, color: '#ffb840',
47
  phase: 0, born: performance.now() - 1000,
48
  payload: { description: 'The seed of your civilization. Ask anything to grow new nodes.' },
49
+ embedding_hint: 'The civilization core. All knowledge branches out from here.',
50
  });
51
 
52
  // ──────── COORDS ────────
 
62
  let best = null, bestD = Infinity;
63
  E.nodes.forEach(n => {
64
  const d = Math.hypot(n.x - w.x, n.y - w.y);
65
+ // Use a generous hit radius at least 18 world-units to make
66
+ // small nodes tappable on touch devices.
67
+ const hitR = Math.max(18, n.radius * 1.35);
68
+ if (d < hitR && d < bestD) { best = n; bestD = d; }
69
  });
70
  return best;
71
  }
72
 
73
  // ──────── PAN / TAP ────────
74
+ let lastX = 0, lastY = 0;
75
+ let downX = 0, downY = 0, downT = 0;
76
+ let totalMove = 0;
77
+ let downOnNode = null;
78
+
79
  canvas.addEventListener('pointerdown', e => {
80
  downX = e.clientX; downY = e.clientY; downT = performance.now();
81
+ totalMove = 0;
82
+ downOnNode = hitTest(e.clientX, e.clientY);
83
  E.cam.drag = true;
84
  canvas.classList.add('dragging');
85
  lastX = e.clientX; lastY = e.clientY;
86
+ try { canvas.setPointerCapture(e.pointerId); } catch {}
87
  });
88
 
89
  canvas.addEventListener('pointermove', e => {
 
93
  canvas.style.cursor = n ? 'pointer' : '';
94
  return;
95
  }
96
+ const dxRaw = e.clientX - lastX;
97
+ const dyRaw = e.clientY - lastY;
98
+ totalMove += Math.hypot(dxRaw, dyRaw);
99
+ const dx = dxRaw / E.cam.z;
100
+ const dy = dyRaw / E.cam.z;
101
  E.cam.x -= dx; E.cam.y -= dy;
102
  E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
103
  E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
 
105
  });
106
 
107
  function endPan(e) {
108
+ const dt = performance.now() - downT;
 
109
  E.cam.drag = false;
110
  canvas.classList.remove('dragging');
111
  try { canvas.releasePointerCapture(e.pointerId); } catch {}
112
+
113
+ // Click detection: total movement < 8px AND duration < 500ms.
114
+ // Either down-or-up location may hit a node (handles touch jitter).
115
+ const moved = totalMove < 8 && Math.hypot(e.clientX - downX, e.clientY - downY) < 8;
116
+ if (moved && dt < 500) {
117
+ const upNode = hitTest(e.clientX, e.clientY);
118
+ const node = upNode || downOnNode;
119
+ if (node) {
120
+ if (typeof window.showNodeDetail === 'function') {
121
+ window.showNodeDetail(node, e.clientX, e.clientY);
122
+ }
123
  } else {
124
+ if (typeof window.hideNodeDetail === 'function') window.hideNodeDetail();
125
  }
126
  }
127
+ downOnNode = null;
128
  }
129
  canvas.addEventListener('pointerup', endPan);
130
  canvas.addEventListener('pointercancel', endPan);
131
 
132
+ // Wheel zoom (zoom to cursor)
133
  canvas.addEventListener('wheel', e => {
134
  e.preventDefault();
135
  const factor = e.deltaY > 0 ? 0.9 : 1.1;
 
145
  E.cam.tx = w.x; E.cam.ty = w.y;
146
  });
147
 
148
+ // Pinch zoom (touch)
149
  let touchDist = 0, touchMid = null;
150
  canvas.addEventListener('touchstart', e => {
151
  if (e.touches.length === 2) {
 
169
  }, { passive: false });
170
  canvas.addEventListener('touchend', () => { touchDist = 0; });
171
 
172
+ // Zoom buttons (defensive)
173
+ const _btn = (id, fn) => { const el = document.getElementById(id); if (el) el.onclick = fn; };
174
+ _btn('z-in', () => E.cam.tz = Math.min(4, E.cam.z * 1.35));
175
+ _btn('z-out', () => E.cam.tz = Math.max(0.15, E.cam.z / 1.35));
176
+ _btn('z-fit', () => fitAll());
177
 
178
  function fitAll() {
179
  if (E.nodes.size === 0) return;
 
182
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
183
  mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
184
  });
185
+ const pad = 240;
186
  E.cam.tx = (mnX + mxX) / 2;
187
  E.cam.ty = (mnY + mxY) / 2;
188
  if (mxX - mnX < 1 && mxY - mnY < 1) {
 
195
  }
196
  window.elysiumFitAll = fitAll;
197
 
198
+ /* ──────── MINIMAP (interactive — click & drag to pan) ──────── */
199
  const mini = document.getElementById('minimap');
200
+ const mctx = mini ? mini.getContext('2d') : null;
201
 
202
+ function computeBounds(pad = 140) {
203
  let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
204
  E.nodes.forEach(n => {
205
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
206
  mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
207
  });
208
+ if (!isFinite(mnX)) { mnX = -200; mnY = -200; mxX = 200; mxY = 200; }
209
  if (mxX - mnX < 100) { mnX -= 200; mxX += 200; }
210
  if (mxY - mnY < 100) { mnY -= 200; mxY += 200; }
211
  return { mnX: mnX - pad, mnY: mnY - pad,
 
213
  }
214
 
215
  function drawMinimap() {
216
+ if (!mini || !mctx) return;
217
+ const w = mini.clientWidth || 170;
218
+ const h = mini.clientHeight || 108;
219
  if (mini.width !== w * DPR || mini.height !== h * DPR) {
220
  mini.width = w * DPR; mini.height = h * DPR;
221
  }
222
  mctx.save();
223
  mctx.scale(DPR, DPR);
 
224
  mctx.fillStyle = 'rgba(2,16,22,1)';
225
  mctx.fillRect(0, 0, w, h);
226
+
227
+ // Grid
228
  mctx.strokeStyle = 'rgba(0,229,200,.08)';
229
  mctx.lineWidth = .5;
230
+ for (let i = 0; i < w; i += 12) {
231
+ mctx.beginPath(); mctx.moveTo(i, 0); mctx.lineTo(i, h); mctx.stroke();
232
+ }
233
+ for (let j = 0; j < h; j += 12) {
234
+ mctx.beginPath(); mctx.moveTo(0, j); mctx.lineTo(w, j); mctx.stroke();
235
+ }
236
 
 
237
  const { mnX, mnY, mxX, mxY } = computeBounds();
238
  const sx = w / (mxX - mnX), sy = h / (mxY - mnY);
239
 
240
+ // Edges (faint)
241
  mctx.lineWidth = .6;
242
  E.edges.forEach(ed => {
243
  const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
 
249
  mctx.stroke();
250
  });
251
 
252
+ // Nodes (glowing dots)
253
  E.nodes.forEach(n => {
254
  const px = (n.x - mnX) * sx, py = (n.y - mnY) * sy;
255
  mctx.shadowColor = n.color;
256
  mctx.shadowBlur = 6;
257
  mctx.fillStyle = n.color;
258
+ const r = (n.type === 'CORE' || n.type === 'CIVILIZATION') ? 3.5
259
+ : n.type === 'AGENT' ? 2.6 : 2.2;
260
  mctx.beginPath();
261
+ mctx.arc(px, py, r, 0, Math.PI * 2);
262
  mctx.fill();
263
  });
264
  mctx.shadowBlur = 0;
265
 
266
+ // Viewport rect
267
  const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
268
  const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
269
  const vw = innerWidth / E.cam.z * sx;
270
  const vh = innerHeight / E.cam.z * sy;
271
+ mctx.strokeStyle = 'rgba(25,214,255,.95)';
272
  mctx.lineWidth = 1.2;
273
  mctx.setLineDash([3, 3]);
274
  mctx.strokeRect(vx, vy, vw, vh);
275
  mctx.setLineDash([]);
276
+
277
+ // Selected node marker on minimap
278
+ if (E.selected) {
279
+ const px = (E.selected.x - mnX) * sx, py = (E.selected.y - mnY) * sy;
280
+ mctx.strokeStyle = '#19d6ff';
281
+ mctx.lineWidth = 1.5;
282
+ mctx.beginPath(); mctx.arc(px, py, 5, 0, Math.PI * 2); mctx.stroke();
283
+ }
284
  mctx.restore();
285
  }
286
 
287
+ // Minimap drag / click pan
288
+ if (mini) {
289
+ let miniDrag = false;
290
+ const miniPan = (e) => {
291
+ const rect = mini.getBoundingClientRect();
292
+ const fx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
293
+ const fy = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
294
+ const { mnX, mnY, mxX, mxY } = computeBounds();
295
+ E.cam.tx = mnX + fx * (mxX - mnX);
296
+ E.cam.ty = mnY + fy * (mxY - mnY);
297
+ };
298
+ mini.addEventListener('pointerdown', e => {
299
+ miniDrag = true; miniPan(e);
300
+ try { mini.setPointerCapture(e.pointerId); } catch {}
301
+ });
302
+ mini.addEventListener('pointermove', e => { if (miniDrag) miniPan(e); });
303
+ mini.addEventListener('pointerup', e => {
304
+ miniDrag = false;
305
+ try { mini.releasePointerCapture(e.pointerId); } catch {}
306
+ });
307
+ mini.addEventListener('pointercancel', () => { miniDrag = false; });
308
+ // Mouse wheel on minimap → zoom canvas around centre
309
+ mini.addEventListener('wheel', e => {
310
+ e.preventDefault();
311
+ const factor = e.deltaY > 0 ? 0.9 : 1.1;
312
+ E.cam.tz = Math.max(0.15, Math.min(4, E.cam.z * factor));
313
+ }, { passive: false });
314
  }
 
 
 
 
315
 
316
+ /* ──────── PUBLIC API ──────── */
317
  const SPAWN_RADIUS = 280;
318
 
319
  window.elysiumAddNode = function (node, parentHint) {
320
+ if (!node || !node.node_id) return null;
321
+ if (E.nodes.has(node.node_id)) {
322
+ // Update label / payload if changed
323
+ const existing = E.nodes.get(node.node_id);
324
+ if (node.label) existing.label = node.label;
325
+ if (node.payload) existing.payload = node.payload;
326
+ if (node.embedding_hint) existing.embedding_hint = node.embedding_hint;
327
+ return existing;
328
+ }
329
  const parentId = parentHint || 'CORE';
330
  const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
331
  const idx = E.nodes.size;
 
332
  const golden = 2.39996;
333
  const angle = (idx * golden) % (Math.PI * 2);
334
  const dist = SPAWN_RADIUS + ((idx * 17) % 220);
335
  const tx = parent.x + Math.cos(angle) * dist;
336
  const ty = parent.y + Math.sin(angle) * dist;
337
  const type = node.node_type || node.type || 'DOMAIN';
338
+ const radius = (type === 'CORE' || type === 'CIVILIZATION') ? 36
339
  : type === 'AGENT' ? 22
340
  : type === 'TOOL' ? 14
341
  : 18;
 
345
  type,
346
  label: node.label || node.node_id,
347
  radius,
348
+ color: (window.colorFor || (() => '#a76bff'))(type),
349
  phase: Math.random() * Math.PI * 2,
350
  born: performance.now(),
351
  payload: node.payload || {},
352
  embedding_hint: node.embedding_hint || '',
353
  };
354
  E.nodes.set(node.node_id, n);
355
+ // Gentle auto-pan toward new node
356
  E.cam.tx = E.cam.x * 0.86 + tx * 0.14;
357
  E.cam.ty = E.cam.y * 0.86 + ty * 0.14;
358
  return n;
359
  };
360
 
361
  window.elysiumAddEdge = function (edge) {
362
+ if (!edge || !edge.source_node_id || !edge.target_node_id) return;
363
  if (E.edges.some(e =>
364
  e.src === edge.source_node_id &&
365
  e.dst === edge.target_node_id &&
 
367
  E.edges.push({
368
  src: edge.source_node_id,
369
  dst: edge.target_node_id,
370
+ type: edge.edge_type || 'GENERIC',
371
  weight: edge.weight ?? 0.5,
372
+ color: edge.edge_type === 'CONFLICT' ? 'rgba(255,84,105,.6)' :
373
  edge.edge_type === 'COALITION' ? 'rgba(167,107,255,.65)' :
374
+ edge.edge_type === 'CAUSAL' ? 'rgba(25,214,255,.55)' :
375
+ edge.edge_type === 'SUPPORTS' ? 'rgba(92,255,174,.55)' :
376
  'rgba(255,184,64,.55)',
377
  born: performance.now(),
378
  });
 
398
 
399
  window.elysiumGetNode = (id) => E.nodes.get(id);
400
 
 
401
  window.elysiumFilterType = function (type) {
402
  E.filterType = (E.filterType === type) ? null : type;
403
  };
404
 
405
+ /* ──────── RENDER LOOP ──────── */
406
  function lerp(a, b, t) { return a + (b - a) * t; }
407
 
408
  function loop(t) {
409
+ // Animate spawn-to-target
410
  E.nodes.forEach(n => {
411
  if (n.tx != null) {
412
  n.x = lerp(n.x, n.tx, 0.12);
 
417
  }
418
  });
419
 
420
+ // Smooth camera
421
  E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
422
  if (!E.cam.drag) {
423
  E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
 
428
  if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
429
  }
430
 
431
+ // Background
432
  ctx.fillStyle = '#02080c';
433
  ctx.fillRect(0, 0, canvas.width, canvas.height);
434
  const grad = ctx.createRadialGradient(
 
441
 
442
  drawAmbient(ctx, t);
443
 
444
+ // World transform
445
  ctx.save();
446
  ctx.scale(DPR, DPR);
447
  ctx.translate(innerWidth / 2, innerHeight / 2);
448
  ctx.scale(E.cam.z, E.cam.z);
449
  ctx.translate(-E.cam.x, -E.cam.y);
450
 
451
+ // Edges
452
  E.edges.forEach(e => {
453
  const s = E.nodes.get(e.src), d = E.nodes.get(e.dst);
454
  if (!s || !d) return;
 
473
  });
474
  ctx.globalAlpha = 1;
475
 
476
+ // Nodes
477
  E.nodes.forEach(n => {
478
  const dim = E.filterType && n.type !== E.filterType ? 0.22 : 1;
479
  ctx.globalAlpha = dim;
480
+ try { window.drawNode(ctx, n, t, E.cam.z); }
481
+ catch (err) { /* swallow per-node draw errors */ }
482
  });
483
  ctx.globalAlpha = 1;
484
  ctx.restore();
 
487
  requestAnimationFrame(loop);
488
  }
489
 
490
+ // Ambient particles
491
  const PARTICLES = Array.from({ length: 60 }, () => ({
492
  x: Math.random() * innerWidth,
493
  y: Math.random() * innerHeight,
frontend/dist/assets/council.js CHANGED
@@ -1,255 +1,368 @@
1
- /* Council overlay + node-detail popover.
 
 
 
2
  Implements:
3
- • Council slide-in panel with per-agent boxes (different neon colors)
4
- Play / Pause / Minimize / Close controls
5
- • Per-agent mini play buttons synced to the combined audio
6
- • Speaking-agent highlight that follows audio timeline
7
- • Node-detail popover with payload, type, connections (image 4 style) */
8
- const AGENT_COLORS = {
9
- THE_BUILDER: '#ff4fa3',
10
- THE_GUARDIAN: '#a76bff',
11
- THE_ORACLE: '#19d6ff',
12
- THE_WEAVER: '#ff80c4',
13
- THE_WILDCARD: '#5cffae',
14
- DYNAMIC: '#ffb840',
15
- };
16
- window.agentColor = (a) => AGENT_COLORS[a] || '#a76bff';
17
-
18
- const overlay = () => document.getElementById('council-overlay');
19
- const pill = () => document.getElementById('council-pill');
20
-
21
- let _audio = null; // <audio> element
22
- let _agentAudios = []; // [{audio_url, ...}, ...]
23
- let _currentAgents = [];
24
-
25
- function escapeHtml(s) {
26
- return String(s || '').replace(/[&<>"']/g, c => ({
27
- '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
28
- }[c]));
29
- }
30
-
31
- window.renderCouncil = function (resp, runtime) {
32
- const ov = overlay();
33
- const body = document.getElementById('co-body');
34
- const synth = document.getElementById('co-synth');
35
- const count = document.getElementById('co-count');
36
- const audio = document.getElementById('debate-audio');
37
- _audio = audio;
38
-
39
- const cd = resp.council_deliberation || {};
40
- const agents = cd.agent_outputs || [];
41
- _currentAgents = agents;
42
- _agentAudios = (runtime && runtime.per_agent_audio) || [];
43
-
44
- // No agents → hide overlay + pill
45
- if (!agents.length) {
46
- ov.classList.remove('show');
47
- setTimeout(() => ov.classList.add('hidden'), 400);
48
- pill().classList.add('hidden');
49
- return;
50
  }
51
 
52
- ov.classList.remove('hidden');
53
- requestAnimationFrame(() => ov.classList.add('show'));
54
-
55
- count.textContent = `+${agents.length}`;
56
- document.getElementById('pill-count').textContent = agents.length;
57
-
58
- // Render each agent as a colored card
59
- body.innerHTML = agents.map((a, i) => {
60
- const c = window.agentColor(a.archetype);
61
- const audioInfo = _agentAudios[i] || {};
62
- const hasAudio = !!audioInfo.audio_url;
63
- return `
64
- <div class="agent-card" style="border-color:${c}; --card-glow:${c}33; animation-delay:${i * 80}ms"
65
- data-aid="${escapeHtml(a.agent_id)}" data-idx="${i}">
66
- <div class="row1">
67
- <span class="ad-dot" style="background:${c};color:${c}"></span>
68
- <span class="name" style="color:${c}">${escapeHtml(a.agent_name || 'Agent')}</span>
69
- <span class="archetype">${escapeHtml((a.archetype || 'DYNAMIC').replace(/^THE_/, ''))}</span>
70
- ${a.veto_triggered ? '<span class="veto">VETO</span>' : ''}
71
- </div>
72
- <div class="thinking">${escapeHtml(a.thinking || '')}</div>
73
- <div class="stance">${escapeHtml(a.tts_speech_text || a.stance || '')}</div>
74
- <div class="footer-row">
75
- <div class="conf">confidence ${(a.confidence ?? 0.8).toFixed(2)}</div>
76
- ${hasAudio ? `<button class="play-mini" data-aurl="${audioInfo.audio_url}" data-idx="${i}" title="Play this agent's voice">▶</button>` : ''}
77
- </div>
78
- </div>`;
79
- }).join('');
80
-
81
- synth.textContent = cd.final_synthesis || '';
82
-
83
- // Wire per-agent mini-play buttons
84
- body.querySelectorAll('.play-mini').forEach(btn => {
85
- btn.onclick = (ev) => {
86
- ev.stopPropagation();
87
- const url = btn.dataset.aurl;
88
- const idx = +btn.dataset.idx;
89
- if (audio.src.endsWith(url) && !audio.paused) {
90
- audio.pause();
91
- btn.classList.remove('playing');
92
- btn.textContent = '▶';
93
- return;
94
- }
95
- // stop any other mini playing
96
- body.querySelectorAll('.play-mini.playing').forEach(b => {
97
- b.classList.remove('playing'); b.textContent = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  });
99
- audio.src = url;
100
- audio.play().catch(() => {});
101
- btn.classList.add('playing');
102
- btn.textContent = '';
103
- highlightAgent(idx);
104
- audio.onended = () => {
105
- btn.classList.remove('playing'); btn.textContent = '';
106
- clearHighlight();
107
- };
108
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  });
110
 
111
- // Combined audio drama
112
- if (runtime && runtime.audio_url) {
113
- audio.style.display = 'block';
114
- audio.src = runtime.audio_url;
115
- audio.dataset.combined = '1';
116
- document.getElementById('co-play').disabled = false;
117
- document.getElementById('co-pause').disabled = true;
118
- // optional autoplay (best-effort, browsers may block)
119
- audio.play().then(() => {
120
- document.getElementById('co-play').disabled = true;
121
- document.getElementById('co-pause').disabled = false;
 
 
 
 
 
 
 
 
122
  }).catch(() => {});
 
123
 
124
- const cards = body.querySelectorAll('.agent-card');
125
- let idx = 0;
126
- if (cards[0]) cards[0].classList.add('speaking');
127
- audio.ontimeupdate = () => {
128
- if (!audio.duration || audio.dataset.combined !== '1') return;
129
- const target = Math.min(cards.length - 1,
130
- Math.floor((audio.currentTime / audio.duration) * cards.length));
131
- if (target !== idx) {
132
- cards[idx]?.classList.remove('speaking');
133
- cards[target]?.classList.add('speaking');
134
- idx = target;
 
 
 
 
 
 
135
  }
136
- };
137
- audio.onended = () => {
138
- cards.forEach(c => c.classList.remove('speaking'));
139
- document.getElementById('co-play').disabled = false;
140
- document.getElementById('co-pause').disabled = true;
141
- };
142
- } else {
143
- audio.style.display = 'none';
144
- audio.removeAttribute('src');
145
- document.getElementById('co-play').disabled = true;
146
- document.getElementById('co-pause').disabled = true;
147
- }
148
- };
149
-
150
- function highlightAgent(idx) {
151
- const cards = document.querySelectorAll('#co-body .agent-card');
152
- cards.forEach(c => c.classList.remove('speaking'));
153
- cards[idx]?.classList.add('speaking');
154
- }
155
- function clearHighlight() {
156
- document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
157
- }
158
-
159
- /* ── Controls ── */
160
- document.getElementById('co-close').onclick = () => {
161
- overlay().classList.remove('show');
162
- setTimeout(() => overlay().classList.add('hidden'), 400);
163
- pill().classList.add('hidden');
164
- if (_audio) { _audio.pause(); _audio.currentTime = 0; }
165
- };
166
-
167
- document.getElementById('co-min').onclick = () => {
168
- overlay().classList.add('minimized');
169
- if (_currentAgents.length) pill().classList.remove('hidden');
170
- };
171
-
172
- document.getElementById('council-pill').onclick = () => {
173
- overlay().classList.remove('minimized');
174
- pill().classList.add('hidden');
175
- };
176
-
177
- document.getElementById('co-play').onclick = () => {
178
- if (!_audio || !_audio.src) return;
179
- _audio.dataset.combined = '1';
180
- _audio.play().then(() => {
181
- document.getElementById('co-play').disabled = true;
182
- document.getElementById('co-pause').disabled = false;
183
- }).catch(() => {});
184
- };
185
- document.getElementById('co-pause').onclick = () => {
186
- if (!_audio) return;
187
- _audio.pause();
188
- document.getElementById('co-play').disabled = false;
189
- document.getElementById('co-pause').disabled = true;
190
- };
191
-
192
- /* ── My-Agent button toggles the overlay ── */
193
- document.getElementById('my-agent').onclick = () => {
194
- const ov = overlay();
195
- if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
196
- if (!_currentAgents.length) {
197
- window.toast?.('No agents yet — ask a complex question to summon the council', 'info');
198
- return;
199
  }
200
- ov.classList.remove('hidden', 'minimized');
201
- requestAnimationFrame(() => ov.classList.add('show'));
202
- pill().classList.add('hidden');
203
- } else {
204
- ov.classList.remove('show');
205
- setTimeout(() => ov.classList.add('hidden'), 400);
206
- }
207
- };
208
-
209
- /* ──────────────────────────────────────────────────────────
210
- NODE DETAIL POPOVER — fetches /api/node/:id and renders
211
- payload + type + connections (matches image 4 layout)
212
- ────────────────────────────────────────────────────────── */
213
- window.showNodeDetail = async function (n, sx, sy) {
214
- window.elysiumSelect?.(n.node_id);
215
- const el = document.getElementById('node-detail');
216
- el.style.left = Math.min(innerWidth - 340, Math.max(10, sx + 14)) + 'px';
217
- el.style.top = Math.min(innerHeight - 340, Math.max(10, sy - 40)) + 'px';
218
-
219
- // Initial skeleton so user sees something instantly
220
- el.innerHTML = `
221
- <div class="nd-head">
222
- <div class="nd-title">
223
- <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
224
- ${escapeHtml(n.label)}</div>
225
- <button class="nd-close" onclick="window.hideNodeDetail()">×</button>
226
- </div>
227
- <div class="nd-type">${escapeHtml(n.type)}</div>
228
- <div class="nd-stats">
229
- <div class="nd-pill"><b>…</b>links</div>
230
- <div class="nd-pill"><b>…</b>in</div>
231
- <div class="nd-pill"><b>…</b>out</div>
232
- </div>
233
- <div class="nd-section">DESCRIPTION</div>
234
- <div class="nd-payload">${escapeHtml(n.embedding_hint || (n.payload?.description) || 'Loading…')}</div>
235
- `;
236
- el.classList.add('show');
237
-
238
- // Fetch enriched details
239
- try {
240
- const d = await window.ElysiumAPI.nodeDetail(n.node_id);
241
- if (!d) return;
242
- const conns = (d.incoming || []).concat(d.outgoing || []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  const connsHtml = conns.length
244
- ? conns.slice(0, 6).map(c => `
245
  <div class="nd-conn">
246
- <span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to)}</span>
247
- <span class="cw">${(c.weight ?? 0.5).toFixed(2)}</span>
248
  </div>`).join('')
249
  : '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
250
 
251
  const payload = d.payload && Object.keys(d.payload).length
252
- ? Object.entries(d.payload).slice(0, 6).map(([k, v]) =>
253
  `${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
254
  ).join('\n')
255
  : (d.embedding_hint || 'No payload data');
@@ -257,13 +370,14 @@ window.showNodeDetail = async function (n, sx, sy) {
257
  el.innerHTML = `
258
  <div class="nd-head">
259
  <div class="nd-title">
260
- <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
261
- ${escapeHtml(d.label)}</div>
262
- <button class="nd-close" onclick="window.hideNodeDetail()">×</button>
 
263
  </div>
264
- <div class="nd-type">${escapeHtml(d.node_type)}</div>
265
  <div class="nd-stats">
266
- <div class="nd-pill"><b>${d.degree}</b>links</div>
267
  <div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
268
  <div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
269
  </div>
@@ -272,24 +386,30 @@ window.showNodeDetail = async function (n, sx, sy) {
272
  <div class="nd-section">CONNECTIONS</div>
273
  <div class="nd-conns">${connsHtml}</div>
274
  `;
275
- } catch (e) {
276
- // local-only fallback
277
  }
278
- };
279
-
280
- window.hideNodeDetail = function () {
281
- document.getElementById('node-detail').classList.remove('show');
282
- window.ELYSIUM?.nodes.forEach(n => n.selected = false);
283
- };
284
-
285
- // Close popover when clicking elsewhere (but not on canvas — canvas handles its own)
286
- document.addEventListener('pointerdown', e => {
287
- const nd = document.getElementById('node-detail');
288
- if (nd.classList.contains('show') &&
289
- !nd.contains(e.target) &&
290
- e.target.id !== 'elysium-canvas') {
291
- // canvas click closes via its own logic; only outside-of-popover clicks here
292
- if (e.target.closest('#elysium-canvas')) return;
 
293
  window.hideNodeDetail();
294
- }
295
- });
 
 
 
 
 
 
1
+ /* ============================================================
2
+ ELYSIUM — council.js
3
+ Council overlay + Node-detail popover.
4
+
5
  Implements:
6
+ • Council slide-in panel with per-agent cards
7
+ Combined-track playback (▶ / ⏸)
8
+ • Per-agent mini play buttons
9
+ • Speaking-agent highlight synced to audio time
10
+ • Node-detail popover (Task 3) — fetches /api/node/:id and
11
+ falls back to local node payload if backend not reachable
12
+ • All DOM access is defensive — no "classList of null" crashes
13
+ ============================================================ */
14
+ (() => {
15
+ 'use strict';
16
+
17
+ const AGENT_COLORS = {
18
+ THE_BUILDER: '#ff4fa3',
19
+ THE_GUARDIAN: '#a76bff',
20
+ THE_ORACLE: '#19d6ff',
21
+ THE_WEAVER: '#ff80c4',
22
+ THE_WILDCARD: '#5cffae',
23
+ DYNAMIC: '#ffb840',
24
+ };
25
+ window.agentColor = (a) => AGENT_COLORS[a] || '#a76bff';
26
+
27
+ const $ = (id) => document.getElementById(id);
28
+ const safe = (fn) => { try { return fn(); } catch (e) { console.warn(e); } };
29
+
30
+ function escapeHtml(s) {
31
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
32
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
33
+ }[c]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
+ let _audio = null;
37
+ let _agentAudios = [];
38
+ let _currentAgents = [];
39
+
40
+ /* =========================================================
41
+ RENDER COUNCIL (called from boot.js handleResponse)
42
+ ========================================================= */
43
+ window.renderCouncil = function (resp, runtime) {
44
+ const ov = $('council-overlay');
45
+ const body = $('co-body');
46
+ const synth = $('co-synth');
47
+ const count = $('co-count');
48
+ const audio = $('debate-audio');
49
+ if (!ov || !body) return;
50
+ _audio = audio;
51
+
52
+ const cd = (resp && resp.council_deliberation) || {};
53
+ const agents = Array.isArray(cd.agent_outputs) ? cd.agent_outputs : [];
54
+ _currentAgents = agents;
55
+ _agentAudios = (runtime && Array.isArray(runtime.per_agent_audio))
56
+ ? runtime.per_agent_audio : [];
57
+
58
+ // No agents → hide overlay + pill
59
+ if (!agents.length) {
60
+ ov.classList.remove('show');
61
+ setTimeout(() => ov.classList.add('hidden'), 400);
62
+ const pill = $('council-pill'); if (pill) pill.classList.add('hidden');
63
+ if (synth) synth.textContent = '';
64
+ if (audio) { try { audio.pause(); } catch {} audio.removeAttribute('src'); audio.style.display='none'; }
65
+ if (count) count.textContent = '+0';
66
+ return;
67
+ }
68
+
69
+ ov.classList.remove('hidden');
70
+ requestAnimationFrame(() => ov.classList.add('show'));
71
+
72
+ if (count) count.textContent = `+${agents.length}`;
73
+ const pillCount = $('pill-count');
74
+ if (pillCount) pillCount.textContent = agents.length;
75
+
76
+ body.innerHTML = agents.map((a, i) => {
77
+ if (!a) return '';
78
+ const c = window.agentColor(a.archetype);
79
+ const audioInfo = _agentAudios[i] || {};
80
+ const hasAudio = !!audioInfo.audio_url;
81
+ const archetype = (a.archetype || 'DYNAMIC').replace(/^THE_/, '');
82
+ return `
83
+ <div class="agent-card" style="border-color:${c}; --card-glow:${c}33; animation-delay:${i * 80}ms"
84
+ data-aid="${escapeHtml(a.agent_id)}" data-idx="${i}">
85
+ <div class="row1">
86
+ <span class="ad-dot" style="background:${c};color:${c}"></span>
87
+ <span class="name" style="color:${c}">${escapeHtml(a.agent_name || 'Agent')}</span>
88
+ <span class="archetype">${escapeHtml(archetype)}</span>
89
+ ${a.veto_triggered ? '<span class="veto">VETO</span>' : ''}
90
+ </div>
91
+ <div class="thinking">${escapeHtml(a.thinking || '')}</div>
92
+ <div class="stance">${escapeHtml(a.tts_speech_text || a.stance || '')}</div>
93
+ <div class="footer-row">
94
+ <div class="conf">confidence ${(typeof a.confidence === 'number' ? a.confidence : 0.8).toFixed(2)}</div>
95
+ ${hasAudio ? `<button class="play-mini" data-aurl="${audioInfo.audio_url}" data-idx="${i}" title="Play this agent's voice">▶</button>` : ''}
96
+ </div>
97
+ </div>`;
98
+ }).join('');
99
+
100
+ if (synth) synth.textContent = cd.final_synthesis || '';
101
+
102
+ // Wire per-agent mini-play buttons
103
+ if (audio) {
104
+ body.querySelectorAll('.play-mini').forEach(btn => {
105
+ btn.onclick = (ev) => {
106
+ ev.stopPropagation();
107
+ const url = btn.dataset.aurl;
108
+ const idx = +btn.dataset.idx;
109
+ if (audio.src && audio.src.endsWith(url) && !audio.paused) {
110
+ audio.pause();
111
+ btn.classList.remove('playing');
112
+ btn.textContent = '▶';
113
+ return;
114
+ }
115
+ body.querySelectorAll('.play-mini.playing').forEach(b => {
116
+ b.classList.remove('playing'); b.textContent = '▶';
117
+ });
118
+ audio.dataset.combined = '0';
119
+ audio.src = url;
120
+ audio.play().catch(() => {});
121
+ btn.classList.add('playing');
122
+ btn.textContent = '⏸';
123
+ highlightAgent(idx);
124
+ audio.onended = () => {
125
+ btn.classList.remove('playing'); btn.textContent = '▶';
126
+ clearHighlight();
127
+ };
128
+ };
129
  });
130
+
131
+ // Combined audio drama (if backend produced one)
132
+ if (runtime && runtime.audio_url) {
133
+ audio.style.display = 'block';
134
+ audio.src = runtime.audio_url;
135
+ audio.dataset.combined = '1';
136
+ const playBtn = $('co-play');
137
+ const pauseBtn = $('co-pause');
138
+ if (playBtn) playBtn.disabled = false;
139
+ if (pauseBtn) pauseBtn.disabled = true;
140
+ audio.play().then(() => {
141
+ if (playBtn) playBtn.disabled = true;
142
+ if (pauseBtn) pauseBtn.disabled = false;
143
+ }).catch(() => {});
144
+
145
+ const cards = body.querySelectorAll('.agent-card');
146
+ let idx = 0;
147
+ if (cards[0]) cards[0].classList.add('speaking');
148
+ audio.ontimeupdate = () => {
149
+ if (!audio.duration || audio.dataset.combined !== '1') return;
150
+ const target = Math.min(cards.length - 1,
151
+ Math.floor((audio.currentTime / audio.duration) * cards.length));
152
+ if (target !== idx) {
153
+ cards[idx]?.classList.remove('speaking');
154
+ cards[target]?.classList.add('speaking');
155
+ idx = target;
156
+ }
157
+ };
158
+ audio.onended = () => {
159
+ cards.forEach(c => c.classList.remove('speaking'));
160
+ if (playBtn) playBtn.disabled = false;
161
+ if (pauseBtn) pauseBtn.disabled = true;
162
+ };
163
+ } else {
164
+ audio.style.display = 'none';
165
+ audio.removeAttribute('src');
166
+ const playBtn = $('co-play');
167
+ const pauseBtn = $('co-pause');
168
+ if (playBtn) playBtn.disabled = true;
169
+ if (pauseBtn) pauseBtn.disabled = true;
170
+ }
171
+ }
172
+ };
173
+
174
+ function highlightAgent(idx) {
175
+ const cards = document.querySelectorAll('#co-body .agent-card');
176
+ cards.forEach(c => c.classList.remove('speaking'));
177
+ if (cards[idx]) cards[idx].classList.add('speaking');
178
+ }
179
+ function clearHighlight() {
180
+ document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
181
+ }
182
+
183
+ /* =========================================================
184
+ OVERLAY CONTROLS (defensive — only wire if button exists)
185
+ ========================================================= */
186
+ const wire = (id, fn) => { const el = $(id); if (el) el.onclick = fn; };
187
+
188
+ wire('co-close', () => {
189
+ const ov = $('council-overlay');
190
+ if (ov) {
191
+ ov.classList.remove('show');
192
+ setTimeout(() => ov.classList.add('hidden'), 400);
193
+ }
194
+ const pill = $('council-pill'); if (pill) pill.classList.add('hidden');
195
+ if (_audio) { try { _audio.pause(); _audio.currentTime = 0; } catch {} }
196
  });
197
 
198
+ wire('co-min', () => {
199
+ const ov = $('council-overlay'); if (ov) ov.classList.add('minimized');
200
+ if (_currentAgents.length) { const p = $('council-pill'); if (p) p.classList.remove('hidden'); }
201
+ });
202
+
203
+ wire('council-pill', () => {
204
+ const ov = $('council-overlay');
205
+ if (ov) ov.classList.remove('minimized');
206
+ const p = $('council-pill'); if (p) p.classList.add('hidden');
207
+ });
208
+
209
+ wire('co-play', () => {
210
+ if (!_audio || !_audio.src) return;
211
+ _audio.dataset.combined = '1';
212
+ _audio.play().then(() => {
213
+ const playBtn = $('co-play');
214
+ const pauseBtn = $('co-pause');
215
+ if (playBtn) playBtn.disabled = true;
216
+ if (pauseBtn) pauseBtn.disabled = false;
217
  }).catch(() => {});
218
+ });
219
 
220
+ wire('co-pause', () => {
221
+ if (!_audio) return;
222
+ try { _audio.pause(); } catch {}
223
+ const playBtn = $('co-play');
224
+ const pauseBtn = $('co-pause');
225
+ if (playBtn) playBtn.disabled = false;
226
+ if (pauseBtn) pauseBtn.disabled = true;
227
+ });
228
+
229
+ // My-Agent button: toggle overlay (and warn if empty)
230
+ wire('my-agent', () => {
231
+ const ov = $('council-overlay');
232
+ if (!ov) return;
233
+ if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
234
+ if (!_currentAgents.length) {
235
+ if (window.toast) window.toast('No agents yet — ask a complex question to summon the council', 'info');
236
+ return;
237
  }
238
+ ov.classList.remove('hidden', 'minimized');
239
+ requestAnimationFrame(() => ov.classList.add('show'));
240
+ const p = $('council-pill'); if (p) p.classList.add('hidden');
241
+ } else {
242
+ ov.classList.remove('show');
243
+ setTimeout(() => ov.classList.add('hidden'), 400);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  }
245
+ });
246
+
247
+ /* =========================================================
248
+ NODE DETAIL POPOVER (Task 3)
249
+ Click any node → fetch enriched detail from /api/node/:id,
250
+ fall back to local data if offline.
251
+ ========================================================= */
252
+ window.showNodeDetail = async function (n, sx, sy) {
253
+ const el = $('node-detail');
254
+ if (!el || !n) return;
255
+
256
+ if (typeof window.elysiumSelect === 'function') {
257
+ try { window.elysiumSelect(n.node_id); } catch {}
258
+ }
259
+
260
+ // Position popover near click but keep on-screen
261
+ const W = window.innerWidth, H = window.innerHeight;
262
+ const left = Math.min(W - 340, Math.max(10, (sx || W/2) + 14));
263
+ const top = Math.min(H - 360, Math.max(10, (sy || H/2) - 40));
264
+ el.style.left = left + 'px';
265
+ el.style.top = top + 'px';
266
+
267
+ // Initial skeleton with locally-known data (instant render)
268
+ const localPayload = n.payload && Object.keys(n.payload).length
269
+ ? Object.entries(n.payload).slice(0, 6).map(([k, v]) =>
270
+ `${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
271
+ ).join('\n')
272
+ : (n.embedding_hint || 'Loading details…');
273
+
274
+ el.innerHTML = `
275
+ <div class="nd-head">
276
+ <div class="nd-title">
277
+ <span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
278
+ ${escapeHtml(n.label)}
279
+ </div>
280
+ <button class="nd-close" aria-label="Close">×</button>
281
+ </div>
282
+ <div class="nd-type">${escapeHtml(n.type)}</div>
283
+ <div class="nd-stats">
284
+ <div class="nd-pill"><b>…</b>links</div>
285
+ <div class="nd-pill"><b>…</b>in</div>
286
+ <div class="nd-pill"><b>…</b>out</div>
287
+ </div>
288
+ <div class="nd-section">PAYLOAD</div>
289
+ <div class="nd-payload">${escapeHtml(localPayload)}</div>
290
+ <div class="nd-section">CONNECTIONS</div>
291
+ <div class="nd-conns"><div class="nd-conn"><span class="ct">Loading…</span></div></div>
292
+ `;
293
+ el.classList.remove('hidden');
294
+ requestAnimationFrame(() => el.classList.add('show'));
295
+
296
+ // Wire close
297
+ const closeBtn = el.querySelector('.nd-close');
298
+ if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); window.hideNodeDetail(); };
299
+
300
+ // Compute local connections immediately (works even offline)
301
+ const localConns = [];
302
+ if (window.ELYSIUM) {
303
+ window.ELYSIUM.edges.forEach(e => {
304
+ if (e.src === n.node_id) {
305
+ const target = window.ELYSIUM.nodes.get(e.dst);
306
+ localConns.push({
307
+ ct: target ? target.label : e.dst,
308
+ type: e.type,
309
+ weight: e.weight,
310
+ dir: 'out',
311
+ });
312
+ } else if (e.dst === n.node_id) {
313
+ const source = window.ELYSIUM.nodes.get(e.src);
314
+ localConns.push({
315
+ ct: source ? source.label : e.src,
316
+ type: e.type,
317
+ weight: e.weight,
318
+ dir: 'in',
319
+ });
320
+ }
321
+ });
322
+ }
323
+
324
+ // Render with local data first
325
+ renderDetail(el, {
326
+ node_id: n.node_id,
327
+ label: n.label,
328
+ node_type: n.type,
329
+ payload: n.payload || {},
330
+ embedding_hint: n.embedding_hint || '',
331
+ incoming: localConns.filter(c => c.dir === 'in').map(c => ({
332
+ from_label: c.ct, edge_type: c.type, weight: c.weight,
333
+ })),
334
+ outgoing: localConns.filter(c => c.dir === 'out').map(c => ({
335
+ to_label: c.ct, edge_type: c.type, weight: c.weight,
336
+ })),
337
+ degree: localConns.length,
338
+ });
339
+
340
+ // Then try to enrich from backend (silent fail)
341
+ if (window.ElysiumAPI && window.ElysiumAPI.nodeDetail) {
342
+ try {
343
+ const d = await window.ElysiumAPI.nodeDetail(n.node_id);
344
+ if (d) renderDetail(el, d);
345
+ } catch (e) { /* keep local data */ }
346
+ }
347
+ };
348
+
349
+ function renderDetail(el, d) {
350
+ if (!el || !d) return;
351
+ const n = window.ELYSIUM ? window.ELYSIUM.nodes.get(d.node_id) : null;
352
+ const color = (n && n.color) || (window.colorFor ? window.colorFor(d.node_type) : '#a76bff');
353
+
354
+ const conns = (Array.isArray(d.incoming) ? d.incoming : [])
355
+ .concat(Array.isArray(d.outgoing) ? d.outgoing : []);
356
  const connsHtml = conns.length
357
+ ? conns.slice(0, 8).map(c => `
358
  <div class="nd-conn">
359
+ <span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to || '?')}</span>
360
+ <span class="cw">${(typeof c.weight === 'number' ? c.weight : 0.5).toFixed(2)}</span>
361
  </div>`).join('')
362
  : '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
363
 
364
  const payload = d.payload && Object.keys(d.payload).length
365
+ ? Object.entries(d.payload).slice(0, 8).map(([k, v]) =>
366
  `${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
367
  ).join('\n')
368
  : (d.embedding_hint || 'No payload data');
 
370
  el.innerHTML = `
371
  <div class="nd-head">
372
  <div class="nd-title">
373
+ <span class="nd-dot" style="background:${color};color:${color}"></span>
374
+ ${escapeHtml(d.label || d.node_id)}
375
+ </div>
376
+ <button class="nd-close" aria-label="Close">×</button>
377
  </div>
378
+ <div class="nd-type">${escapeHtml(d.node_type || '')}</div>
379
  <div class="nd-stats">
380
+ <div class="nd-pill"><b>${d.degree ?? conns.length}</b>links</div>
381
  <div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
382
  <div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
383
  </div>
 
386
  <div class="nd-section">CONNECTIONS</div>
387
  <div class="nd-conns">${connsHtml}</div>
388
  `;
389
+ const closeBtn = el.querySelector('.nd-close');
390
+ if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); window.hideNodeDetail(); };
391
  }
392
+
393
+ window.hideNodeDetail = function () {
394
+ const el = $('node-detail');
395
+ if (!el) return;
396
+ el.classList.remove('show');
397
+ setTimeout(() => el.classList.add('hidden'), 250);
398
+ if (window.ELYSIUM) window.ELYSIUM.nodes.forEach(n => n.selected = false);
399
+ };
400
+
401
+ // Click outside popover (and outside canvas) → close
402
+ document.addEventListener('pointerdown', e => {
403
+ const nd = $('node-detail');
404
+ if (!nd || nd.classList.contains('hidden')) return;
405
+ if (nd.contains(e.target)) return; // click inside popover
406
+ if (e.target.id === 'elysium-canvas') return; // canvas handles its own
407
+ if (e.target.closest && e.target.closest('#minimap-wrap')) return;
408
  window.hideNodeDetail();
409
+ });
410
+
411
+ // Escape closes popover
412
+ document.addEventListener('keydown', e => {
413
+ if (e.key === 'Escape') window.hideNodeDetail();
414
+ });
415
+ })();
frontend/dist/assets/elysium.css CHANGED
@@ -1,8 +1,5 @@
1
  /* ============================================================
2
  ELYSIUM — Dark Neon Bioluminescent Theme
3
- Heavily inspired by the reference images:
4
- deep teal-black backdrop, magenta/cyan/violet/gold neon nodes,
5
- glassy panels with neon hairline borders.
6
  ============================================================ */
7
  :root{
8
  --bg-0:#02080c;
@@ -45,8 +42,7 @@
45
 
46
  *,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
47
  html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;
48
- background:
49
- radial-gradient(ellipse 90% 70% at 50% 40%, #0a2434 0%, #03101a 55%, #01060b 100%);
50
  color:var(--text);font-family:var(--font-sans);font-size:14px;
51
  user-select:none;-webkit-user-select:none}
52
  button{font-family:inherit;color:inherit;cursor:pointer}
@@ -56,7 +52,7 @@ button{font-family:inherit;color:inherit;cursor:pointer}
56
  touch-action:none;z-index:0}
57
  #elysium-canvas.dragging{cursor:grabbing}
58
 
59
- /* ── GLASS PANELS ─────────────────────────────────────── */
60
  .glass{
61
  background:linear-gradient(180deg, var(--panel), var(--panel-2));
62
  border:1px solid var(--border);
@@ -111,16 +107,16 @@ button{font-family:inherit;color:inherit;cursor:pointer}
111
  box-shadow:inset 0 0 8px rgba(0,229,200,.08)}
112
 
113
  /* ── MINIMAP ──────────────────────────────────────────── */
114
- #minimap-wrap{position:fixed;left:74px;bottom:140px;z-index:15;
115
  background:rgba(2,12,18,.85);border:1px solid var(--border);border-radius:12px;
116
  padding:6px 6px 4px;box-shadow:var(--shadow);backdrop-filter:blur(10px)}
117
  .minimap-label{font-size:9px;letter-spacing:2.2px;color:var(--muted);
118
  text-align:center;margin-bottom:4px;font-weight:600}
119
  #minimap{display:block;width:170px;height:108px;border-radius:8px;cursor:crosshair;
120
- background:rgba(2,16,22,.95)}
121
 
122
  /* ── ZOOM CTRL ────────────────────────────────────────── */
123
- #zoom-ctrl{position:fixed;right:24px;bottom:140px;display:flex;flex-direction:column;
124
  gap:6px;z-index:15}
125
  #zoom-ctrl button{width:38px;height:38px;background:rgba(6,18,26,.85);
126
  border:1px solid var(--border);border-radius:10px;color:var(--teal);
@@ -140,7 +136,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
140
  @keyframes crisisPulse{0%,100%{opacity:.6}50%{opacity:1}}
141
 
142
  /* ── STATS BAR ────────────────────────────────────────── */
143
- #stats{position:fixed;left:58px;right:0;bottom:84px;height:56px;
144
  display:flex;align-items:center;padding:0 28px;gap:32px;z-index:18;
145
  border-radius:0;border-left:none;border-right:none;border-bottom:none;
146
  background:linear-gradient(180deg,rgba(2,12,18,.72),rgba(2,8,12,.92))}
@@ -152,28 +148,36 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
152
  .stat-value{font-size:22px;font-family:var(--font-disp);color:var(--cyan);
153
  font-weight:700;line-height:1;text-shadow:0 0 8px rgba(25,214,255,.45)}
154
 
155
- /* ── QUERY AREA (preview strip + bar + mode row) ──────── */
156
  #query-area{position:fixed;left:58px;right:0;bottom:0;z-index:20;
157
- display:flex;flex-direction:column;gap:0}
158
 
 
159
  #attach-strip{margin:0 22px 6px;padding:8px 10px;display:flex;gap:8px;
160
- flex-wrap:wrap;align-items:center;border-radius:14px 14px 14px 14px;
161
- border-bottom:none;max-height:84px;overflow:auto}
 
 
162
  .preview-tile{display:flex;align-items:center;gap:8px;
163
  background:rgba(0,229,200,.06);border:1px solid var(--border-strong);
164
- border-radius:10px;padding:4px 8px 4px 4px;font-size:11px;color:var(--text)}
 
 
165
  .preview-tile img{width:38px;height:38px;border-radius:6px;object-fit:cover;
166
  box-shadow:0 0 8px rgba(0,229,200,.3)}
167
  .preview-tile .pdf-ico{width:38px;height:38px;border-radius:6px;display:grid;
168
  place-items:center;background:linear-gradient(135deg,#3a1a2a,#1a0a14);
169
  color:var(--magenta);font-family:var(--font-mono);font-size:11px;font-weight:700;
170
  box-shadow:var(--neon-magenta)}
171
- .preview-tile .nm{max-width:120px;overflow:hidden;text-overflow:ellipsis;
172
  white-space:nowrap;font-family:var(--font-mono)}
173
- .preview-tile .x{background:none;border:none;color:var(--muted);font-size:14px;
174
- cursor:pointer;padding:0 4px}
175
- .preview-tile .x:hover{color:var(--red)}
176
- .attach-hint{font-size:11px;color:var(--muted-2);margin-left:6px}
 
 
 
177
 
178
  #query-bar{margin:0 22px 8px;height:54px;
179
  display:flex;align-items:center;padding:0 12px;gap:10px;
@@ -289,7 +293,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
289
  #co-synth:empty{display:none}
290
  #debate-audio{width:100%;margin-top:10px;height:32px;display:none}
291
 
292
- /* COUNCIL PILL (minimized state) */
293
  #council-pill{position:fixed;left:22px;top:22px;z-index:24;
294
  padding:8px 16px;border-radius:20px;background:rgba(6,18,26,.92);
295
  border:1px solid var(--border-strong);color:var(--text);font-size:12px;
@@ -300,12 +304,13 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
300
  #council-pill .dot-mini{width:8px;height:8px;border-radius:50%;background:var(--magenta);
301
  box-shadow:var(--neon-magenta)}
302
 
303
- /* ── NODE DETAIL POPOVER ──────────────────────────────── */
304
  #node-detail{position:fixed;width:320px;padding:18px;z-index:30;
305
  opacity:0;transform:translateY(8px);pointer-events:none;
306
  transition:opacity .25s,transform .25s;
307
  border:1px solid var(--border-strong);box-shadow:var(--neon-teal),var(--shadow)}
308
  #node-detail.show{opacity:1;transform:translateY(0);pointer-events:auto}
 
309
  .nd-head{display:flex;justify-content:space-between;align-items:flex-start;
310
  margin-bottom:8px;gap:8px}
311
  .nd-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px;
@@ -313,7 +318,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
313
  .nd-title .nd-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0;
314
  box-shadow:0 0 12px currentColor}
315
  .nd-close{background:none;border:none;color:var(--muted);font-size:20px;
316
- cursor:pointer;padding:0;line-height:1}
317
  .nd-close:hover{color:var(--text)}
318
  .nd-type{font-size:10px;letter-spacing:1.6px;color:var(--cyan);
319
  text-transform:uppercase;margin-bottom:12px;font-family:var(--font-mono);
@@ -329,7 +334,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
329
  .nd-payload{font-size:11px;color:var(--text);background:rgba(0,0,0,.25);
330
  padding:8px 10px;border-radius:8px;font-family:var(--font-mono);
331
  max-height:90px;overflow:auto;line-height:1.5;
332
- border-left:2px solid var(--border-strong)}
333
  .nd-conns{font-size:11px;color:var(--muted);max-height:100px;overflow:auto}
334
  .nd-conn{display:flex;justify-content:space-between;padding:3px 0;
335
  border-bottom:1px solid rgba(0,229,200,.07)}
@@ -343,7 +348,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
343
  border-left:3px solid var(--teal);padding:10px 18px;border-radius:10px;
344
  font-size:12px;color:var(--text);box-shadow:var(--neon-teal),var(--shadow);
345
  animation:toastIn .35s ease both,toastOut .4s ease 4.5s both;
346
- backdrop-filter:blur(10px)}
347
  .toast.warn{border-left-color:var(--gold);box-shadow:var(--neon-gold),var(--shadow)}
348
  .toast.error{border-left-color:var(--red);box-shadow:0 0 14px rgba(255,84,105,.5)}
349
  .toast.info{border-left-color:var(--violet);box-shadow:var(--neon-violet),var(--shadow)}
@@ -362,7 +367,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
362
  @keyframes hintFloat{0%,100%{opacity:.7;transform:translate(-50%,-50%)}
363
  50%{opacity:1;transform:translate(-50%,calc(-50% - 6px))}}
364
 
365
- /* ── BUSY OVERLAY (blocks input while model thinks) ──── */
366
  #busy-overlay{position:fixed;inset:0;z-index:35;pointer-events:none;
367
  display:none;place-items:center;background:rgba(2,12,18,0);
368
  transition:background .3s}
@@ -378,10 +383,10 @@ body[data-busy="1"] #busy-overlay{display:grid;background:rgba(2,12,18,.18)}
378
  /* ── RESPONSIVE ───────────────────────────────────────── */
379
  @media (max-width:980px){
380
  #legend{width:200px;padding:14px;top:14px;right:14px}
381
- #minimap-wrap{padding:5px}
382
  #minimap{width:140px;height:90px}
383
- #zoom-ctrl{right:14px;bottom:124px}
384
- #stats{gap:20px;padding:0 16px}
385
  .stat-value{font-size:18px}
386
  .stat-label{font-size:9px}
387
  #council-overlay{width:300px}
@@ -391,19 +396,20 @@ body[data-busy="1"] #busy-overlay{display:grid;background:rgba(2,12,18,.18)}
391
  #legend{width:170px;padding:12px;top:10px;right:10px;font-size:11px}
392
  #legend .lbl{display:none}
393
  .meta-row{font-size:10px}
394
- #minimap-wrap{left:54px;bottom:118px}
395
  #minimap{width:120px;height:80px}
396
- #stats{gap:14px;padding:0 12px;height:48px;bottom:78px}
397
  .stat-value{font-size:15px}
398
  .stat-icon{font-size:14px}
399
  #query-area{left:44px}
400
  .mode-row{padding-right:14px}
401
  #council-overlay{left:8px;right:8px;width:auto;top:8px;
402
- max-height:calc(100vh - 200px)}
403
- #zoom-ctrl{bottom:78px;right:10px}
 
404
  }
405
 
406
- /* ── HIDE GRADIO if ever shown ─────────────────────────── */
407
  .gradio-container,footer,.built-with,gradio-app>.main>.contain{display:none!important}
408
 
409
  .hidden{display:none!important}
 
1
  /* ============================================================
2
  ELYSIUM — Dark Neon Bioluminescent Theme
 
 
 
3
  ============================================================ */
4
  :root{
5
  --bg-0:#02080c;
 
42
 
43
  *,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
44
  html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;
45
+ background:radial-gradient(ellipse 90% 70% at 50% 40%, #0a2434 0%, #03101a 55%, #01060b 100%);
 
46
  color:var(--text);font-family:var(--font-sans);font-size:14px;
47
  user-select:none;-webkit-user-select:none}
48
  button{font-family:inherit;color:inherit;cursor:pointer}
 
52
  touch-action:none;z-index:0}
53
  #elysium-canvas.dragging{cursor:grabbing}
54
 
55
+ /* ── GLASS ────────────────────────────────────────────── */
56
  .glass{
57
  background:linear-gradient(180deg, var(--panel), var(--panel-2));
58
  border:1px solid var(--border);
 
107
  box-shadow:inset 0 0 8px rgba(0,229,200,.08)}
108
 
109
  /* ── MINIMAP ──────────────────────────────────────────── */
110
+ #minimap-wrap{position:fixed;left:74px;bottom:170px;z-index:15;
111
  background:rgba(2,12,18,.85);border:1px solid var(--border);border-radius:12px;
112
  padding:6px 6px 4px;box-shadow:var(--shadow);backdrop-filter:blur(10px)}
113
  .minimap-label{font-size:9px;letter-spacing:2.2px;color:var(--muted);
114
  text-align:center;margin-bottom:4px;font-weight:600}
115
  #minimap{display:block;width:170px;height:108px;border-radius:8px;cursor:crosshair;
116
+ background:rgba(2,16,22,.95);touch-action:none}
117
 
118
  /* ── ZOOM CTRL ────────────────────────────────────────── */
119
+ #zoom-ctrl{position:fixed;right:24px;bottom:170px;display:flex;flex-direction:column;
120
  gap:6px;z-index:15}
121
  #zoom-ctrl button{width:38px;height:38px;background:rgba(6,18,26,.85);
122
  border:1px solid var(--border);border-radius:10px;color:var(--teal);
 
136
  @keyframes crisisPulse{0%,100%{opacity:.6}50%{opacity:1}}
137
 
138
  /* ── STATS BAR ────────────────────────────────────────── */
139
+ #stats{position:fixed;left:58px;right:0;bottom:108px;height:56px;
140
  display:flex;align-items:center;padding:0 28px;gap:32px;z-index:18;
141
  border-radius:0;border-left:none;border-right:none;border-bottom:none;
142
  background:linear-gradient(180deg,rgba(2,12,18,.72),rgba(2,8,12,.92))}
 
148
  .stat-value{font-size:22px;font-family:var(--font-disp);color:var(--cyan);
149
  font-weight:700;line-height:1;text-shadow:0 0 8px rgba(25,214,255,.45)}
150
 
151
+ /* ── QUERY AREA ───────────────────────────────────────── */
152
  #query-area{position:fixed;left:58px;right:0;bottom:0;z-index:20;
153
+ display:flex;flex-direction:column;gap:0;padding-bottom:8px}
154
 
155
+ /* File preview strip — appears ABOVE input when files attached */
156
  #attach-strip{margin:0 22px 6px;padding:8px 10px;display:flex;gap:8px;
157
+ flex-wrap:wrap;align-items:center;border-radius:14px;
158
+ max-height:84px;overflow:auto;
159
+ animation:stripIn .25s ease-out}
160
+ @keyframes stripIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
161
  .preview-tile{display:flex;align-items:center;gap:8px;
162
  background:rgba(0,229,200,.06);border:1px solid var(--border-strong);
163
+ border-radius:10px;padding:4px 8px 4px 4px;font-size:11px;color:var(--text);
164
+ animation:tileIn .3s ease}
165
+ @keyframes tileIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
166
  .preview-tile img{width:38px;height:38px;border-radius:6px;object-fit:cover;
167
  box-shadow:0 0 8px rgba(0,229,200,.3)}
168
  .preview-tile .pdf-ico{width:38px;height:38px;border-radius:6px;display:grid;
169
  place-items:center;background:linear-gradient(135deg,#3a1a2a,#1a0a14);
170
  color:var(--magenta);font-family:var(--font-mono);font-size:11px;font-weight:700;
171
  box-shadow:var(--neon-magenta)}
172
+ .preview-tile .nm{max-width:140px;overflow:hidden;text-overflow:ellipsis;
173
  white-space:nowrap;font-family:var(--font-mono)}
174
+ .preview-tile .x{background:rgba(255,84,105,.12);border:1px solid rgba(255,84,105,.25);
175
+ color:#ffb0b0;font-size:14px;font-weight:bold;
176
+ cursor:pointer;padding:0;width:20px;height:20px;border-radius:50%;
177
+ display:grid;place-items:center;transition:all .15s;line-height:1}
178
+ .preview-tile .x:hover{background:var(--red);color:#fff;transform:scale(1.1);
179
+ box-shadow:0 0 8px rgba(255,84,105,.5)}
180
+ .attach-hint{font-size:11px;color:var(--muted-2);margin-left:6px;font-style:italic}
181
 
182
  #query-bar{margin:0 22px 8px;height:54px;
183
  display:flex;align-items:center;padding:0 12px;gap:10px;
 
293
  #co-synth:empty{display:none}
294
  #debate-audio{width:100%;margin-top:10px;height:32px;display:none}
295
 
296
+ /* COUNCIL PILL (minimized) */
297
  #council-pill{position:fixed;left:22px;top:22px;z-index:24;
298
  padding:8px 16px;border-radius:20px;background:rgba(6,18,26,.92);
299
  border:1px solid var(--border-strong);color:var(--text);font-size:12px;
 
304
  #council-pill .dot-mini{width:8px;height:8px;border-radius:50%;background:var(--magenta);
305
  box-shadow:var(--neon-magenta)}
306
 
307
+ /* ── NODE DETAIL POPOVER (Task 3) ─────────────────────── */
308
  #node-detail{position:fixed;width:320px;padding:18px;z-index:30;
309
  opacity:0;transform:translateY(8px);pointer-events:none;
310
  transition:opacity .25s,transform .25s;
311
  border:1px solid var(--border-strong);box-shadow:var(--neon-teal),var(--shadow)}
312
  #node-detail.show{opacity:1;transform:translateY(0);pointer-events:auto}
313
+ #node-detail.hidden{display:none}
314
  .nd-head{display:flex;justify-content:space-between;align-items:flex-start;
315
  margin-bottom:8px;gap:8px}
316
  .nd-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px;
 
318
  .nd-title .nd-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0;
319
  box-shadow:0 0 12px currentColor}
320
  .nd-close{background:none;border:none;color:var(--muted);font-size:20px;
321
+ cursor:pointer;padding:0;line-height:1;width:24px;height:24px}
322
  .nd-close:hover{color:var(--text)}
323
  .nd-type{font-size:10px;letter-spacing:1.6px;color:var(--cyan);
324
  text-transform:uppercase;margin-bottom:12px;font-family:var(--font-mono);
 
334
  .nd-payload{font-size:11px;color:var(--text);background:rgba(0,0,0,.25);
335
  padding:8px 10px;border-radius:8px;font-family:var(--font-mono);
336
  max-height:90px;overflow:auto;line-height:1.5;
337
+ border-left:2px solid var(--border-strong);white-space:pre-wrap;word-break:break-word}
338
  .nd-conns{font-size:11px;color:var(--muted);max-height:100px;overflow:auto}
339
  .nd-conn{display:flex;justify-content:space-between;padding:3px 0;
340
  border-bottom:1px solid rgba(0,229,200,.07)}
 
348
  border-left:3px solid var(--teal);padding:10px 18px;border-radius:10px;
349
  font-size:12px;color:var(--text);box-shadow:var(--neon-teal),var(--shadow);
350
  animation:toastIn .35s ease both,toastOut .4s ease 4.5s both;
351
+ backdrop-filter:blur(10px);max-width:480px}
352
  .toast.warn{border-left-color:var(--gold);box-shadow:var(--neon-gold),var(--shadow)}
353
  .toast.error{border-left-color:var(--red);box-shadow:0 0 14px rgba(255,84,105,.5)}
354
  .toast.info{border-left-color:var(--violet);box-shadow:var(--neon-violet),var(--shadow)}
 
367
  @keyframes hintFloat{0%,100%{opacity:.7;transform:translate(-50%,-50%)}
368
  50%{opacity:1;transform:translate(-50%,calc(-50% - 6px))}}
369
 
370
+ /* ── BUSY OVERLAY ─────────────────────────────────────── */
371
  #busy-overlay{position:fixed;inset:0;z-index:35;pointer-events:none;
372
  display:none;place-items:center;background:rgba(2,12,18,0);
373
  transition:background .3s}
 
383
  /* ── RESPONSIVE ───────────────────────────────────────── */
384
  @media (max-width:980px){
385
  #legend{width:200px;padding:14px;top:14px;right:14px}
386
+ #minimap-wrap{padding:5px;bottom:160px}
387
  #minimap{width:140px;height:90px}
388
+ #zoom-ctrl{right:14px;bottom:160px}
389
+ #stats{gap:20px;padding:0 16px;bottom:100px}
390
  .stat-value{font-size:18px}
391
  .stat-label{font-size:9px}
392
  #council-overlay{width:300px}
 
396
  #legend{width:170px;padding:12px;top:10px;right:10px;font-size:11px}
397
  #legend .lbl{display:none}
398
  .meta-row{font-size:10px}
399
+ #minimap-wrap{left:54px;bottom:140px}
400
  #minimap{width:120px;height:80px}
401
+ #stats{gap:14px;padding:0 12px;height:48px;bottom:96px}
402
  .stat-value{font-size:15px}
403
  .stat-icon{font-size:14px}
404
  #query-area{left:44px}
405
  .mode-row{padding-right:14px}
406
  #council-overlay{left:8px;right:8px;width:auto;top:8px;
407
+ max-height:calc(100vh - 240px)}
408
+ #zoom-ctrl{bottom:96px;right:10px}
409
+ #node-detail{width:280px}
410
  }
411
 
412
+ /* Hide any default Gradio surface */
413
  .gradio-container,footer,.built-with,gradio-app>.main>.contain{display:none!important}
414
 
415
  .hidden{display:none!important}
frontend/dist/assets/nodes.js CHANGED
@@ -1,133 +1,126 @@
1
- /* Bioluminescent node rendering — dark-neon theme to match reference images. */
2
- const TYPE_COLOR = {
3
- CORE: '#ffb840', // golden seed
4
- CIVILIZATION: '#ffb840',
5
- DOMAIN: '#a76bff', // violet
6
- AGENT: '#ff4fa3', // magenta
7
- TOOL: '#ff9b3c', // warm orange
8
- PROJECT: '#19d6ff', // cyan
9
- LIFE_EVENT: '#5cffae', // green
10
- EMOTION: '#ff80c4', // pink
11
- PERSON: '#ff70b8', // pink
12
- VALUE: '#7e5cff', // violet
13
- MEMORY: '#00e5c8', // teal
14
- FACT: '#19d6ff', // cyan
15
- CONCEPT: '#a76bff',
16
- QUERY: '#5cffae',
17
- };
18
- const DEFAULT_COLOR = '#a76bff';
19
 
20
- function shade(hex, amt) {
21
- const c = parseInt(hex.slice(1), 16);
22
- let r = (c >> 16) + amt, g = ((c >> 8) & 255) + amt, b = (c & 255) + amt;
23
- r = Math.max(0, Math.min(255, r));
24
- g = Math.max(0, Math.min(255, g));
25
- b = Math.max(0, Math.min(255, b));
26
- return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
27
- }
 
 
 
 
 
 
 
 
 
28
 
29
- window.colorFor = (type) => TYPE_COLOR[type] || DEFAULT_COLOR;
30
- window.TYPE_COLOR = TYPE_COLOR;
31
-
32
- window.drawNode = function (ctx, n, t, zoom) {
33
- const ageMs = t - (n.born || t);
34
- const age = Math.min(1, ageMs / 700);
35
- const k = age;
36
- const overshoot = age < 1
37
- ? 1 + 1.7 * (k - 1) ** 3 + 0.7 * (k - 1) ** 2
38
- : 1;
39
-
40
- const pulse = 1 + 0.06 * Math.sin((t / 1000) * 0.55 + (n.phase || 0));
41
- const r = n.radius * pulse * overshoot;
42
- const x = n.x, y = n.y;
43
 
44
- ctx.globalAlpha = age;
 
45
 
46
- // outer halo (soft glow)
47
- const halo = ctx.createRadialGradient(x, y, r * 0.9, x, y, r * 2.6);
48
- halo.addColorStop(0, n.color + 'aa');
49
- halo.addColorStop(0.5, n.color + '33');
50
- halo.addColorStop(1, n.color + '00');
51
- ctx.fillStyle = halo;
52
- ctx.beginPath(); ctx.arc(x, y, r * 2.6, 0, Math.PI * 2); ctx.fill();
 
 
 
 
 
53
 
54
- // rotating dashed orbital ring (CORE only)
55
- if (n.type === 'CORE' || n.type === 'CIVILIZATION') {
56
- ctx.save();
57
- ctx.translate(x, y);
58
- ctx.rotate(t * 0.0004);
59
- ctx.strokeStyle = n.color + '88';
60
- ctx.lineWidth = 1.5 / zoom;
61
- ctx.setLineDash([6 / zoom, 6 / zoom]);
62
- ctx.beginPath(); ctx.arc(0, 0, r * 1.85, 0, Math.PI * 2); ctx.stroke();
63
- ctx.setLineDash([]);
64
- ctx.restore();
65
- }
66
 
67
- // main sphere — neon gradient
68
- const grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
69
- grad.addColorStop(0, shade(n.color, 60));
70
- grad.addColorStop(0.5, n.color);
71
- grad.addColorStop(1, shade(n.color, -70));
 
 
72
 
73
- ctx.shadowColor = n.color;
74
- ctx.shadowBlur = 28 * pulse;
75
- ctx.fillStyle = grad;
76
- ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
77
- ctx.shadowBlur = 0;
 
 
 
 
 
 
 
78
 
79
- // specular highlight
80
- ctx.fillStyle = 'rgba(255,255,255,.5)';
81
- ctx.beginPath();
82
- ctx.ellipse(x - r * 0.32, y - r * 0.32, r * 0.28, r * 0.17, -0.5, 0, Math.PI * 2);
83
- ctx.fill();
 
 
 
 
 
84
 
85
- // selection ring
86
- if (n.selected) {
87
- ctx.strokeStyle = '#19d6ff';
88
- ctx.lineWidth = 2.5 / zoom;
89
- ctx.setLineDash([4 / zoom, 4 / zoom]);
90
- ctx.beginPath(); ctx.arc(x, y, r + 8 / zoom, 0, Math.PI * 2); ctx.stroke();
91
- ctx.setLineDash([]);
92
- }
93
 
94
- // counter badge
95
- if (n.count != null && zoom > 0.5) {
96
- const bx = x - r * 1.1, by = y - r * 0.8;
97
- ctx.fillStyle = 'rgba(0,0,0,.7)';
98
- ctx.strokeStyle = n.color + '88';
99
- ctx.lineWidth = 1 / zoom;
100
- if (ctx.roundRect) {
101
- ctx.beginPath();
102
- ctx.roundRect(bx - 12, by - 8, 22, 16, 8);
103
- ctx.fill(); ctx.stroke();
104
  }
105
- ctx.fillStyle = '#fff';
106
- ctx.font = `bold ${10}px Space Mono`;
107
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
108
- ctx.fillText(String(n.count), bx - 1, by);
109
- }
110
 
111
- // label
112
- if (zoom > 0.4 && n.label) {
113
- const fs = Math.min(13, 11 * Math.max(1, zoom * 0.85));
114
- // text shadow for legibility
115
- ctx.fillStyle = 'rgba(0,0,0,.6)';
116
- ctx.font = `500 ${fs / zoom}px Inter, sans-serif`;
117
- ctx.textAlign = 'center'; ctx.textBaseline = 'top';
118
- const lbl = n.label.length > 24 ? n.label.slice(0, 22) + '…' : n.label;
119
- ctx.fillText(lbl, x + 1 / zoom, y + r + 7 / zoom);
120
- ctx.fillStyle = '#d8f6fa';
121
- ctx.fillText(lbl, x, y + r + 6 / zoom);
122
- }
123
 
124
- // pulse highlight ring (from UI directive)
125
- if (n.pulseUntil && t < n.pulseUntil) {
126
- const p = (n.pulseUntil - t) / 1400;
127
- ctx.strokeStyle = `rgba(25,214,255,${p})`;
128
- ctx.lineWidth = 2.5 / zoom;
129
- ctx.beginPath(); ctx.arc(x, y, r + (1 - p) * 36, 0, Math.PI * 2); ctx.stroke();
130
- }
131
 
132
- ctx.globalAlpha = 1;
133
- };
 
 
1
+ /* ============================================================
2
+ ELYSIUM nodes.js
3
+ Bioluminescent node renderer (dark-neon theme).
4
+ Pure draw helpers; safe to call from animation loop.
5
+ ============================================================ */
6
+ (() => {
7
+ 'use strict';
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ const TYPE_COLOR = {
10
+ CORE: '#ffb840', // golden seed
11
+ CIVILIZATION: '#ffb840',
12
+ DOMAIN: '#a76bff', // violet
13
+ AGENT: '#ff4fa3', // magenta
14
+ TOOL: '#ff9b3c', // warm orange
15
+ PROJECT: '#19d6ff', // cyan
16
+ LIFE_EVENT: '#5cffae', // green
17
+ EMOTION: '#ff80c4', // pink
18
+ PERSON: '#ff70b8',
19
+ VALUE: '#7e5cff',
20
+ MEMORY: '#00e5c8', // teal
21
+ FACT: '#19d6ff',
22
+ CONCEPT: '#a76bff',
23
+ QUERY: '#5cffae',
24
+ };
25
+ const DEFAULT_COLOR = '#a76bff';
26
 
27
+ function shade(hex, amt) {
28
+ if (typeof hex !== 'string' || hex.length < 7) return hex || DEFAULT_COLOR;
29
+ const c = parseInt(hex.slice(1), 16);
30
+ let r = (c >> 16) + amt;
31
+ let g = ((c >> 8) & 255) + amt;
32
+ let b = (c & 255) + amt;
33
+ r = Math.max(0, Math.min(255, r));
34
+ g = Math.max(0, Math.min(255, g));
35
+ b = Math.max(0, Math.min(255, b));
36
+ return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
37
+ }
 
 
 
38
 
39
+ window.colorFor = (type) => TYPE_COLOR[type] || DEFAULT_COLOR;
40
+ window.TYPE_COLOR = TYPE_COLOR;
41
 
42
+ window.drawNode = function (ctx, n, t, zoom) {
43
+ if (!ctx || !n) return;
44
+ const ageMs = t - (n.born || t);
45
+ const age = Math.min(1, ageMs / 700);
46
+ const k = age;
47
+ const overshoot = age < 1
48
+ ? 1 + 1.7 * (k - 1) ** 3 + 0.7 * (k - 1) ** 2
49
+ : 1;
50
+ const pulse = 1 + 0.06 * Math.sin((t / 1000) * 0.55 + (n.phase || 0));
51
+ const r = Math.max(2, n.radius * pulse * overshoot);
52
+ const x = n.x, y = n.y;
53
+ const color = n.color || DEFAULT_COLOR;
54
 
55
+ ctx.globalAlpha = age;
 
 
 
 
 
 
 
 
 
 
 
56
 
57
+ // Outer halo
58
+ const halo = ctx.createRadialGradient(x, y, r * 0.9, x, y, r * 2.6);
59
+ halo.addColorStop(0, color + 'aa');
60
+ halo.addColorStop(0.5, color + '33');
61
+ halo.addColorStop(1, color + '00');
62
+ ctx.fillStyle = halo;
63
+ ctx.beginPath(); ctx.arc(x, y, r * 2.6, 0, Math.PI * 2); ctx.fill();
64
 
65
+ // Rotating dashed orbital ring (CORE only)
66
+ if (n.type === 'CORE' || n.type === 'CIVILIZATION') {
67
+ ctx.save();
68
+ ctx.translate(x, y);
69
+ ctx.rotate(t * 0.0004);
70
+ ctx.strokeStyle = color + '88';
71
+ ctx.lineWidth = 1.5 / zoom;
72
+ ctx.setLineDash([6 / zoom, 6 / zoom]);
73
+ ctx.beginPath(); ctx.arc(0, 0, r * 1.85, 0, Math.PI * 2); ctx.stroke();
74
+ ctx.setLineDash([]);
75
+ ctx.restore();
76
+ }
77
 
78
+ // Main sphere
79
+ const grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
80
+ grad.addColorStop(0, shade(color, 60));
81
+ grad.addColorStop(0.5, color);
82
+ grad.addColorStop(1, shade(color, -70));
83
+ ctx.shadowColor = color;
84
+ ctx.shadowBlur = 28 * pulse;
85
+ ctx.fillStyle = grad;
86
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
87
+ ctx.shadowBlur = 0;
88
 
89
+ // Specular highlight
90
+ ctx.fillStyle = 'rgba(255,255,255,.5)';
91
+ ctx.beginPath();
92
+ ctx.ellipse(x - r * 0.32, y - r * 0.32, r * 0.28, r * 0.17, -0.5, 0, Math.PI * 2);
93
+ ctx.fill();
 
 
 
94
 
95
+ // Selection ring
96
+ if (n.selected) {
97
+ ctx.strokeStyle = '#19d6ff';
98
+ ctx.lineWidth = 2.5 / zoom;
99
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
100
+ ctx.beginPath(); ctx.arc(x, y, r + 8 / zoom, 0, Math.PI * 2); ctx.stroke();
101
+ ctx.setLineDash([]);
 
 
 
102
  }
 
 
 
 
 
103
 
104
+ // Label
105
+ if (zoom > 0.4 && n.label) {
106
+ const fs = Math.min(13, 11 * Math.max(1, zoom * 0.85));
107
+ ctx.fillStyle = 'rgba(0,0,0,.6)';
108
+ ctx.font = `500 ${fs / zoom}px Inter, sans-serif`;
109
+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';
110
+ const lbl = n.label.length > 24 ? n.label.slice(0, 22) + '' : n.label;
111
+ ctx.fillText(lbl, x + 1 / zoom, y + r + 7 / zoom);
112
+ ctx.fillStyle = '#d8f6fa';
113
+ ctx.fillText(lbl, x, y + r + 6 / zoom);
114
+ }
 
115
 
116
+ // Pulse highlight ring (from UI directive)
117
+ if (n.pulseUntil && t < n.pulseUntil) {
118
+ const p = (n.pulseUntil - t) / 1400;
119
+ ctx.strokeStyle = `rgba(25,214,255,${p})`;
120
+ ctx.lineWidth = 2.5 / zoom;
121
+ ctx.beginPath(); ctx.arc(x, y, r + (1 - p) * 36, 0, Math.PI * 2); ctx.stroke();
122
+ }
123
 
124
+ ctx.globalAlpha = 1;
125
+ };
126
+ })();
frontend/dist/index.html CHANGED
@@ -59,7 +59,7 @@
59
  <!-- ALERT SCRIM (overlay tint for alert levels) -->
60
  <div id="alert-scrim"></div>
61
 
62
- <!-- STATS BAR — REPLACED with meaningful civilization metrics -->
63
  <section id="stats" class="glass">
64
  <div class="stat" title="Total nodes in your civilization mycelium">
65
  <div class="stat-icon">🌐</div>
 
59
  <!-- ALERT SCRIM (overlay tint for alert levels) -->
60
  <div id="alert-scrim"></div>
61
 
62
+ <!-- STATS BAR — meaningful civilization metrics -->
63
  <section id="stats" class="glass">
64
  <div class="stat" title="Total nodes in your civilization mycelium">
65
  <div class="stat-icon">🌐</div>