@lyric_dev/data-loom 0.2.4

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 ADDED
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+
3
+ const board = document.getElementById("board");
4
+ const edgesSvg = document.getElementById("edges");
5
+ const doneBand = document.getElementById("doneband");
6
+ const detail = document.getElementById("detail");
7
+ const detailBody = document.getElementById("detail-body");
8
+ const connDot = document.getElementById("conn-dot");
9
+ const connText = document.getElementById("conn-text");
10
+ const genEl = document.getElementById("gen");
11
+ const conflictsEl = document.getElementById("conflicts");
12
+ const reviewEl = document.getElementById("review");
13
+ const projectSelect = document.getElementById("project-select");
14
+
15
+ let projects = null;
16
+ 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)
20
+ 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
+ });
31
+
32
+ // --- tab switching ---
33
+ let activeTab = "roadmap";
34
+ for (const btn of document.querySelectorAll(".tab[data-tab]")) {
35
+ btn.addEventListener("click", () => {
36
+ 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);
41
+ });
42
+ }
43
+
44
+ // --- project selection ---
45
+ projectSelect.addEventListener("change", async () => {
46
+ const path = projectSelect.value;
47
+ try {
48
+ const res = await fetch(`/api/project/select?path=${encodeURIComponent(path)}`, { method: "POST" });
49
+ if (!res.ok) renderProjects(projects); // rejected — restore previous selection
50
+ } catch (e) {
51
+ console.error("project switch failed", e);
52
+ renderProjects(projects);
53
+ }
54
+ });
55
+
56
+ function setProjects(p) {
57
+ projects = p;
58
+ renderProjects(p);
59
+ 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 = "";
63
+ doneBand.classList.add("hidden");
64
+ conflictsEl.classList.add("hidden");
65
+ }
66
+ }
67
+
68
+ function renderProjects(p) {
69
+ if (!p) return;
70
+ const placeholder = p.current ? "" : '<option value="" disabled selected>Select a project…</option>';
71
+ projectSelect.innerHTML =
72
+ placeholder +
73
+ p.candidates
74
+ .map((c) => `<option value="${escapeHtml(c.path)}"${c.path === p.current ? " selected" : ""}>${escapeHtml(c.name)}</option>`)
75
+ .join("");
76
+ }
77
+
78
+ connect();
79
+
80
+ function connect() {
81
+ const ws = new WebSocket(`ws://${location.host}`);
82
+ ws.addEventListener("open", () => setConn(true));
83
+ ws.addEventListener("close", () => {
84
+ setConn(false);
85
+ setTimeout(connect, 1500); // auto-reconnect
86
+ });
87
+ ws.addEventListener("message", (ev) => {
88
+ try {
89
+ const msg = JSON.parse(ev.data);
90
+ if (msg.type === "model") render(msg.model);
91
+ else if (msg.type === "mcp") setMcp(msg.mcp);
92
+ else if (msg.type === "mcpServer") updateMcpServer(msg.server);
93
+ else if (msg.type === "project") setProjects(msg.project);
94
+ } catch (e) {
95
+ console.error("bad message", e);
96
+ }
97
+ });
98
+ }
99
+
100
+ function setConn(live) {
101
+ connDot.classList.toggle("live", live);
102
+ connText.textContent = live ? "live" : "reconnecting…";
103
+ }
104
+
105
+ function render(m) {
106
+ 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
+ }
112
+ genEl.textContent = m.generatedAt ? "· " + new Date(m.generatedAt).toLocaleTimeString() : "";
113
+ conflictedNames = new Set((m.conflicts || []).flatMap((c) => c.changes));
114
+ renderConflicts(m.conflicts || []);
115
+ renderReview();
116
+ // Recommended next = ready proposals in the earliest phase that has any.
117
+ const ready = m.changes.filter((c) => !c.archived && c.readiness === "ready");
118
+ const minPhase = ready.length ? Math.min(...ready.map((c) => c.phase)) : 0;
119
+ nextUpNames = new Set(ready.filter((c) => c.phase === minPhase).map((c) => c.name));
120
+ renderBoard();
121
+ renderDoneBand();
122
+ // Wait a frame so layout settles before measuring for edges.
123
+ requestAnimationFrame(drawEdges);
124
+ }
125
+
126
+ function renderConflicts(conflicts) {
127
+ if (!conflicts.length) {
128
+ conflictsEl.classList.add("hidden");
129
+ conflictsEl.innerHTML = "";
130
+ return;
131
+ }
132
+ 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("");
138
+ }
139
+
140
+ function renderReview() {
141
+ // Open changes that have never been reviewed for dependencies.
142
+ const pending = model.changes.filter((c) => !c.archived && c.dependencyReview === "pending");
143
+ const n = pending.length;
144
+ if (!n) {
145
+ reviewEl.classList.add("hidden");
146
+ reviewEl.innerHTML = "";
147
+ return;
148
+ }
149
+ reviewEl.classList.remove("hidden");
150
+ 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>`;
154
+ }
155
+
156
+ function renderBoard() {
157
+ board.innerHTML = "";
158
+ cardEls = new Map();
159
+
160
+ if (!model.phases.length) {
161
+ board.innerHTML = '<div class="empty-state">No active changes yet. Propose one to populate the roadmap.</div>';
162
+ return;
163
+ }
164
+
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);
177
+ });
178
+ }
179
+
180
+ 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?"));
194
+ }
195
+ if (c.totalTasks > 0) {
196
+ meta.appendChild(el("span", "tasks-mini", `${c.completedTasks}/${c.totalTasks} tasks`));
197
+ }
198
+ card.appendChild(meta);
199
+
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(", ")));
206
+ }
207
+
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);
214
+
215
+ card.addEventListener("click", () => showDetail(c));
216
+ cardEls.set(c.name, card);
217
+ return card;
218
+ }
219
+
220
+ function renderDoneBand() {
221
+ const archived = model.changes.filter((c) => c.archived);
222
+ if (!archived.length) {
223
+ doneBand.classList.add("hidden");
224
+ return;
225
+ }
226
+ doneBand.classList.remove("hidden");
227
+ doneBand.innerHTML = "";
228
+ const head = el("div", "doneband-head", `${doneCollapsed ? "▸" : "▾"} Done — ${archived.length} archived`);
229
+ head.addEventListener("click", () => {
230
+ doneCollapsed = !doneCollapsed;
231
+ renderDoneBand();
232
+ requestAnimationFrame(drawEdges);
233
+ });
234
+ doneBand.appendChild(head);
235
+ const items = el("div", `doneband-items${doneCollapsed ? " collapsed" : ""}`);
236
+ for (const c of archived) {
237
+ const chip = el("div", "done-chip", c.name);
238
+ chip.addEventListener("click", () => showDetail(c));
239
+ items.appendChild(chip);
240
+ }
241
+ doneBand.appendChild(items);
242
+ }
243
+
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();
308
+
309
+ function setMcp(m) {
310
+ mcpModel = m;
311
+ renderTopology();
312
+ }
313
+
314
+ function updateMcpServer(server) {
315
+ if (!mcpModel) return;
316
+ const i = mcpModel.servers.findIndex((s) => s.name === server.name);
317
+ if (i >= 0) mcpModel.servers[i] = server;
318
+ else mcpModel.servers.push(server);
319
+ renderTopology();
320
+ }
321
+
322
+ function renderTopology() {
323
+ if (!mcpModel) return;
324
+ topoNodes.innerHTML = "";
325
+ serverEls = new Map();
326
+
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);
329
+
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);
333
+ }
334
+
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);
366
+ });
367
+ drawSpokes();
368
+ }
369
+
370
+ function place(node, x, y) {
371
+ node.style.left = x + "px";
372
+ node.style.top = y + "px";
373
+ }
374
+
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);
390
+ }
391
+ }
392
+
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…';
397
+ try {
398
+ await fetch(`/api/mcp/check?name=${encodeURIComponent(name)}`, { method: "POST" });
399
+ // result arrives via the ws 'mcpServer' push -> updateMcpServer re-renders
400
+ } catch (e) {
401
+ console.error("check failed", e);
402
+ }
403
+ }
404
+
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;
415
+ }
416
+
417
+ // --- tiny DOM helpers ---
418
+ function el(tag, cls, html) {
419
+ const e = document.createElement(tag);
420
+ if (cls) e.className = cls;
421
+ if (html !== undefined) e.innerHTML = html;
422
+ return e;
423
+ }
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
+ }
436
+ function escapeHtml(s) {
437
+ return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
438
+ }
@@ -0,0 +1,22 @@
1
+ <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DataLoom">
2
+ <title>DataLoom</title>
3
+ <rect x="2" y="2" width="60" height="60" rx="15" fill="#141a22" stroke="#2a3038" stroke-width="2"/>
4
+ <g fill="#6ea8fe">
5
+ <rect x="17" y="11" width="9" height="42" rx="4.5"/>
6
+ <rect x="38" y="11" width="9" height="42" rx="4.5"/>
7
+ </g>
8
+ <g fill="#4cae6a">
9
+ <rect x="11" y="17" width="42" height="9" rx="4.5"/>
10
+ <rect x="11" y="38" width="42" height="9" rx="4.5"/>
11
+ </g>
12
+ <g fill="#6ea8fe">
13
+ <rect x="17" y="17" width="9" height="9" rx="2.5"/>
14
+ <rect x="38" y="38" width="9" height="9" rx="2.5"/>
15
+ </g>
16
+ <g fill="#eaf0f8">
17
+ <circle cx="21.5" cy="21.5" r="1.9"/>
18
+ <circle cx="42.5" cy="21.5" r="1.9"/>
19
+ <circle cx="21.5" cy="42.5" r="1.9"/>
20
+ <circle cx="42.5" cy="42.5" r="1.9"/>
21
+ </g>
22
+ </svg>
@@ -0,0 +1,59 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>DataLoom</title>
7
+ <link rel="icon" type="image/svg+xml" href="/icon.svg" />
8
+ <link rel="stylesheet" href="/style.css" />
9
+ </head>
10
+ <body>
11
+ <header class="topbar">
12
+ <div class="brand"><img class="brand-logo" src="/icon.svg" alt="" width="22" height="22" />DataLoom</div>
13
+ <nav class="tabs">
14
+ <button class="tab active" data-tab="roadmap">Roadmap</button>
15
+ <button class="tab" data-tab="topology">MCP Topology</button>
16
+ </nav>
17
+ <div class="project">
18
+ <label class="project-label" for="project-select">project</label>
19
+ <select id="project-select" title="Select which project to display"></select>
20
+ </div>
21
+ <div class="conn">
22
+ <span id="conn-dot" class="dot"></span>
23
+ <span id="conn-text">connecting…</span>
24
+ <span id="gen" class="gen"></span>
25
+ </div>
26
+ </header>
27
+
28
+ <main>
29
+ <section id="roadmap" class="view active">
30
+ <div id="conflicts" class="conflicts hidden"></div>
31
+ <div id="review" class="review hidden"></div>
32
+ <div class="board-wrap">
33
+ <svg id="edges" class="edges" aria-hidden="true"></svg>
34
+ <div id="board" class="board"></div>
35
+ </div>
36
+ <section id="doneband" class="doneband hidden"></section>
37
+ </section>
38
+
39
+ <section id="topology" class="view">
40
+ <div class="topo-legend">
41
+ <span><i class="swatch solid"></i> global</span>
42
+ <span><i class="swatch dashed"></i> project</span>
43
+ <span class="topo-hint">click a server to passively check it — data_loom never starts anything</span>
44
+ </div>
45
+ <div id="topo-wrap" class="topo-wrap">
46
+ <svg id="spokes" class="edges" aria-hidden="true"></svg>
47
+ <div id="topo-nodes"></div>
48
+ </div>
49
+ </section>
50
+ </main>
51
+
52
+ <aside id="detail" class="detail hidden">
53
+ <button id="detail-close" class="detail-close" aria-label="Close">&times;</button>
54
+ <div id="detail-body"></div>
55
+ </aside>
56
+
57
+ <script src="/app.js"></script>
58
+ </body>
59
+ </html>