@lyric_dev/data-loom 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ // ── DOM refs ──
3
4
  const board = document.getElementById("board");
4
5
  const edgesSvg = document.getElementById("edges");
5
6
  const doneBand = document.getElementById("doneband");
@@ -11,42 +12,76 @@ const genEl = document.getElementById("gen");
11
12
  const conflictsEl = document.getElementById("conflicts");
12
13
  const reviewEl = document.getElementById("review");
13
14
  const projectSelect = document.getElementById("project-select");
15
+ const themeToggle = document.getElementById("theme-toggle");
16
+ const topoNodes = document.getElementById("topo-nodes");
17
+ const spokesSvg = document.getElementById("spokes");
14
18
 
19
+ // ── client state ──
15
20
  let projects = null;
16
21
  let model = null;
17
- let cardEls = new Map(); // change name -> card element
18
- let conflictedNames = new Set(); // change names involved in any conflict
19
- let nextUpNames = new Set(); // ready proposals in the earliest phase (recommended next)
22
+ let mcpModel = null;
23
+ let activeTab = "roadmap";
24
+ let scopeFilter = "all";
25
+ let selectedChange = null; // change name shown in the detail panel
26
+ let selectedServer = null; // server name shown in the detail panel
27
+ let nextUpNames = new Set(); // ready proposals in the earliest ready phase
28
+ let conflictedNames = new Set();
20
29
  let doneCollapsed = true;
21
- let detailChange = null; // name of the change shown in the detail panel
22
-
23
- document.getElementById("detail-close").addEventListener("click", () => {
24
- detail.classList.add("hidden");
25
- detailChange = null;
26
- });
27
- window.addEventListener("resize", () => {
28
- drawEdges();
29
- layoutTopology();
30
+ const liveOverride = new Map(); // server name -> transient liveness (e.g. "checking")
31
+
32
+ // ── roadmap graph geometry (fixed design canvas, 1180×600) ──
33
+ const G_MX = 150,
34
+ G_W = 1180,
35
+ N_HALF = 105,
36
+ N_TOP = 58,
37
+ V_SPACE = 170;
38
+
39
+ // ── theme ──
40
+ function applyTheme(t) {
41
+ // t: "light" | "dark" (light = blueprint-light default, dark = blueprint)
42
+ if (t === "dark") document.documentElement.dataset.theme = "dark";
43
+ else delete document.documentElement.dataset.theme;
44
+ const light = t !== "dark";
45
+ themeToggle.querySelector(".theme-icon").textContent = light ? "☾" : "☀";
46
+ themeToggle.querySelector(".theme-label").textContent = light ? "dark" : "light";
47
+ themeToggle.title = light ? "Switch to dark" : "Switch to light";
48
+ }
49
+ let theme = localStorage.getItem("dataloom-theme") === "dark" ? "dark" : "light";
50
+ applyTheme(theme);
51
+ themeToggle.addEventListener("click", () => {
52
+ theme = theme === "dark" ? "light" : "dark";
53
+ localStorage.setItem("dataloom-theme", theme);
54
+ applyTheme(theme);
30
55
  });
31
56
 
32
- // --- tab switching ---
33
- let activeTab = "roadmap";
57
+ // ── tab switching (active state re-derived from `activeTab`) ──
58
+ function syncTabs() {
59
+ for (const b of document.querySelectorAll(".tab[data-tab]")) b.classList.toggle("active", b.dataset.tab === activeTab);
60
+ for (const v of document.querySelectorAll(".view")) v.classList.toggle("active", v.id === activeTab);
61
+ }
34
62
  for (const btn of document.querySelectorAll(".tab[data-tab]")) {
35
63
  btn.addEventListener("click", () => {
36
64
  activeTab = btn.dataset.tab;
37
- for (const b of document.querySelectorAll(".tab")) b.classList.toggle("active", b === btn);
38
- for (const v of document.querySelectorAll(".view")) v.classList.toggle("active", v.id === activeTab);
39
- if (activeTab === "roadmap") requestAnimationFrame(drawEdges);
40
- if (activeTab === "topology") requestAnimationFrame(layoutTopology);
65
+ closeDetail();
66
+ syncTabs();
67
+ });
68
+ }
69
+
70
+ // ── scope filter ──
71
+ for (const chip of document.querySelectorAll(".scope-chip[data-scope]")) {
72
+ chip.addEventListener("click", () => {
73
+ scopeFilter = chip.dataset.scope;
74
+ for (const c of document.querySelectorAll(".scope-chip")) c.classList.toggle("active", c === chip);
75
+ renderTopology();
41
76
  });
42
77
  }
43
78
 
44
- // --- project selection ---
79
+ // ── project selection ──
45
80
  projectSelect.addEventListener("change", async () => {
46
81
  const path = projectSelect.value;
47
82
  try {
48
83
  const res = await fetch(`/api/project/select?path=${encodeURIComponent(path)}`, { method: "POST" });
49
- if (!res.ok) renderProjects(projects); // rejected — restore previous selection
84
+ if (!res.ok) renderProjects(projects);
50
85
  } catch (e) {
51
86
  console.error("project switch failed", e);
52
87
  renderProjects(projects);
@@ -57,11 +92,11 @@ function setProjects(p) {
57
92
  projects = p;
58
93
  renderProjects(p);
59
94
  if (!p.current) {
60
- // No active project — prompt the user to pick one.
61
- board.innerHTML = '<div class="empty-state">Select a project above to begin.</div>';
62
- edgesSvg.innerHTML = "";
95
+ clearBoard();
96
+ board.appendChild(el("div", "empty-state", "Select a project above to begin."));
63
97
  doneBand.classList.add("hidden");
64
98
  conflictsEl.classList.add("hidden");
99
+ reviewEl.classList.add("hidden");
65
100
  }
66
101
  }
67
102
 
@@ -102,13 +137,11 @@ function setConn(live) {
102
137
  connText.textContent = live ? "live" : "reconnecting…";
103
138
  }
104
139
 
140
+ // ═══════════════ ROADMAP ═══════════════
141
+
105
142
  function render(m) {
106
143
  model = m;
107
- // Close a stale detail panel if its change is gone (e.g. after a project switch).
108
- if (detailChange && !m.changes.some((c) => c.name === detailChange)) {
109
- detail.classList.add("hidden");
110
- detailChange = null;
111
- }
144
+ if (selectedChange && !m.changes.some((c) => c.name === selectedChange)) closeDetail();
112
145
  genEl.textContent = m.generatedAt ? "· " + new Date(m.generatedAt).toLocaleTimeString() : "";
113
146
  conflictedNames = new Set((m.conflicts || []).flatMap((c) => c.changes));
114
147
  renderConflicts(m.conflicts || []);
@@ -119,104 +152,154 @@ function render(m) {
119
152
  nextUpNames = new Set(ready.filter((c) => c.phase === minPhase).map((c) => c.name));
120
153
  renderBoard();
121
154
  renderDoneBand();
122
- // Wait a frame so layout settles before measuring for edges.
123
- requestAnimationFrame(drawEdges);
124
155
  }
125
156
 
126
157
  function renderConflicts(conflicts) {
158
+ conflictsEl.innerHTML = "";
127
159
  if (!conflicts.length) {
128
160
  conflictsEl.classList.add("hidden");
129
- conflictsEl.innerHTML = "";
130
161
  return;
131
162
  }
132
163
  conflictsEl.classList.remove("hidden");
133
- conflictsEl.innerHTML =
134
- `<div class="conflicts-head">⚠ ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""}</div>` +
135
- conflicts
136
- .map((c) => `<div class="conflict-item conflict-${c.type}">${escapeHtml(c.description)}</div>`)
137
- .join("");
164
+ conflictsEl.appendChild(el("span", "conflicts-mark", "⚠"));
165
+ const body = el("div");
166
+ body.appendChild(el("div", "conflicts-head", `${conflicts.length} ordering conflict${conflicts.length > 1 ? "s" : ""}`));
167
+ for (const c of conflicts) body.appendChild(el("div", "conflict-item", c.description));
168
+ conflictsEl.appendChild(body);
138
169
  }
139
170
 
140
171
  function renderReview() {
141
- // Open changes that have never been reviewed for dependencies.
172
+ reviewEl.innerHTML = "";
142
173
  const pending = model.changes.filter((c) => !c.archived && c.dependencyReview === "pending");
143
174
  const n = pending.length;
144
175
  if (!n) {
145
176
  reviewEl.classList.add("hidden");
146
- reviewEl.innerHTML = "";
147
177
  return;
148
178
  }
149
179
  reviewEl.classList.remove("hidden");
180
+ reviewEl.appendChild(el("span", "review-badge", String(n)));
181
+ const txt = el("span", "review-text");
150
182
  const subject = n > 1 ? "proposals need" : "proposal needs";
151
- reviewEl.innerHTML =
152
- `<span class="review-badge">${n}</span>` +
153
- `<span>${subject} dependency review — ask Claude to review ${n > 1 ? "them" : "it"} via the data-loom MCP server.</span>`;
183
+ txt.innerHTML = `${n} ${subject} dependency review — ask Claude to <b>weave</b> ${n > 1 ? "them" : "it"} via the data-loom MCP server.`;
184
+ reviewEl.appendChild(txt);
185
+ }
186
+
187
+ function clearBoard() {
188
+ for (const ch of [...board.children]) if (ch !== edgesSvg) ch.remove();
189
+ edgesSvg.innerHTML = "";
154
190
  }
155
191
 
156
192
  function renderBoard() {
157
- board.innerHTML = "";
158
- cardEls = new Map();
193
+ clearBoard();
194
+ if (!model) return;
159
195
 
160
- if (!model.phases.length) {
161
- board.innerHTML = '<div class="empty-state">No active changes yet. Propose one to populate the roadmap.</div>';
196
+ const open = model.changes.filter((c) => !c.archived);
197
+ if (!open.length) {
198
+ board.appendChild(el("div", "empty-state", "No active changes yet. Propose one to populate the roadmap."));
162
199
  return;
163
200
  }
164
201
 
165
- const byName = new Map(model.changes.map((c) => [c.name, c]));
166
- model.phases.forEach((phase, i) => {
167
- if (i > 0) board.appendChild(el("div", "phase-arrow", "↓"));
168
- const band = el("div", "phase-band");
169
- band.appendChild(el("div", "phase-head", `Phase <b>${phase.phase}</b>`));
170
- const row = el("div", "phase-band-cards");
171
- for (const name of phase.changeNames) {
172
- const c = byName.get(name);
173
- if (c) row.appendChild(renderCard(c));
174
- }
175
- band.appendChild(row);
176
- board.appendChild(band);
202
+ const phaseNums = [...new Set(open.map((c) => c.phase))].sort((a, b) => a - b);
203
+ const span = (G_W - 2 * G_MX) / Math.max(phaseNums.length - 1, 1);
204
+ const gx = (p) => G_MX + phaseNums.indexOf(p) * span;
205
+
206
+ // Grow the canvas vertically so the tallest phase always fits (real projects
207
+ // can stack many cards in one phase). CARD_BAND leaves headroom for the
208
+ // tallest card plus the frame header and top/bottom margins.
209
+ const CARD_BAND = 340;
210
+ const maxN = Math.max(1, ...phaseNums.map((p) => open.filter((c) => c.phase === p).length));
211
+ const canvasH = Math.max(600, (maxN - 1) * V_SPACE + CARD_BAND);
212
+ const cy = canvasH / 2;
213
+ board.style.height = canvasH + "px";
214
+ edgesSvg.setAttribute("viewBox", `0 0 ${G_W} ${canvasH}`);
215
+
216
+ // Phase-band frames (behind everything), sized to the canvas.
217
+ phaseNums.forEach((p, i) => {
218
+ const frame = el("div", "phase-frame");
219
+ frame.style.left = gx(p) - 117 + "px";
220
+ frame.style.height = canvasH - 80 + "px";
221
+ const head = el("div", "phase-frame-head");
222
+ head.appendChild(el("span", "phase-frame-title", "PHASE " + p));
223
+ head.appendChild(el("span", "phase-frame-sub", i === 0 ? "ready" : i === phaseNums.length - 1 ? "final" : "blocked"));
224
+ frame.appendChild(head);
225
+ board.insertBefore(frame, edgesSvg);
226
+ });
227
+
228
+ // Proposal cards (computed positions), each phase's stack centered on the canvas.
229
+ const pos = {};
230
+ phaseNums.forEach((p) => {
231
+ const items = open.filter((c) => c.phase === p);
232
+ items.forEach((c, i) => {
233
+ const x = gx(p);
234
+ const y = cy + (i - (items.length - 1) / 2) * V_SPACE;
235
+ pos[c.name] = { x, y };
236
+ const card = renderCard(c);
237
+ card.style.left = x - N_HALF + "px";
238
+ card.style.top = y - N_TOP + "px";
239
+ board.appendChild(card);
240
+ });
177
241
  });
242
+
243
+ drawEdges(open, pos);
178
244
  }
179
245
 
180
246
  function renderCard(c) {
181
- const warn = conflictedNames.has(c.name) || c.unsatisfiedDependencies.length > 0;
182
- const next = nextUpNames.has(c.name);
183
- const review = !c.archived && c.dependencyReview === "pending";
184
- const card = el("div", `card s-${c.status}${warn ? " warn" : ""}${next ? " next" : ""}${review ? " needs-review" : ""}`);
185
- card.appendChild(el("div", "card-name", c.name));
186
-
187
- const meta = el("div", "card-meta");
188
- meta.appendChild(el("span", `pill s-${c.status}`, c.status));
189
- if (!c.archived && c.readiness !== "done") {
190
- meta.appendChild(el("span", `pill r-${c.readiness}`, c.readiness));
191
- }
192
- if (review) {
193
- meta.appendChild(el("span", "pill review", "review?"));
247
+ const card = el("div", "gcard" + (selectedChange === c.name ? " selected" : ""));
248
+ const bar = el("div", "gcard-bar");
249
+ bar.style.background = statusColor(c.status);
250
+ card.appendChild(bar);
251
+
252
+ const body = el("div", "gcard-body");
253
+ body.appendChild(el("div", "gcard-name", c.name));
254
+
255
+ const pills = el("div", "gcard-pills");
256
+ const sp = el("span", "gpill", c.status);
257
+ sp.style.color = statusColor(c.status);
258
+ pills.appendChild(sp);
259
+ if (c.readiness === "ready") pills.appendChild(el("span", "gpill ready", "ready"));
260
+ if (c.dependencyReview === "pending") pills.appendChild(el("span", "gpill review", "review?"));
261
+ body.appendChild(pills);
262
+
263
+ if (c.readiness === "blocked" && c.dependsOn.length) {
264
+ body.appendChild(el("div", "gcard-waiting", "⏳ waiting on " + c.dependsOn.join(", ")));
194
265
  }
195
- if (c.totalTasks > 0) {
196
- meta.appendChild(el("span", "tasks-mini", `${c.completedTasks}/${c.totalTasks} tasks`));
197
- }
198
- card.appendChild(meta);
199
266
 
200
- if (c.readiness === "blocked") {
201
- const waiting = c.dependsOn.filter((d) => {
202
- const dep = model.changes.find((x) => x.name === d);
203
- return !dep || dep.status !== "done";
204
- });
205
- if (waiting.length) card.appendChild(el("div", "blocked-note", "waiting on " + waiting.map(escapeHtml).join(", ")));
267
+ const capsParts = [];
268
+ if (c.newCapabilities.length) capsParts.push(`+${c.newCapabilities.length} new`);
269
+ if (c.modifiedCapabilities.length) capsParts.push(`~${c.modifiedCapabilities.length} mod`);
270
+ if (capsParts.length || c.totalTasks > 0) {
271
+ const tasks = c.totalTasks > 0 ? `${c.completedTasks}/${c.totalTasks} tasks` : "";
272
+ const text = [capsParts.join(" "), tasks].filter(Boolean).join(" · ");
273
+ body.appendChild(el("div", "gcard-caps", text));
206
274
  }
275
+ card.appendChild(body);
207
276
 
208
- const caps = el("div", "caps");
209
- const parts = [];
210
- if (c.newCapabilities.length) parts.push(`<span class="add">+${c.newCapabilities.length} new</span>`);
211
- if (c.modifiedCapabilities.length) parts.push(`<span class="mod">~${c.modifiedCapabilities.length} mod</span>`);
212
- caps.innerHTML = parts.join(" &nbsp; ");
213
- if (parts.length) card.appendChild(caps);
277
+ if (nextUpNames.has(c.name)) card.appendChild(el("div", "gcard-next", "NEXT UP"));
214
278
 
215
- card.addEventListener("click", () => showDetail(c));
216
- cardEls.set(c.name, card);
279
+ card.addEventListener("click", () => selectChange(c));
217
280
  return card;
218
281
  }
219
282
 
283
+ function drawEdges(open, pos) {
284
+ let s =
285
+ '<defs><marker id="dep-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">' +
286
+ '<path d="M1 1L8 5L1 9" fill="none" stroke="var(--edge)" stroke-width="1.6"/></marker></defs>';
287
+ for (const c of open) {
288
+ for (const dep of c.dependsOn) {
289
+ const a = pos[dep];
290
+ const b = pos[c.name];
291
+ if (!a || !b) continue;
292
+ const x1 = a.x + N_HALF,
293
+ y1 = a.y,
294
+ x2 = b.x - N_HALF,
295
+ y2 = b.y,
296
+ mx = (x1 + x2) / 2;
297
+ s += `<path d="M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}" fill="none" stroke="var(--edge)" stroke-width="1.6" stroke-dasharray="5 4" marker-end="url(#dep-arrow)"/>`;
298
+ }
299
+ }
300
+ edgesSvg.innerHTML = s;
301
+ }
302
+
220
303
  function renderDoneBand() {
221
304
  const archived = model.changes.filter((c) => c.archived);
222
305
  if (!archived.length) {
@@ -225,214 +308,346 @@ function renderDoneBand() {
225
308
  }
226
309
  doneBand.classList.remove("hidden");
227
310
  doneBand.innerHTML = "";
228
- const head = el("div", "doneband-head", `${doneCollapsed ? "▸" : "▾"} Done — ${archived.length} archived`);
311
+ const head = el("div", "doneband-head", `${doneCollapsed ? "▸" : "▾"} archived — ${archived.length} done`);
229
312
  head.addEventListener("click", () => {
230
313
  doneCollapsed = !doneCollapsed;
231
314
  renderDoneBand();
232
- requestAnimationFrame(drawEdges);
233
315
  });
234
316
  doneBand.appendChild(head);
235
- const items = el("div", `doneband-items${doneCollapsed ? " collapsed" : ""}`);
317
+ const items = el("div", "doneband-items" + (doneCollapsed ? " collapsed" : ""));
236
318
  for (const c of archived) {
237
319
  const chip = el("div", "done-chip", c.name);
238
- chip.addEventListener("click", () => showDetail(c));
320
+ chip.addEventListener("click", () => selectChange(c));
239
321
  items.appendChild(chip);
240
322
  }
241
323
  doneBand.appendChild(items);
242
324
  }
243
325
 
244
- function drawEdges() {
245
- edgesSvg.innerHTML =
246
- '<defs><marker id="dep-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">' +
247
- '<path d="M2 1L8 5L2 9" fill="none" stroke="var(--edge)" stroke-width="1.5"/></marker></defs>';
248
- if (!model) return;
249
- const wrap = board.parentElement.getBoundingClientRect();
250
- for (const c of model.changes) {
251
- if (c.archived) continue;
252
- const toEl = cardEls.get(c.name);
253
- if (!toEl) continue;
254
- for (const depName of c.dependsOn) {
255
- const fromEl = cardEls.get(depName);
256
- if (!fromEl) continue;
257
- const f = fromEl.getBoundingClientRect();
258
- const t = toEl.getBoundingClientRect();
259
- let d;
260
- if (t.top >= f.bottom - 4) {
261
- // dependent sits in a lower band -> vertical connector (bottom -> top)
262
- const x1 = f.left - wrap.left + f.width / 2;
263
- const y1 = f.bottom - wrap.top;
264
- const x2 = t.left - wrap.left + t.width / 2;
265
- const y2 = t.top - wrap.top;
266
- const my = (y1 + y2) / 2;
267
- d = `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
268
- } else {
269
- // same band -> horizontal connector (right -> left)
270
- const x1 = f.right - wrap.left;
271
- const y1 = f.top - wrap.top + f.height / 2;
272
- const x2 = t.left - wrap.left;
273
- const y2 = t.top - wrap.top + t.height / 2;
274
- const mx = (x1 + x2) / 2;
275
- d = `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`;
276
- }
277
- const p = path(d);
278
- p.setAttribute("stroke-dasharray", "5 4");
279
- p.setAttribute("marker-end", "url(#dep-arrow)");
280
- edgesSvg.appendChild(p);
281
- }
282
- }
283
- }
284
-
285
- function showDetail(c) {
286
- detail.classList.remove("hidden");
287
- detailChange = c.name;
288
- detailBody.innerHTML = `
289
- <h2>${escapeHtml(c.name)}</h2>
290
- <div class="card-meta">
291
- <span class="pill s-${c.status}">${c.status}</span>
292
- ${c.archived ? '<span class="pill">archived</span>' : `<span class="pill">Phase ${c.phase}</span>`}
293
- ${c.totalTasks > 0 ? `<span class="tasks-mini">${c.completedTasks}/${c.totalTasks} tasks</span>` : ""}
294
- </div>
295
- <h3>New capabilities</h3>${list(c.newCapabilities)}
296
- <h3>Modified capabilities</h3>${list(c.modifiedCapabilities)}
297
- <h3>Depends on</h3>${list(c.dependsOn)}
298
- ${c.unsatisfiedDependencies.length ? `<h3>⚠ Unsatisfied</h3>${list(c.unsatisfiedDependencies)}` : ""}
299
- `;
300
- }
301
-
302
- // --- MCP topology (HOW tab) ---
303
- let mcpModel = null;
304
- const topoNodes = document.getElementById("topo-nodes");
305
- const spokesSvg = document.getElementById("spokes");
306
- let hubEl = null;
307
- let serverEls = new Map();
326
+ // ═══════════════ MCP TOPOLOGY (circuit board) ═══════════════
308
327
 
309
328
  function setMcp(m) {
310
329
  mcpModel = m;
330
+ if (selectedServer && !m.servers.some((s) => s.name === selectedServer)) closeDetail();
311
331
  renderTopology();
332
+ if (selectedServer) renderServerDetail();
312
333
  }
313
334
 
314
335
  function updateMcpServer(server) {
315
336
  if (!mcpModel) return;
337
+ liveOverride.delete(server.name); // real result arrived — drop transient state
316
338
  const i = mcpModel.servers.findIndex((s) => s.name === server.name);
317
339
  if (i >= 0) mcpModel.servers[i] = server;
318
340
  else mcpModel.servers.push(server);
319
341
  renderTopology();
342
+ if (selectedServer === server.name) renderServerDetail();
320
343
  }
321
344
 
322
- function renderTopology() {
323
- if (!mcpModel) return;
324
- topoNodes.innerHTML = "";
325
- serverEls = new Map();
345
+ function liveOf(s) {
346
+ return liveOverride.get(s.name) || s.liveness;
347
+ }
348
+ function visibleServers() {
349
+ if (!mcpModel) return [];
350
+ return scopeFilter === "all" ? mcpModel.servers : mcpModel.servers.filter((s) => s.scope === scopeFilter);
351
+ }
326
352
 
327
- hubEl = el("div", "topo-node hub", `<div class="topo-name">${escapeHtml(mcpModel.hub)}</div><div class="topo-sub">hub</div>`);
328
- topoNodes.appendChild(hubEl);
353
+ function clearTopo() {
354
+ for (const ch of [...topoNodes.children]) if (ch !== spokesSvg) ch.remove();
355
+ spokesSvg.innerHTML = "";
356
+ }
329
357
 
330
- if (!mcpModel.servers.length) {
331
- const empty = el("div", "topo-empty", "No MCP servers found in your Claude Code config.");
332
- topoNodes.appendChild(empty);
358
+ function renderTopology() {
359
+ if (!mcpModel) return;
360
+ clearTopo();
361
+
362
+ // MCU chip (hub).
363
+ const mcu = el("div", "mcu");
364
+ mcu.appendChild(el("div", "mcu-name", mcpModel.hub || "Claude Code"));
365
+ mcu.appendChild(el("div", "mcu-sub", "MCU · hub"));
366
+ topoNodes.appendChild(mcu);
367
+
368
+ const vis = visibleServers();
369
+ if (!vis.length) {
370
+ topoNodes.appendChild(
371
+ el("div", "topo-empty", mcpModel.servers.length ? "No servers match this scope." : "No MCP servers found in your Claude Code config.")
372
+ );
373
+ return;
333
374
  }
334
375
 
335
- for (const s of mcpModel.servers) {
336
- const node = el("div", `topo-node srv live-${s.liveness} scope-${s.scope}`);
337
- node.innerHTML = `
338
- <div class="topo-name">${escapeHtml(s.name)}</div>
339
- <div class="topo-sub">${escapeHtml(s.transport)}${s.command ? " · " + escapeHtml(s.command) : ""}${s.url ? " · " + escapeHtml(s.url) : ""}</div>
340
- <div class="topo-state"><span class="live-dot"></span>${escapeHtml(livenessLabel(s.liveness))}${s.lastChecked ? " · " + new Date(s.lastChecked).toLocaleTimeString() : ""}</div>
341
- <button class="topo-check" type="button">check</button>`;
342
- node.querySelector(".topo-check").addEventListener("click", (e) => {
343
- e.stopPropagation();
344
- triggerCheck(s.name, node);
345
- });
346
- serverEls.set(s.name, node);
347
- topoNodes.appendChild(node);
348
- }
349
- layoutTopology();
350
- }
351
-
352
- function layoutTopology() {
353
- if (!mcpModel || !hubEl) return;
354
- const rect = topoNodes.getBoundingClientRect();
355
- if (!rect.width) return; // hidden tab re-laid out on switch
356
- const cx = rect.width / 2;
357
- const cy = rect.height / 2;
358
- place(hubEl, cx, cy);
359
- const n = mcpModel.servers.length;
360
- const R = Math.max(140, Math.min(rect.width, rect.height) / 2 - 130);
361
- mcpModel.servers.forEach((s, i) => {
362
- const node = serverEls.get(s.name);
363
- if (!node) return;
364
- const ang = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
365
- place(node, cx + Math.cos(ang) * R, cy + Math.sin(ang) * R);
376
+ const PCB_CX = 600,
377
+ PCB_CY = 320,
378
+ CHW = 152;
379
+ const half = Math.ceil(vis.length / 2);
380
+ const leftBank = vis.slice(0, half);
381
+ const rightBank = vis.slice(half);
382
+ const pinSp = (n) => Math.min(26, 120 / Math.max(n, 1));
383
+ const yOf = (n, i, top, bot) => (n <= 1 ? (top + bot) / 2 : top + i * ((bot - top) / (n - 1)));
384
+ const lineFor = (sc) => (sc === "global" ? "var(--line-a)" : "var(--line-b)");
385
+ const liveUp = (s) => ["available", "already-running"].includes(liveOf(s));
386
+
387
+ const traces = [];
388
+
389
+ leftBank.forEach((s, i) => {
390
+ const yc = yOf(leftBank.length, i, 72, 568),
391
+ padX = 346;
392
+ const pinY = PCB_CY + (i - (leftBank.length - 1) / 2) * pinSp(leftBank.length),
393
+ sx = PCB_CX - CHW / 2,
394
+ midX = 452 - i * 22;
395
+ placeComp(s, 150, yc - 28);
396
+ traces.push({ d: `M ${sx} ${pinY} H ${midX} V ${yc} H ${padX}`, color: lineFor(s.scope), dash: s.scope === "project", flow: liveUp(s), px: sx, py: pinY, qx: padX, qy: yc });
366
397
  });
367
- drawSpokes();
398
+ rightBank.forEach((s, i) => {
399
+ const yc = yOf(rightBank.length, i, 72, 568),
400
+ padX = 854;
401
+ const pinY = PCB_CY + (i - (rightBank.length - 1) / 2) * pinSp(rightBank.length),
402
+ ex = PCB_CX + CHW / 2,
403
+ midX = 748 + i * 22;
404
+ placeComp(s, 854, yc - 28);
405
+ traces.push({ d: `M ${ex} ${pinY} H ${midX} V ${yc} H ${padX}`, color: lineFor(s.scope), dash: s.scope === "project", flow: liveUp(s), px: ex, py: pinY, qx: padX, qy: yc });
406
+ });
407
+
408
+ drawTraces(traces);
368
409
  }
369
410
 
370
- function place(node, x, y) {
371
- node.style.left = x + "px";
372
- node.style.top = y + "px";
411
+ function placeComp(s, left, top) {
412
+ const comp = el("div", "pcb-comp" + (selectedServer === s.name ? " selected" : ""));
413
+ comp.style.left = left + "px";
414
+ comp.style.top = top + "px";
415
+
416
+ const topRow = el("div", "pcb-comp-top");
417
+ const led = el("span", "pcb-led");
418
+ const color = livenessColor(liveOf(s));
419
+ led.style.background = color;
420
+ led.style.boxShadow = "0 0 6px " + color;
421
+ topRow.appendChild(led);
422
+ topRow.appendChild(el("span", "pcb-comp-name", s.name));
423
+ comp.appendChild(topRow);
424
+ comp.appendChild(el("div", "pcb-comp-sub", `${s.transport} · ${s.scope} · ${livenessLabel(liveOf(s))}`));
425
+
426
+ comp.addEventListener("click", () => selectServer(s));
427
+ topoNodes.appendChild(comp);
373
428
  }
374
429
 
375
- function drawSpokes() {
376
- spokesSvg.innerHTML = "";
377
- if (!hubEl) return;
378
- const wrap = topoNodes.parentElement.getBoundingClientRect();
379
- const h = hubEl.getBoundingClientRect();
380
- const hx = h.left - wrap.left + h.width / 2;
381
- const hy = h.top - wrap.top + h.height / 2;
382
- for (const [name, node] of serverEls) {
383
- const r = node.getBoundingClientRect();
384
- const x = r.left - wrap.left + r.width / 2;
385
- const y = r.top - wrap.top + r.height / 2;
386
- const server = mcpModel.servers.find((s) => s.name === name);
387
- const p = path(`M ${hx} ${hy} L ${x} ${y}`);
388
- if (server && server.scope === "project") p.setAttribute("stroke-dasharray", "5 4");
389
- spokesSvg.appendChild(p);
430
+ function drawTraces(traces) {
431
+ let s = "";
432
+ // base traces + solder pads
433
+ for (const t of traces) {
434
+ s += `<path d="${t.d}" fill="none" stroke="${t.color}" stroke-width="2"${t.dash ? ' stroke-dasharray="5 4"' : ""} opacity="0.45"/>`;
435
+ s += `<circle cx="${t.px}" cy="${t.py}" r="3.5" fill="${t.color}"/>`;
436
+ s += `<circle cx="${t.qx}" cy="${t.qy}" r="3.5" fill="${t.color}"/>`;
437
+ }
438
+ // live traffic pulse on reachable links
439
+ for (const t of traces) {
440
+ if (t.flow) s += `<path d="${t.d}" fill="none" stroke="${t.color}" stroke-width="2.4" stroke-dasharray="2 13" style="animation:lm-dash 1s linear infinite"/>`;
390
441
  }
442
+ spokesSvg.innerHTML = s;
391
443
  }
392
444
 
393
- async function triggerCheck(name, node) {
394
- node.className = node.className.replace(/live-\S+/g, "") + " live-checking";
395
- const stateEl = node.querySelector(".topo-state");
396
- if (stateEl) stateEl.innerHTML = '<span class="live-dot"></span>checking…';
445
+ async function triggerCheck(name) {
446
+ liveOverride.set(name, "checking");
447
+ renderTopology();
448
+ if (selectedServer === name) renderServerDetail();
397
449
  try {
398
450
  await fetch(`/api/mcp/check?name=${encodeURIComponent(name)}`, { method: "POST" });
399
451
  // result arrives via the ws 'mcpServer' push -> updateMcpServer re-renders
400
452
  } catch (e) {
401
453
  console.error("check failed", e);
454
+ liveOverride.delete(name);
455
+ renderTopology();
456
+ }
457
+ }
458
+
459
+ // ═══════════════ DETAIL PANELS ═══════════════
460
+
461
+ function selectChange(c) {
462
+ if (selectedChange === c.name) {
463
+ closeDetail();
464
+ return;
465
+ }
466
+ selectedChange = c.name;
467
+ selectedServer = null;
468
+ renderChangeDetail(c);
469
+ renderBoard();
470
+ }
471
+
472
+ function selectServer(s) {
473
+ if (selectedServer === s.name) {
474
+ closeDetail();
475
+ return;
402
476
  }
477
+ selectedServer = s.name;
478
+ selectedChange = null;
479
+ renderServerDetail();
480
+ renderTopology();
403
481
  }
404
482
 
405
- function livenessLabel(s) {
406
- return {
407
- unknown: "unknown",
408
- checking: "checking…",
409
- available: "available",
410
- "needs-auth": "needs auth",
411
- unreachable: "unreachable",
412
- "on-demand": "on-demand",
413
- "already-running": "running",
414
- }[s] || s;
483
+ function closeDetail() {
484
+ detail.classList.add("hidden");
485
+ const reChange = selectedChange != null;
486
+ const reServer = selectedServer != null;
487
+ selectedChange = null;
488
+ selectedServer = null;
489
+ if (reChange && model) renderBoard();
490
+ if (reServer && mcpModel) renderTopology();
415
491
  }
416
492
 
417
- // --- tiny DOM helpers ---
418
- function el(tag, cls, html) {
493
+ function openPanel(server) {
494
+ detail.classList.remove("hidden");
495
+ detail.classList.toggle("server", !!server);
496
+ // re-trigger slide-in animation
497
+ detail.style.animation = "none";
498
+ void detail.offsetWidth;
499
+ detail.style.animation = "";
500
+ }
501
+
502
+ function detailHeader(name) {
503
+ const head = el("div", "detail-head");
504
+ head.appendChild(el("div", "detail-name", name));
505
+ const close = el("button", "detail-close", "×");
506
+ close.addEventListener("click", closeDetail);
507
+ head.appendChild(close);
508
+ return head;
509
+ }
510
+
511
+ function renderChangeDetail(c) {
512
+ openPanel(false);
513
+ detailBody.innerHTML = "";
514
+ const inner = el("div", "detail-inner");
515
+ inner.appendChild(detailHeader(c.name));
516
+
517
+ const pills = el("div", "detail-pills");
518
+ const sp = el("span", "detail-pill", c.status);
519
+ sp.style.color = statusColor(c.status);
520
+ pills.appendChild(sp);
521
+ pills.appendChild(el("span", "detail-pill", c.archived ? "archived" : "phase " + c.phase));
522
+ const rp = el("span", "detail-pill", c.readiness);
523
+ rp.style.color = "var(--accent)";
524
+ pills.appendChild(rp);
525
+ inner.appendChild(pills);
526
+
527
+ if (c.totalTasks > 0) {
528
+ const pct = Math.round((c.completedTasks / c.totalTasks) * 100);
529
+ const tasks = el("div", "detail-tasks");
530
+ const row = el("div", "detail-tasks-row");
531
+ row.appendChild(el("span", null, "tasks"));
532
+ row.appendChild(el("span", null, `${c.completedTasks}/${c.totalTasks} · ${pct}%`));
533
+ tasks.appendChild(row);
534
+ const track = el("div", "detail-tasks-track");
535
+ const fill = el("div", "detail-tasks-fill");
536
+ fill.style.width = pct + "%";
537
+ track.appendChild(fill);
538
+ tasks.appendChild(track);
539
+ inner.appendChild(tasks);
540
+ }
541
+
542
+ inner.appendChild(el("div", "detail-section first", "Depends on"));
543
+ if (c.dependsOn.length) inner.appendChild(itemList(c.dependsOn));
544
+ else inner.appendChild(el("div", "detail-none", "— independent —"));
545
+
546
+ inner.appendChild(el("div", "detail-section", "New capabilities"));
547
+ if (c.newCapabilities.length) inner.appendChild(itemList(c.newCapabilities, "add", "+ "));
548
+ else inner.appendChild(el("div", "detail-none", "—"));
549
+
550
+ if (c.modifiedCapabilities.length) {
551
+ inner.appendChild(el("div", "detail-section", "Modified capabilities"));
552
+ inner.appendChild(itemList(c.modifiedCapabilities, "mod", "~ "));
553
+ }
554
+ if (c.unsatisfiedDependencies.length) {
555
+ inner.appendChild(el("div", "detail-section warn", "⚠ Unsatisfied"));
556
+ inner.appendChild(itemList(c.unsatisfiedDependencies, "unsat"));
557
+ }
558
+
559
+ detailBody.appendChild(inner);
560
+ }
561
+
562
+ function renderServerDetail() {
563
+ const s = mcpModel && mcpModel.servers.find((x) => x.name === selectedServer);
564
+ if (!s) return;
565
+ openPanel(true);
566
+ detailBody.innerHTML = "";
567
+ const inner = el("div", "detail-inner");
568
+ inner.appendChild(detailHeader(s.name));
569
+
570
+ const lv = liveOf(s);
571
+ const live = el("div", "detail-live");
572
+ const dot = el("span", "detail-live-dot");
573
+ const color = livenessColor(lv);
574
+ dot.style.background = color;
575
+ dot.style.boxShadow = "0 0 8px " + color;
576
+ live.appendChild(dot);
577
+ live.appendChild(el("span", "detail-live-label", livenessLabel(lv)));
578
+ inner.appendChild(live);
579
+
580
+ const rows = el("div", "detail-rows");
581
+ rows.appendChild(detailRow("transport", s.transport));
582
+ rows.appendChild(detailRow("scope", s.scope));
583
+ rows.appendChild(detailRow("source", s.detail || s.source || "—"));
584
+ inner.appendChild(rows);
585
+
586
+ const btn = el("button", "detail-check", "check availability");
587
+ btn.addEventListener("click", () => triggerCheck(s.name));
588
+ inner.appendChild(btn);
589
+
590
+ inner.appendChild(
591
+ el("p", "detail-note", "Passive probe only — DataLoom never starts a server. It reports what's reachable so you know what to launch yourself.")
592
+ );
593
+
594
+ detailBody.appendChild(inner);
595
+ }
596
+
597
+ function detailRow(k, v) {
598
+ const row = el("div", "detail-row");
599
+ row.appendChild(el("span", "k", k));
600
+ row.appendChild(el("span", "v", v));
601
+ return row;
602
+ }
603
+
604
+ function itemList(items, cls, prefix) {
605
+ const list = el("div", "detail-list");
606
+ for (const it of items) list.appendChild(el("span", "detail-item" + (cls ? " " + cls : ""), (prefix || "") + it));
607
+ return list;
608
+ }
609
+
610
+ // ═══════════════ helpers ═══════════════
611
+
612
+ function statusColor(status) {
613
+ return status === "in-progress" ? "var(--c-progress)" : status === "done" ? "var(--c-done)" : "var(--c-draft)";
614
+ }
615
+
616
+ function livenessColor(l) {
617
+ return (
618
+ {
619
+ available: "var(--c-done)",
620
+ "already-running": "var(--c-done)",
621
+ "needs-auth": "var(--c-progress)",
622
+ unreachable: "var(--c-warn)",
623
+ "on-demand": "var(--accent)",
624
+ checking: "var(--c-progress)",
625
+ unknown: "var(--muted)",
626
+ }[l] || "var(--muted)"
627
+ );
628
+ }
629
+
630
+ function livenessLabel(l) {
631
+ return (
632
+ {
633
+ unknown: "unknown",
634
+ checking: "checking…",
635
+ available: "available",
636
+ "needs-auth": "needs auth",
637
+ unreachable: "unreachable",
638
+ "on-demand": "on-demand",
639
+ "already-running": "running",
640
+ }[l] || l
641
+ );
642
+ }
643
+
644
+ function el(tag, cls, text) {
419
645
  const e = document.createElement(tag);
420
646
  if (cls) e.className = cls;
421
- if (html !== undefined) e.innerHTML = html;
647
+ if (text != null) e.textContent = text;
422
648
  return e;
423
649
  }
424
- function path(d) {
425
- const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
426
- p.setAttribute("d", d);
427
- p.setAttribute("fill", "none");
428
- p.setAttribute("stroke", "var(--edge)");
429
- p.setAttribute("stroke-width", "1.6");
430
- return p;
431
- }
432
- function list(items) {
433
- if (!items || !items.length) return '<div class="empty">—</div>';
434
- return "<ul>" + items.map((i) => `<li><code>${escapeHtml(i)}</code></li>`).join("") + "</ul>";
435
- }
650
+
436
651
  function escapeHtml(s) {
437
652
  return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
438
653
  }