@lyric_dev/data-loom 0.6.0 → 0.8.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/README.md +37 -15
- package/dist/autostart.js +223 -36
- package/dist/claudeCode.js +78 -15
- package/dist/index.js +156 -11
- package/dist/lifecycle.js +116 -2
- package/dist/mcpServer.js +31 -40
- package/dist/mcpShim.js +144 -0
- package/dist/paths.js +6 -0
- package/dist/server.js +10 -1
- package/dist/version.js +18 -0
- package/dist/weaveAlias.js +66 -0
- package/package.json +1 -1
- package/public/app.js +73 -1
- package/public/style.css +102 -0
package/dist/mcpShim.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// MCP stdio shim: spawned by a client (typically Claude Code's stdio
|
|
2
|
+
// registration), it ensures the daemon is running, then proxies MCP traffic
|
|
3
|
+
// between its own stdio and the daemon's loopback HTTP `/mcp` endpoint for the
|
|
4
|
+
// life of the client session.
|
|
5
|
+
//
|
|
6
|
+
// Pure proxy — no tools, prompts, or sessions of its own. The daemon remains
|
|
7
|
+
// the single MCP server and source of truth (including per-call project
|
|
8
|
+
// resolution); this file only launches (if needed) and forwards raw JSON-RPC
|
|
9
|
+
// messages both ways.
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
13
|
+
import { isInitializeRequest, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import * as lifecycle from "./lifecycle.js";
|
|
15
|
+
import { mcpUrl, logFile } from "./paths.js";
|
|
16
|
+
const LAUNCH_TIMEOUT_MS = 10_000;
|
|
17
|
+
const POLL_INTERVAL_MS = 300;
|
|
18
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the daemon answers on the loopback port, starting it (through the
|
|
21
|
+
* normal detached lifecycle path) if it does not. Bounded wait — never hangs.
|
|
22
|
+
* A concurrent second shim racing to start is harmless: the daemon's own
|
|
23
|
+
* single-instance guard (the port probe in lifecycle.start) means at most one
|
|
24
|
+
* daemon results, and both shims' polls succeed against it.
|
|
25
|
+
*/
|
|
26
|
+
async function ensureDaemonUp() {
|
|
27
|
+
if (await lifecycle.isRunning())
|
|
28
|
+
return { ok: true };
|
|
29
|
+
await lifecycle.start();
|
|
30
|
+
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
if (await lifecycle.isRunning())
|
|
33
|
+
return { ok: true };
|
|
34
|
+
await delay(POLL_INTERVAL_MS);
|
|
35
|
+
}
|
|
36
|
+
return { ok: false, diagnosis: await diagnoseFailure() };
|
|
37
|
+
}
|
|
38
|
+
/** Best-effort explanation of why the daemon never came up, from its own log tail. */
|
|
39
|
+
async function diagnoseFailure() {
|
|
40
|
+
let tail = "";
|
|
41
|
+
try {
|
|
42
|
+
tail = (await readFile(logFile(), "utf8")).slice(-4000);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* no log yet — daemon likely never got far enough to write one */
|
|
46
|
+
}
|
|
47
|
+
if (/openspec.*was not found/i.test(tail)) {
|
|
48
|
+
return ("DataLoom could not start: the `openspec` CLI is missing. Install it (e.g. `npm i -g openspec`), then retry. " +
|
|
49
|
+
`Log: ${logFile()}`);
|
|
50
|
+
}
|
|
51
|
+
return ("DataLoom's daemon did not come up in time. Run `data-loom status` to check what's wrong, and see the log at " +
|
|
52
|
+
logFile());
|
|
53
|
+
}
|
|
54
|
+
function extractId(msg) {
|
|
55
|
+
return "id" in msg ? msg.id : undefined;
|
|
56
|
+
}
|
|
57
|
+
function errorResponse(id, message) {
|
|
58
|
+
return {
|
|
59
|
+
jsonrpc: "2.0",
|
|
60
|
+
id,
|
|
61
|
+
error: { code: ErrorCode.InternalError, message },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* stdout is the MCP transport's wire — reserved exclusively for framed
|
|
66
|
+
* JSON-RPC messages. Reused helpers here (lifecycle.start/isRunning) predate
|
|
67
|
+
* the shim and log human-readable status with console.log, which writes to
|
|
68
|
+
* that same stdout; left alone, those lines interleave with JSON-RPC frames
|
|
69
|
+
* and corrupt the client's stream. Redirect the stdout-writing console
|
|
70
|
+
* methods to stderr for this process's whole lifetime (console.warn/error
|
|
71
|
+
* already go to stderr).
|
|
72
|
+
*/
|
|
73
|
+
function keepStdoutForTransportOnly() {
|
|
74
|
+
console.log = console.error;
|
|
75
|
+
console.info = console.error;
|
|
76
|
+
console.debug = console.error;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Entry point for `data-loom mcp-shim`. Never resolves until the client
|
|
80
|
+
* session ends (or the launch fails and the handshake has been answered) —
|
|
81
|
+
* the caller (index.ts) just awaits and lets the process exit naturally.
|
|
82
|
+
*/
|
|
83
|
+
export async function runShim() {
|
|
84
|
+
keepStdoutForTransportOnly();
|
|
85
|
+
const stdio = new StdioServerTransport();
|
|
86
|
+
// Buffer whatever arrives while the daemon is (maybe) still starting, so
|
|
87
|
+
// nothing sent before the proxy is wired up is lost.
|
|
88
|
+
const pending = [];
|
|
89
|
+
stdio.onmessage = (msg) => {
|
|
90
|
+
pending.push(msg);
|
|
91
|
+
};
|
|
92
|
+
stdio.onerror = (err) => console.error("[data-loom mcp-shim] stdio error:", err);
|
|
93
|
+
await stdio.start();
|
|
94
|
+
const readiness = await ensureDaemonUp();
|
|
95
|
+
if (!readiness.ok) {
|
|
96
|
+
const initMsg = pending.find((m) => isInitializeRequest(m));
|
|
97
|
+
const id = initMsg ? extractId(initMsg) : undefined;
|
|
98
|
+
if (id !== undefined) {
|
|
99
|
+
await stdio.send(errorResponse(id, readiness.diagnosis));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// No handshake to answer yet — still surface the failure, just not as
|
|
103
|
+
// an MCP response. Never exit without saying why.
|
|
104
|
+
console.error(`[data-loom mcp-shim] ${readiness.diagnosis}`);
|
|
105
|
+
}
|
|
106
|
+
await stdio.close();
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const client = new StreamableHTTPClientTransport(new URL(mcpUrl()));
|
|
111
|
+
let closing = false;
|
|
112
|
+
// The shim's whole job ends with its client session — one side closing
|
|
113
|
+
// (client disconnects, or the daemon connection drops) tears down the
|
|
114
|
+
// other, but the daemon itself is never touched.
|
|
115
|
+
const closeBoth = () => {
|
|
116
|
+
if (closing)
|
|
117
|
+
return;
|
|
118
|
+
closing = true;
|
|
119
|
+
void stdio.close();
|
|
120
|
+
void client.close();
|
|
121
|
+
process.exit(0);
|
|
122
|
+
};
|
|
123
|
+
client.onmessage = (msg) => {
|
|
124
|
+
void stdio.send(msg);
|
|
125
|
+
};
|
|
126
|
+
client.onerror = (err) => console.error("[data-loom mcp-shim] daemon connection error:", err);
|
|
127
|
+
client.onclose = closeBoth;
|
|
128
|
+
stdio.onerror = (err) => console.error("[data-loom mcp-shim] stdio error:", err);
|
|
129
|
+
stdio.onclose = closeBoth;
|
|
130
|
+
// StdioServerTransport only ever calls onclose when *we* call close() — it
|
|
131
|
+
// does not itself listen for stdin ending. The client process closing its
|
|
132
|
+
// end of the pipe (the real "session ended" signal) only shows up as
|
|
133
|
+
// stdin's own 'end'/'close' event, so that is what actually has to trigger
|
|
134
|
+
// teardown here.
|
|
135
|
+
process.stdin.on("end", closeBoth);
|
|
136
|
+
process.stdin.on("close", closeBoth);
|
|
137
|
+
await client.start();
|
|
138
|
+
for (const msg of pending)
|
|
139
|
+
await client.send(msg);
|
|
140
|
+
// From here on, every message from the client goes straight through.
|
|
141
|
+
stdio.onmessage = (msg) => {
|
|
142
|
+
void client.send(msg);
|
|
143
|
+
};
|
|
144
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -31,6 +31,12 @@ export function stateDir() {
|
|
|
31
31
|
}
|
|
32
32
|
export const pidFile = () => join(stateDir(), "daemon.pid");
|
|
33
33
|
export const logFile = () => join(stateDir(), "daemon.log");
|
|
34
|
+
/**
|
|
35
|
+
* The stable launcher OS supervisors invoke (Scheduled Task / LaunchAgent /
|
|
36
|
+
* systemd unit) — a fixed path that never changes across Node version or
|
|
37
|
+
* package-location churn, unlike the `node` + script paths it wraps.
|
|
38
|
+
*/
|
|
39
|
+
export const launcherFile = () => join(stateDir(), process.platform === "win32" ? "daemon.cmd" : "daemon.sh");
|
|
34
40
|
/** Create the state dir on demand; safe to call repeatedly. */
|
|
35
41
|
export async function ensureStateDir() {
|
|
36
42
|
const dir = stateDir();
|
package/dist/server.js
CHANGED
|
@@ -15,7 +15,7 @@ const MIME = {
|
|
|
15
15
|
".svg": "image/svg+xml",
|
|
16
16
|
};
|
|
17
17
|
export async function startServer(opts) {
|
|
18
|
-
const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject } = opts;
|
|
18
|
+
const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject, onShutdown } = opts;
|
|
19
19
|
let wss;
|
|
20
20
|
const broadcast = (msg) => {
|
|
21
21
|
const data = JSON.stringify(msg);
|
|
@@ -147,6 +147,15 @@ export async function startServer(opts) {
|
|
|
147
147
|
return sendJson(res, getMcp());
|
|
148
148
|
if (url.pathname === "/api/projects")
|
|
149
149
|
return sendJson(res, await getProjects());
|
|
150
|
+
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
151
|
+
// Loopback-only (isAllowed already ran) graceful stop. Answer first,
|
|
152
|
+
// then trigger shutdown once the response has flushed so the caller
|
|
153
|
+
// gets its 200 before the process exits.
|
|
154
|
+
res.writeHead(200, { "content-type": MIME[".json"] });
|
|
155
|
+
res.end(JSON.stringify({ stopping: true }));
|
|
156
|
+
res.on("finish", () => setImmediate(() => onShutdown?.()));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
150
159
|
if (url.pathname === "/api/mcp/check" && req.method === "POST") {
|
|
151
160
|
const server = await checkMcp(url.searchParams.get("name") ?? "");
|
|
152
161
|
if (!server)
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The advertised package version — read from the manifest shipped next to the
|
|
2
|
+
// compiled code, so it can never drift from the released version again (it was
|
|
3
|
+
// hardcoded in mcpServer.ts and stuck at 0.4.1 through the 0.5.0 release).
|
|
4
|
+
// Shared by the MCP server (server version), the weave alias (version stamp),
|
|
5
|
+
// and the startup refresh (comparing stamps).
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
export const VERSION = (() => {
|
|
10
|
+
try {
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
13
|
+
return manifest.version ?? "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared provisioning for the `/loom:weave` alias: a thin, version-stamped
|
|
2
|
+
// command file that delegates to the data-loom MCP server's `weave` prompt
|
|
3
|
+
// (see mcpServer.ts) instead of carrying the workflow text itself. Written by
|
|
4
|
+
// the install_weave_skill tool, `connect claude-code`, and healed on daemon
|
|
5
|
+
// startup — all three must agree on path, stamp format, and template, so it
|
|
6
|
+
// lives here rather than duplicated across callers.
|
|
7
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
export const WEAVE_ALIAS_PATH = join(homedir(), ".claude", "commands", "loom", "weave.md");
|
|
11
|
+
const STAMP_RE = /<!--\s*data-loom:version\s+(\S+?)\s*-->/;
|
|
12
|
+
function buildAlias(version) {
|
|
13
|
+
return `---
|
|
14
|
+
name: "Loom: Weave"
|
|
15
|
+
description: "Review the open proposals and weave their dependency order via the data-loom MCP server"
|
|
16
|
+
category: Workflow
|
|
17
|
+
tags: [loom, dependencies, mcp, review]
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<!-- data-loom:version ${version} -->
|
|
21
|
+
|
|
22
|
+
Fetch the **data-loom** MCP server's \`weave\` prompt (\`prompts/get\` with name \`weave\`) and follow it exactly — that prompt is this workflow's single source of truth and always matches the running server.
|
|
23
|
+
|
|
24
|
+
**If the data-loom tools or the \`weave\` prompt are unreachable:** the daemon is almost certainly not running or not registered in this session. Tell the user to check \`data-loom status\`, start it with \`data-loom start\` if it isn't running, and register it with \`data-loom connect claude-code\` if it isn't registered — then retry.
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
async function readAlias() {
|
|
28
|
+
try {
|
|
29
|
+
return await readFile(WEAVE_ALIAS_PATH, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Write the current alias, creating its parent directory if absent. Returns the path written. */
|
|
36
|
+
export async function provisionWeaveAlias(version) {
|
|
37
|
+
await mkdir(dirname(WEAVE_ALIAS_PATH), { recursive: true });
|
|
38
|
+
await writeFile(WEAVE_ALIAS_PATH, buildAlias(version));
|
|
39
|
+
return WEAVE_ALIAS_PATH;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* On daemon startup: rewrite an existing alias whose version stamp is missing
|
|
43
|
+
* or does not match the running version (covers both a prior version's alias
|
|
44
|
+
* and a pre-prompt full-text install, which predates the stamp). Never creates
|
|
45
|
+
* the file — absence means the user hasn't opted in via connect or the tool.
|
|
46
|
+
*/
|
|
47
|
+
export async function refreshWeaveAliasIfOutdated(version) {
|
|
48
|
+
const text = await readAlias();
|
|
49
|
+
if (text === null)
|
|
50
|
+
return;
|
|
51
|
+
if (text.match(STAMP_RE)?.[1] === version)
|
|
52
|
+
return;
|
|
53
|
+
await provisionWeaveAlias(version);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Remove the alias only when it carries a data-loom version stamp — our
|
|
57
|
+
* positive signal of ownership. A file with no stamp at all (or no file) is
|
|
58
|
+
* left untouched. Returns whether a file was removed.
|
|
59
|
+
*/
|
|
60
|
+
export async function removeWeaveAliasIfOurs() {
|
|
61
|
+
const text = await readAlias();
|
|
62
|
+
if (text === null || !STAMP_RE.test(text))
|
|
63
|
+
return false;
|
|
64
|
+
await unlink(WEAVE_ALIAS_PATH);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -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 —
|
|
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() {
|
|
@@ -272,6 +276,16 @@ function renderCard(c) {
|
|
|
272
276
|
const text = [capsParts.join(" "), tasks].filter(Boolean).join(" · ");
|
|
273
277
|
body.appendChild(el("div", "gcard-caps", text));
|
|
274
278
|
}
|
|
279
|
+
const action = changeAction(c);
|
|
280
|
+
if (action) {
|
|
281
|
+
const btn = el("button", "card-action " + action.kind, action.label);
|
|
282
|
+
btn.title = "Copy " + action.command;
|
|
283
|
+
btn.addEventListener("click", (ev) => {
|
|
284
|
+
ev.stopPropagation(); // copy only — don't also select the card / open detail
|
|
285
|
+
copyCommand(action.command);
|
|
286
|
+
});
|
|
287
|
+
body.appendChild(btn);
|
|
288
|
+
}
|
|
275
289
|
card.appendChild(body);
|
|
276
290
|
|
|
277
291
|
if (nextUpNames.has(c.name)) card.appendChild(el("div", "gcard-next", "NEXT UP"));
|
|
@@ -524,6 +538,13 @@ function renderChangeDetail(c) {
|
|
|
524
538
|
pills.appendChild(rp);
|
|
525
539
|
inner.appendChild(pills);
|
|
526
540
|
|
|
541
|
+
const action = changeAction(c);
|
|
542
|
+
if (action) {
|
|
543
|
+
const btn = el("button", "detail-action " + action.kind, "Copy " + action.command);
|
|
544
|
+
btn.addEventListener("click", () => copyCommand(action.command));
|
|
545
|
+
inner.appendChild(btn);
|
|
546
|
+
}
|
|
547
|
+
|
|
527
548
|
if (c.totalTasks > 0) {
|
|
528
549
|
const pct = Math.round((c.completedTasks / c.totalTasks) * 100);
|
|
529
550
|
const tasks = el("div", "detail-tasks");
|
|
@@ -607,6 +628,57 @@ function itemList(items, cls, prefix) {
|
|
|
607
628
|
return list;
|
|
608
629
|
}
|
|
609
630
|
|
|
631
|
+
// ═══════════════ command handoff (clipboard + toast) ═══════════════
|
|
632
|
+
|
|
633
|
+
// A single reused toast node, auto-dismissed. DataLoom prepares a command; the
|
|
634
|
+
// user runs it in their own Claude Code session — so a copy is confirmed here,
|
|
635
|
+
// never executed.
|
|
636
|
+
let toastEl = null;
|
|
637
|
+
let toastTimer = null;
|
|
638
|
+
function showToast(message, tone) {
|
|
639
|
+
if (!toastEl) {
|
|
640
|
+
toastEl = el("div", "toast");
|
|
641
|
+
document.body.appendChild(toastEl);
|
|
642
|
+
}
|
|
643
|
+
toastEl.textContent = message;
|
|
644
|
+
toastEl.className = "toast" + (tone ? " " + tone : "");
|
|
645
|
+
// force reflow so re-triggering the transition restarts it
|
|
646
|
+
void toastEl.offsetWidth;
|
|
647
|
+
toastEl.classList.add("show");
|
|
648
|
+
clearTimeout(toastTimer);
|
|
649
|
+
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 3200);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Copy a Claude Code command to the clipboard and confirm via a toast. Purely
|
|
653
|
+
// client-side: no network call, no daemon/agent activity. On localhost the
|
|
654
|
+
// Clipboard API is available (secure context); a rejection or its absence falls
|
|
655
|
+
// back to showing the command for manual copy.
|
|
656
|
+
async function copyCommand(command) {
|
|
657
|
+
try {
|
|
658
|
+
await navigator.clipboard.writeText(command);
|
|
659
|
+
showToast(`Copied ${command} — paste into Claude Code`);
|
|
660
|
+
} catch {
|
|
661
|
+
showToast(`Couldn't copy — run this in Claude Code: ${command}`, "warn");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// The command action a change offers, derived from state the model already
|
|
666
|
+
// carries. Kept in one place so a card and the detail panel always agree:
|
|
667
|
+
// archived → nothing
|
|
668
|
+
// tasks all complete → archive
|
|
669
|
+
// ready (incomplete) → apply
|
|
670
|
+
// otherwise → nothing
|
|
671
|
+
function changeAction(c) {
|
|
672
|
+
if (c.archived) return null;
|
|
673
|
+
if (c.totalTasks > 0 && c.completedTasks === c.totalTasks) {
|
|
674
|
+
return { kind: "archive", label: "Archive", command: "/opsx:archive " + c.name };
|
|
675
|
+
}
|
|
676
|
+
if (c.readiness === "ready") {
|
|
677
|
+
return { kind: "apply", label: "Apply", command: "/opsx:apply " + c.name };
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
|
|
610
682
|
// ═══════════════ helpers ═══════════════
|
|
611
683
|
|
|
612
684
|
function statusColor(status) {
|
package/public/style.css
CHANGED
|
@@ -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 {
|