@lyric_dev/data-loom 0.7.0 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -29,9 +29,9 @@ let conflictedNames = new Set();
29
29
  let doneCollapsed = true;
30
30
  const liveOverride = new Map(); // server name -> transient liveness (e.g. "checking")
31
31
 
32
- // ── roadmap graph geometry (fixed design canvas, 1180×600) ──
32
+ // ── roadmap graph geometry (fixed left/right margin + fixed per-phase pitch) ──
33
33
  const G_MX = 150,
34
- G_W = 1180,
34
+ PHASE_SPAN = 260, // horizontal pitch between phase columns (234px frame + gutter)
35
35
  N_HALF = 105,
36
36
  N_TOP = 58,
37
37
  V_SPACE = 170;
@@ -180,8 +180,12 @@ function renderReview() {
180
180
  reviewEl.appendChild(el("span", "review-badge", String(n)));
181
181
  const txt = el("span", "review-text");
182
182
  const subject = n > 1 ? "proposals need" : "proposal needs";
183
- txt.innerHTML = `${n} ${subject} dependency review — ask Claude to <b>weave</b> ${n > 1 ? "them" : "it"} via the data-loom MCP server.`;
183
+ txt.innerHTML = `${n} ${subject} dependency review — copy the <b>weave</b> command to run it in Claude Code.`;
184
184
  reviewEl.appendChild(txt);
185
+ const btn = el("button", "banner-action", "Weave");
186
+ btn.title = "Copy /loom:weave";
187
+ btn.addEventListener("click", () => copyCommand("/loom:weave"));
188
+ reviewEl.appendChild(btn);
185
189
  }
186
190
 
187
191
  function clearBoard() {
@@ -200,8 +204,11 @@ function renderBoard() {
200
204
  }
201
205
 
202
206
  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;
207
+ // Fixed per-phase pitch the canvas grows wider as phases are added instead of
208
+ // squeezing columns into a constant width. Wide plans then scroll horizontally
209
+ // (see .board-wrap) rather than overlapping.
210
+ const canvasW = 2 * G_MX + Math.max(phaseNums.length - 1, 0) * PHASE_SPAN;
211
+ const gx = (p) => G_MX + phaseNums.indexOf(p) * PHASE_SPAN;
205
212
 
206
213
  // Grow the canvas vertically so the tallest phase always fits (real projects
207
214
  // can stack many cards in one phase). CARD_BAND leaves headroom for the
@@ -210,8 +217,9 @@ function renderBoard() {
210
217
  const maxN = Math.max(1, ...phaseNums.map((p) => open.filter((c) => c.phase === p).length));
211
218
  const canvasH = Math.max(600, (maxN - 1) * V_SPACE + CARD_BAND);
212
219
  const cy = canvasH / 2;
220
+ board.style.width = canvasW + "px";
213
221
  board.style.height = canvasH + "px";
214
- edgesSvg.setAttribute("viewBox", `0 0 ${G_W} ${canvasH}`);
222
+ edgesSvg.setAttribute("viewBox", `0 0 ${canvasW} ${canvasH}`);
215
223
 
216
224
  // Phase-band frames (behind everything), sized to the canvas.
217
225
  phaseNums.forEach((p, i) => {
@@ -272,6 +280,16 @@ function renderCard(c) {
272
280
  const text = [capsParts.join(" "), tasks].filter(Boolean).join(" · ");
273
281
  body.appendChild(el("div", "gcard-caps", text));
274
282
  }
283
+ const action = changeAction(c);
284
+ if (action) {
285
+ const btn = el("button", "card-action " + action.kind, action.label);
286
+ btn.title = "Copy " + action.command;
287
+ btn.addEventListener("click", (ev) => {
288
+ ev.stopPropagation(); // copy only — don't also select the card / open detail
289
+ copyCommand(action.command);
290
+ });
291
+ body.appendChild(btn);
292
+ }
275
293
  card.appendChild(body);
276
294
 
277
295
  if (nextUpNames.has(c.name)) card.appendChild(el("div", "gcard-next", "NEXT UP"));
@@ -524,6 +542,13 @@ function renderChangeDetail(c) {
524
542
  pills.appendChild(rp);
525
543
  inner.appendChild(pills);
526
544
 
545
+ const action = changeAction(c);
546
+ if (action) {
547
+ const btn = el("button", "detail-action " + action.kind, "Copy " + action.command);
548
+ btn.addEventListener("click", () => copyCommand(action.command));
549
+ inner.appendChild(btn);
550
+ }
551
+
527
552
  if (c.totalTasks > 0) {
528
553
  const pct = Math.round((c.completedTasks / c.totalTasks) * 100);
529
554
  const tasks = el("div", "detail-tasks");
@@ -607,6 +632,57 @@ function itemList(items, cls, prefix) {
607
632
  return list;
608
633
  }
609
634
 
635
+ // ═══════════════ command handoff (clipboard + toast) ═══════════════
636
+
637
+ // A single reused toast node, auto-dismissed. DataLoom prepares a command; the
638
+ // user runs it in their own Claude Code session — so a copy is confirmed here,
639
+ // never executed.
640
+ let toastEl = null;
641
+ let toastTimer = null;
642
+ function showToast(message, tone) {
643
+ if (!toastEl) {
644
+ toastEl = el("div", "toast");
645
+ document.body.appendChild(toastEl);
646
+ }
647
+ toastEl.textContent = message;
648
+ toastEl.className = "toast" + (tone ? " " + tone : "");
649
+ // force reflow so re-triggering the transition restarts it
650
+ void toastEl.offsetWidth;
651
+ toastEl.classList.add("show");
652
+ clearTimeout(toastTimer);
653
+ toastTimer = setTimeout(() => toastEl.classList.remove("show"), 3200);
654
+ }
655
+
656
+ // Copy a Claude Code command to the clipboard and confirm via a toast. Purely
657
+ // client-side: no network call, no daemon/agent activity. On localhost the
658
+ // Clipboard API is available (secure context); a rejection or its absence falls
659
+ // back to showing the command for manual copy.
660
+ async function copyCommand(command) {
661
+ try {
662
+ await navigator.clipboard.writeText(command);
663
+ showToast(`Copied ${command} — paste into Claude Code`);
664
+ } catch {
665
+ showToast(`Couldn't copy — run this in Claude Code: ${command}`, "warn");
666
+ }
667
+ }
668
+
669
+ // The command action a change offers, derived from state the model already
670
+ // carries. Kept in one place so a card and the detail panel always agree:
671
+ // archived → nothing
672
+ // tasks all complete → archive
673
+ // ready (incomplete) → apply
674
+ // otherwise → nothing
675
+ function changeAction(c) {
676
+ if (c.archived) return null;
677
+ if (c.totalTasks > 0 && c.completedTasks === c.totalTasks) {
678
+ return { kind: "archive", label: "Archive", command: "/opsx:archive " + c.name };
679
+ }
680
+ if (c.readiness === "ready") {
681
+ return { kind: "apply", label: "Apply", command: "/opsx:apply " + c.name };
682
+ }
683
+ return null;
684
+ }
685
+
610
686
  // ═══════════════ helpers ═══════════════
611
687
 
612
688
  function statusColor(status) {
package/public/style.css CHANGED
@@ -293,12 +293,12 @@ main {
293
293
 
294
294
  .board-wrap {
295
295
  position: relative;
296
+ overflow-x: auto; /* wide roadmaps (many phases) pan horizontally; banners above stay fixed */
296
297
  }
297
298
  .board {
298
299
  position: relative;
299
- width: 1180px;
300
- height: 600px;
301
- margin: 0 auto;
300
+ height: 600px; /* width + height are set from JS per phase/card count */
301
+ margin: 0 auto; /* centers when it fits; collapses to 0 when it overflows and scrolls */
302
302
  }
303
303
  .edges {
304
304
  position: absolute;
@@ -540,6 +540,108 @@ main {
540
540
  color: var(--text);
541
541
  font-family: var(--prose);
542
542
  }
543
+ .banner-action {
544
+ flex: none;
545
+ margin-left: auto;
546
+ font-family: var(--prose);
547
+ font-weight: 600;
548
+ font-size: 12px;
549
+ padding: 6px 15px;
550
+ border-radius: 8px;
551
+ border: 1px solid var(--accent-bd);
552
+ background: var(--panel);
553
+ color: var(--accent);
554
+ cursor: pointer;
555
+ transition: background 0.15s, color 0.15s;
556
+ }
557
+ .banner-action:hover {
558
+ background: var(--accent);
559
+ color: var(--on-accent);
560
+ }
561
+
562
+ /* ===== Command handoff: card/detail actions + toast ===== */
563
+ .card-action {
564
+ align-self: flex-start;
565
+ margin-top: 10px;
566
+ font-family: var(--prose);
567
+ font-weight: 600;
568
+ font-size: 11px;
569
+ padding: 4px 11px;
570
+ border-radius: 7px;
571
+ border: 1px solid var(--accent-bd);
572
+ background: var(--accent-soft);
573
+ color: var(--accent);
574
+ cursor: pointer;
575
+ transition: background 0.15s, color 0.15s;
576
+ }
577
+ .card-action:hover {
578
+ background: var(--accent);
579
+ color: var(--on-accent);
580
+ }
581
+ .card-action.archive {
582
+ border-color: var(--c-done);
583
+ color: var(--c-done);
584
+ background: transparent;
585
+ }
586
+ .card-action.archive:hover {
587
+ background: var(--c-done);
588
+ color: var(--on-accent);
589
+ }
590
+
591
+ .detail-action {
592
+ margin-top: 16px;
593
+ width: 100%;
594
+ font-family: var(--prose);
595
+ font-weight: 600;
596
+ font-size: 12.5px;
597
+ padding: 9px;
598
+ border-radius: 9px;
599
+ border: 1px solid var(--accent-bd);
600
+ background: var(--accent-soft);
601
+ color: var(--accent);
602
+ cursor: pointer;
603
+ transition: background 0.15s, color 0.15s;
604
+ }
605
+ .detail-action:hover {
606
+ background: var(--accent);
607
+ color: var(--on-accent);
608
+ }
609
+ .detail-action.archive {
610
+ border-color: var(--c-done);
611
+ color: var(--c-done);
612
+ }
613
+ .detail-action.archive:hover {
614
+ background: var(--c-done);
615
+ color: var(--on-accent);
616
+ }
617
+
618
+ .toast {
619
+ position: fixed;
620
+ left: 50%;
621
+ bottom: 26px;
622
+ transform: translate(-50%, 12px);
623
+ max-width: min(70vw, 480px);
624
+ padding: 11px 16px;
625
+ border-radius: 10px;
626
+ background: var(--panel);
627
+ border: 1px solid var(--accent-bd);
628
+ color: var(--text);
629
+ font-family: var(--mono);
630
+ font-size: 12px;
631
+ box-shadow: var(--shadow);
632
+ opacity: 0;
633
+ pointer-events: none;
634
+ z-index: 40;
635
+ transition: opacity 0.18s ease, transform 0.18s ease;
636
+ }
637
+ .toast.show {
638
+ opacity: 1;
639
+ transform: translate(-50%, 0);
640
+ }
641
+ .toast.warn {
642
+ border-color: var(--c-warn);
643
+ color: var(--c-warn);
644
+ }
543
645
 
544
646
  /* ===== MCP Topology: circuit board ===== */
545
647
  .topo-controls {