diff --git a/CONNECTION-MAP.md b/CONNECTION-MAP.md index 2c08448..eb07514 100644 --- a/CONNECTION-MAP.md +++ b/CONNECTION-MAP.md @@ -2,7 +2,7 @@ **Upstream version:** v0.51.118 **Plugin version:** 0.4.0 -**Total dependencies:** 24 (19 public API · 0 forced internal · 5 frontend) +**Total dependencies:** 30 (24 public API · 0 forced internal · 6 frontend) > **Auto-generated by `scripts/ast-connection-map.py`. Do not hand-edit.** > To change a justification, edit the `# CONNECTION:` comment above the @@ -18,10 +18,11 @@ | `plugin.py:34` | `api.register_static` | `api.register_static(STATIC_PREFIX, str(STATIC_DIR))` | | `plugin.py:35` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/app.css")` | | `plugin.py:36` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/app.js")` | -| `plugin.py:40` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")` | -| `plugin.py:41` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` | -| `plugin.py:46` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` | -| `plugin.py:47` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` | +| `plugin.py:39` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/svrnty_nav.js")` | +| `plugin.py:42` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css")` | +| `plugin.py:43` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` | +| `plugin.py:45` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` | +| `plugin.py:46` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` | | `routes/adwright.py:68` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` | | `routes/adwright.py:69` | `api.register_route` | `api.register_route(` | | `routes/adwright.py:71` | `api.register_route` | `api.register_route(` | @@ -31,6 +32,10 @@ | `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` | | `routes/transcribe.py:38` | `api.register_route` | `api.register_route("/api/transcribe", "POST", _handle_transcribe)` | | `routes/transcribe.py:39` | `api.register_audio_attachment_processor` | `api.register_audio_attachment_processor(_transcribe_audio_attachments)` | +| `routes/umbrella.py:30` | `api.logger` | `log = api.logger("svrnty.routes.umbrella")` | +| `routes/umbrella.py:31` | `api.register_route` | `api.register_route("/umbrella", "GET", _handle_panel_html)` | +| `routes/umbrella.py:32` | `api.register_route` | `api.register_route("/api/umbrella", "GET", _handle_graph_json)` | +| `routes/umbrella.py:33` | `api.register_route` | `api.register_route("/api/umbrella/doc", "GET", _handle_doc_body)` | | `routes/vault_status.py:19` | `api.logger` | `log = api.logger("svrnty.routes.vault_status")` | | `routes/vault_status.py:20` | `api.register_route` | `api.register_route("/api/vault/status", "GET", _handle_vault_status)` | @@ -52,5 +57,6 @@ _None. Plugin uses only the public API._ ✓ | `static/bte.js` | 369 | `/api/query/assetGrid` | | `static/bte.js` | 483 | `/api/command/rateAsset` | | `static/adwright.js` | 484 | `/api/adwright/provision-creds` | +| `static/umbrella.js` | 41 | `/api/umbrella` | | `static/app.js` | 165 | `/api/vault/status` | diff --git a/plugin.py b/plugin.py index b1140bf..e991b62 100644 --- a/plugin.py +++ b/plugin.py @@ -34,15 +34,14 @@ def register(api): api.register_static(STATIC_PREFIX, str(STATIC_DIR)) api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/app.css") api.inject_script(f"/plugins/{STATIC_PREFIX}/app.js") - # Adwright tool panel (ADWRIGHT-PANEL-PRD §7) — additional assets - # served from the same static dir, injected after the brand skin so - # adwright.css overrides any conflicting brand defaults. + # Sidebar nav glue — MUST load before tool panel scripts so its + # switchPanel wrap is in place when panels listen for it. + api.inject_script(f"/plugins/{STATIC_PREFIX}/svrnty_nav.js") + # Adwright tool panel — content mounts into
, visibility keyed + # off main.svrnty-showing-adwright (set by svrnty_nav.js wrapper). api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/adwright.css") api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js") - # BTE Command Center panel (COMMAND-CENTER-PRD §3 + PLANB-RECIPE-TAXONOMY). - # Independent IIFE under window.SvrntyBTE namespace; ordering doesn't - # matter — both panels coexist via distinct .svrnty-bte-* / adwright - # selectors and namespaces. + # BTE Command Center panel — same pattern (main.svrnty-showing-bte). api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css") api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js") log.info("static + assets wired at /plugins/%s/", STATIC_PREFIX) @@ -74,4 +73,5 @@ def _phase2_routes(): "vault_status", # P2.B — vault connections status ✓ "adwright", # P2.C — Adwright tool panel routes (PRD §5+§6) ✓ "bte_proxy", # P2.D — BTE Command Center same-origin proxy (PRD §3) ✓ + "umbrella", # P2.E — cortex-os umbrella graph viz (UMBRELLA-VIZ-PRD) ✓ ] diff --git a/routes/umbrella.py b/routes/umbrella.py new file mode 100644 index 0000000..90c473b --- /dev/null +++ b/routes/umbrella.py @@ -0,0 +1,111 @@ +"""GET /umbrella + /api/umbrella + /api/umbrella/doc — cortex-os umbrella graph viz. + +Per sot/03-PROTOCOLS/CORTEX-OS-UMBRELLA-VIZ-PRD.md. + +Endpoints: + GET /umbrella → static HTML page (Cytoscape.js panel) + GET /api/umbrella → graph/umbrella.json contents (UI-stable v1.0 schema) + GET /api/umbrella/doc?path=X → markdown body + frontmatter for a node's source_path + +Reads graph artifact from $HERMES_REPO_ROOT/graph/umbrella.json (curator-maintained). +Doc reads are restricted to hermes/ workspace subdir (no path traversal). + +Public API surface used: api.register_route, api.logger. +No upstream forced internal dependencies. +""" +import json +import os +import urllib.parse +from pathlib import Path + +_DEFAULT_REPO_ROOT = "/home/svrnty/workspaces/hermes" + + +def _repo_root() -> Path: + return Path(os.environ.get("HERMES_REPO_ROOT", _DEFAULT_REPO_ROOT)).resolve() + + +def register(api): + """Wire umbrella panel + APIs.""" + log = api.logger("svrnty.routes.umbrella") + api.register_route("/umbrella", "GET", _handle_panel_html) + api.register_route("/api/umbrella", "GET", _handle_graph_json) + api.register_route("/api/umbrella/doc", "GET", _handle_doc_body) + log.info("umbrella panel + APIs registered") + + +def _send(handler, status: int, body: bytes, content_type: str) -> None: + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + handler.wfile.write(body) + + +def _handle_graph_json(handler, parsed) -> bool: + graph_path = _repo_root() / "graph" / "umbrella.json" + if not graph_path.exists(): + body = json.dumps({ + "error": "graph/umbrella.json not found", + "hint": "run curator/sweep.py --grapher-only to generate", + }).encode("utf-8") + _send(handler, 404, body, "application/json; charset=utf-8") + return True + try: + body = graph_path.read_bytes() + except OSError as e: + body = json.dumps({"error": str(e)}).encode("utf-8") + _send(handler, 500, body, "application/json; charset=utf-8") + return True + _send(handler, 200, body, "application/json; charset=utf-8") + return True + + +def _handle_doc_body(handler, parsed) -> bool: + qs = urllib.parse.parse_qs(parsed.query or "") + rel = (qs.get("path") or [""])[0].strip() + if not rel: + _send(handler, 400, b'{"error":"missing path query param"}', "application/json; charset=utf-8") + return True + root = _repo_root() + try: + target = (root / rel).resolve() + except (OSError, RuntimeError): + _send(handler, 400, b'{"error":"invalid path"}', "application/json; charset=utf-8") + return True + # Path-traversal guard: resolved target must be inside the workspace root. + if root not in target.parents and target != root: + _send(handler, 403, b'{"error":"path outside workspace"}', "application/json; charset=utf-8") + return True + if not target.exists() or not target.is_file(): + _send(handler, 404, json.dumps({"error": f"file not found: {rel}"}).encode("utf-8"), + "application/json; charset=utf-8") + return True + try: + content = target.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as e: + _send(handler, 500, json.dumps({"error": str(e)}).encode("utf-8"), + "application/json; charset=utf-8") + return True + # Cap response size — UI only renders a preview pane; large docs read in full via git. + MAX = 50_000 + truncated = len(content) > MAX + if truncated: + content = content[:MAX] + "\n\n*[truncated — read full file in repo]*" + body = json.dumps({ + "path": rel, + "size": len(content), + "truncated": truncated, + "body": content, + }).encode("utf-8") + _send(handler, 200, body, "application/json; charset=utf-8") + return True + + +def _handle_panel_html(handler, parsed) -> bool: + """Serve the static umbrella.html via Location redirect (assets live in static/).""" + handler.send_response(302) + handler.send_header("Location", "/plugins/svrnty/umbrella.html") + handler.end_headers() + return True diff --git a/static/svrnty_nav.js b/static/svrnty_nav.js new file mode 100644 index 0000000..f107e1e --- /dev/null +++ b/static/svrnty_nav.js @@ -0,0 +1,116 @@ +// svrnty_nav.js — injects Adwright + BTE sidebar buttons into hermes-webui's +// .sidebar-nav and wraps switchPanel so our panels participate in the existing +// main-view show/hide system (showing- on
). +// +// Each panel module (adwright.js, bte.js) mounts its content inside
+// and CSS keys visibility off main.showing-adwright / main.showing-bte. +// +// IIFE, idempotent — guarded by window.__svrntyNavLoaded. + +(function () { + "use strict"; + if (window.__svrntyNavLoaded) return; + window.__svrntyNavLoaded = true; + + const TABS = [ + { + id: "adwright", + label: "Adwright", + tooltip: "Adwright — marketing intelligence", + // Bullseye / target icon — marketing focus + svg: '', + }, + { + id: "bte", + label: "BTE", + tooltip: "BTE — brand creative studio", + // Palette/sparkle icon — creative + svg: '', + }, + ]; + + function _injectButtons() { + const nav = document.querySelector(".sidebar-nav"); + if (!nav) return false; + TABS.forEach((t) => { + if (nav.querySelector('[data-panel="' + t.id + '"]')) return; + const btn = document.createElement("button"); + btn.className = "nav-tab has-tooltip has-tooltip--bottom svrnty-nav-tab"; + btn.setAttribute("data-panel", t.id); + btn.setAttribute("data-label", t.label); + btn.setAttribute("data-tooltip", t.tooltip); + btn.setAttribute("aria-label", t.label); + btn.innerHTML = t.svg; + btn.addEventListener("click", () => { + if (typeof window.switchPanel === "function") { + window.switchPanel(t.id, { fromRailClick: true }); + } + }); + nav.appendChild(btn); + }); + return true; + } + + // Wrap switchPanel to add showing- class on
for our IDs. + // We chain the original so all upstream behavior (collapse rail, hide other + // panels, toggle data-panel active) keeps working unchanged. + function _wrapSwitchPanel() { + if (typeof window.switchPanel !== "function") return false; + if (window.switchPanel.__svrntyWrapped) return true; + const original = window.switchPanel; + const OUR_IDS = TABS.map((t) => t.id); + + async function wrapped(name, opts) { + const result = await original(name, opts); + const main = document.querySelector("main.main"); + if (main) { + OUR_IDS.forEach((id) => { + main.classList.toggle("svrnty-showing-" + id, name === id); + }); + } + // Notify panel modules so they can lazy-init/refresh. + try { + window.dispatchEvent( + new CustomEvent("svrnty:panel-switch", { detail: { name } }), + ); + } catch (_) {} + return result; + } + wrapped.__svrntyWrapped = true; + window.switchPanel = wrapped; + return true; + } + + function _init() { + const buttonsOk = _injectButtons(); + const wrapOk = _wrapSwitchPanel(); + if (!buttonsOk || !wrapOk) { + // DOM not ready yet — retry on next paint + requestAnimationFrame(_init); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _init); + } else { + _init(); + } + + // Re-inject buttons if something re-renders the sidebar (defensive). + const obs = new MutationObserver(() => { + const nav = document.querySelector(".sidebar-nav"); + if (!nav) return; + const missing = TABS.some((t) => !nav.querySelector('[data-panel="' + t.id + '"]')); + if (missing) _injectButtons(); + }); + if (document.body) { + obs.observe(document.body, { childList: true, subtree: true }); + } else { + document.addEventListener("DOMContentLoaded", () => + obs.observe(document.body, { childList: true, subtree: true }), + ); + } + + // Expose namespace + window.SvrntyNav = { TABS, _injectButtons, _wrapSwitchPanel }; +})(); diff --git a/static/umbrella.css b/static/umbrella.css new file mode 100644 index 0000000..9feb8b2 --- /dev/null +++ b/static/umbrella.css @@ -0,0 +1,108 @@ +/* Cortex-OS Umbrella panel — Plan B brand-aligned (8-property DESIGN.md subset). */ + +:root { + --bg: #0f1115; + --bg-2: #15181f; + --fg: #e7eaf0; + --fg-dim: #98a0b3; + --accent: #6ee7b7; + --accent-2: #fbbf24; + --warn: #f87171; + --rounded: 8px; + --pad: 12px; + --mono: ui-monospace, "JetBrains Mono", "SF Mono", monospace; +} + +.umbrella-root { + position: fixed; inset: 0; + background: var(--bg); color: var(--fg); + display: grid; + grid-template-rows: auto 1fr auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif; + font-size: 14px; + z-index: 9999; +} +.umbrella-header { + background: var(--bg-2); + padding: var(--pad); + border-bottom: 1px solid #2a2f3a; + display: grid; + grid-template-columns: auto 1fr auto; + gap: var(--pad); + align-items: center; +} +.umbrella-header h1 { margin: 0; font-size: 16px; font-weight: 600; color: var(--accent); } +.umbrella-stats { font-family: var(--mono); font-size: 12px; color: var(--fg-dim); } +.umbrella-controls { display: flex; gap: 8px; align-items: center; } +.umbrella-controls input[type=search] { + background: var(--bg); color: var(--fg); + border: 1px solid #2a2f3a; border-radius: var(--rounded); + padding: 6px 10px; width: 200px; font-size: 13px; +} +.umbrella-controls button { + background: var(--bg); color: var(--fg); + border: 1px solid #2a2f3a; border-radius: var(--rounded); + padding: 6px 10px; font-size: 12px; cursor: pointer; +} +.umbrella-controls button:hover { border-color: var(--accent); color: var(--accent); } +.umbrella-filters { + grid-column: 1 / -1; + display: flex; flex-wrap: wrap; gap: 6px; +} +.chip { + background: var(--bg); color: var(--fg-dim); + border: 1px solid #2a2f3a; border-radius: 999px; + padding: 4px 10px; font-size: 11px; cursor: pointer; + font-family: var(--mono); +} +.chip.active { color: var(--accent); border-color: var(--accent); background: rgba(110,231,183,0.08); } + +.umbrella-canvas { position: relative; display: grid; grid-template-columns: 1fr; } +.umbrella-cy { position: absolute; inset: 0; } +.umbrella-side { + position: absolute; top: 0; right: 0; bottom: 0; + width: 420px; background: var(--bg-2); + border-left: 1px solid #2a2f3a; + transform: translateX(100%); + transition: transform 180ms ease-out; + overflow: hidden; + display: flex; flex-direction: column; +} +.umbrella-side[data-open="true"] { transform: translateX(0); } +.umbrella-side .close { + position: absolute; top: 8px; right: 8px; + background: transparent; color: var(--fg-dim); + border: none; cursor: pointer; font-size: 20px; line-height: 1; +} +.side-content { padding: var(--pad); overflow-y: auto; height: 100%; } +.side-content h2 { margin: 0 0 8px; font-size: 16px; color: var(--accent); } +.side-meta { font-family: var(--mono); font-size: 11px; color: var(--fg-dim); margin-bottom: 12px; } +.side-meta div { margin-bottom: 2px; } +.side-meta b { color: var(--fg); } +.side-body { + background: var(--bg); + border-radius: var(--rounded); padding: 10px; + font-family: var(--mono); font-size: 12px; + white-space: pre-wrap; word-break: break-word; + max-height: 50vh; overflow-y: auto; + border: 1px solid #2a2f3a; +} +.side-edges { margin-top: 12px; font-size: 12px; } +.side-edges h3 { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; margin: 8px 0 4px; font-weight: 500; letter-spacing: 0.05em; } +.side-edges ul { list-style: none; padding: 0; margin: 0; } +.side-edges li { padding: 3px 0; font-family: var(--mono); } +.side-edges li button { + background: transparent; color: var(--accent); + border: none; cursor: pointer; font-family: var(--mono); font-size: 12px; + padding: 0; text-align: left; +} +.side-edges li button:hover { text-decoration: underline; } +.side-edges .etype { color: var(--fg-dim); font-size: 10px; text-transform: uppercase; margin-right: 6px; } + +.umbrella-footer { + background: var(--bg-2); padding: 6px var(--pad); + border-top: 1px solid #2a2f3a; + font-family: var(--mono); font-size: 11px; color: var(--fg-dim); + display: flex; justify-content: space-between; align-items: center; +} +.umbrella-footer a { color: var(--accent); } diff --git a/static/umbrella.html b/static/umbrella.html new file mode 100644 index 0000000..209c213 --- /dev/null +++ b/static/umbrella.html @@ -0,0 +1,45 @@ + + + + + + Cortex-OS Umbrella + + + +
+
+

Cortex-OS Umbrella

+
loading…
+
+ + + + + +
+
+ +
+
+
+
+ +
+ +
+ + + + diff --git a/static/umbrella.js b/static/umbrella.js new file mode 100644 index 0000000..0d41bbc --- /dev/null +++ b/static/umbrella.js @@ -0,0 +1,279 @@ +/* Cortex-OS Umbrella panel — Cytoscape.js render of /api/umbrella graph. + * Consumes UI-stable schema v1.0 per CORTEX-OS-UMBRELLA-VIZ-PRD.md. + * Curator-maintained graph artifact at graph/umbrella.json. + */ +(function () { + "use strict"; + + const TYPE_COLOR = { + doc: "#6ee7b7", + profile: "#fbbf24", + skill: "#a78bfa", + mcp_server: "#60a5fa", + sovereign_api: "#f97316", + cortex_tool: "#94a3b8", + external_dep: "#f87171", + credential: "#475569", + }; + const TYPE_SHAPE = { + doc: "round-rectangle", + profile: "hexagon", + skill: "ellipse", + mcp_server: "diamond", + sovereign_api: "rectangle", + cortex_tool: "round-rectangle", + external_dep: "triangle", + credential: "vee", + }; + const EDGE_STYLE = { + depends_on: { color: "#475569", style: "solid" }, + governs: { color: "#fbbf24", style: "solid" }, + consumes: { color: "#6ee7b7", style: "solid" }, + produces: { color: "#a78bfa", style: "dashed" }, + supersedes: { color: "#f87171", style: "dashed" }, + }; + + let cy = null; + let graph = null; + const activeTypes = new Set(); + + async function loadGraph() { + const res = await fetch("/api/umbrella", { cache: "no-store" }); + if (!res.ok) throw new Error("graph load failed: " + res.status); + return await res.json(); + } + + function renderStats(g) { + const s = g.stats || {}; + const byType = s.by_type || {}; + document.getElementById("stats").textContent = + `nodes ${s.total_nodes || g.nodes.length} · edges ${s.total_edges || g.edges.length} · ` + + Object.entries(byType).map(([k, v]) => `${k}:${v}`).join(" "); + document.getElementById("genInfo").textContent = + `generated ${g.generated_at || "?"} by ${g.generated_by || "?"}`; + } + + function renderFilters(g) { + const types = [...new Set(g.nodes.map(n => n.type))].sort(); + const wrap = document.getElementById("filters"); + wrap.innerHTML = ""; + types.forEach(t => { + activeTypes.add(t); + const chip = document.createElement("button"); + chip.className = "chip active"; + chip.dataset.type = t; + chip.style.borderColor = TYPE_COLOR[t] || "#475569"; + chip.style.color = TYPE_COLOR[t] || "#e7eaf0"; + chip.textContent = `${t} (${g.nodes.filter(n => n.type === t).length})`; + chip.addEventListener("click", () => toggleType(t, chip)); + wrap.appendChild(chip); + }); + } + + function toggleType(t, chipEl) { + if (activeTypes.has(t)) { activeTypes.delete(t); chipEl.classList.remove("active"); } + else { activeTypes.add(t); chipEl.classList.add("active"); } + applyFilter(); + } + + function applyFilter() { + if (!cy) return; + cy.batch(() => { + cy.nodes().forEach(n => { + const visible = activeTypes.has(n.data("type")); + n.style("display", visible ? "element" : "none"); + }); + cy.edges().forEach(e => { + const s = e.source().style("display"); + const t = e.target().style("display"); + e.style("display", (s === "element" && t === "element") ? "element" : "none"); + }); + }); + } + + function buildCyElements(g) { + const nodes = g.nodes.map(n => ({ + data: { id: n.id, label: n.label || n.id, type: n.type, raw: n }, + })); + const edges = g.edges.map((e, i) => ({ + data: { id: `e${i}`, source: e.source, target: e.target, type: e.type, raw: e }, + })); + return [...nodes, ...edges]; + } + + function renderGraph(g) { + cy = cytoscape({ + container: document.getElementById("cy"), + elements: buildCyElements(g), + wheelSensitivity: 0.2, + style: [ + { + selector: "node", + style: { + "background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569", + "shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse", + "label": "data(label)", + "color": "#e7eaf0", + "text-valign": "bottom", + "text-margin-y": 4, + "font-size": 10, + "font-family": "ui-monospace, monospace", + "width": 28, + "height": 28, + "border-width": 1, + "border-color": "#0f1115", + }, + }, + { + selector: "node:selected", + style: { "border-width": 3, "border-color": "#fbbf24" }, + }, + { + selector: "edge", + style: { + "width": 1, + "line-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569", + "line-style": ele => (EDGE_STYLE[ele.data("type")] || {}).style || "solid", + "curve-style": "bezier", + "target-arrow-shape": "triangle", + "target-arrow-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569", + "opacity": 0.5, + }, + }, + { + selector: "edge:selected", + style: { "opacity": 1, "width": 2 }, + }, + ], + layout: { name: "cose", animate: false, idealEdgeLength: 80, nodeRepulsion: 12000 }, + }); + cy.on("tap", "node", (evt) => openSidePanel(evt.target.data("raw"))); + cy.on("tap", (evt) => { if (evt.target === cy) closeSidePanel(); }); + } + + async function openSidePanel(node) { + document.getElementById("side").dataset.open = "true"; + document.getElementById("sideTitle").textContent = node.label || node.id; + const meta = document.getElementById("sideMeta"); + meta.innerHTML = ""; + const metaFields = ["type", "tier", "status", "category", "owner", "role", "pin"]; + metaFields.forEach(f => { + if (node[f] != null) { + const d = document.createElement("div"); + d.innerHTML = `${f}: ${node[f]}`; + meta.appendChild(d); + } + }); + if (node.governance) { + Object.entries(node.governance).forEach(([k, v]) => { + if (v != null) { + const d = document.createElement("div"); + d.innerHTML = `gov.${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`; + meta.appendChild(d); + } + }); + } + if (node.description) { + const d = document.createElement("div"); + d.style.marginTop = "8px"; + d.style.color = "#e7eaf0"; + d.textContent = node.description; + meta.appendChild(d); + } + // Body — fetch markdown if source_path + const body = document.getElementById("sideBody"); + if (node.source_path) { + body.textContent = "loading…"; + try { + const r = await fetch("/api/umbrella/doc?path=" + encodeURIComponent(node.source_path), { cache: "no-store" }); + if (r.ok) { + const j = await r.json(); + body.textContent = j.body || "(empty)"; + } else { + body.textContent = "(no doc — " + r.status + ")"; + } + } catch (e) { + body.textContent = "(fetch error: " + e + ")"; + } + } else { + body.textContent = "(no source_path)"; + } + // Edges + const edges = document.getElementById("sideEdges"); + edges.innerHTML = ""; + if (!cy) return; + const cyNode = cy.getElementById(node.id); + const outgoing = cyNode.connectedEdges().filter(e => e.source().id() === node.id); + const incoming = cyNode.connectedEdges().filter(e => e.target().id() === node.id); + if (outgoing.length) { + edges.innerHTML += `

outgoing (${outgoing.length})

    ` + + outgoing.map(e => + `
  • ${e.data("type")}
  • ` + ).join("") + `
`; + } + if (incoming.length) { + edges.innerHTML += `

incoming (${incoming.length})

    ` + + incoming.map(e => + `
  • ${e.data("type")}
  • ` + ).join("") + `
`; + } + edges.querySelectorAll("button[data-id]").forEach(b => { + b.addEventListener("click", () => { + const id = b.dataset.id; + const n = graph.nodes.find(n => n.id === id); + if (n) { openSidePanel(n); cy.getElementById(id).select(); } + }); + }); + } + + function closeSidePanel() { + document.getElementById("side").dataset.open = "false"; + if (cy) cy.elements().unselect(); + } + + function bindControls() { + document.getElementById("closeSide").addEventListener("click", closeSidePanel); + document.getElementById("reset").addEventListener("click", () => cy.fit(null, 30)); + document.querySelectorAll("button[data-layout]").forEach(b => { + b.addEventListener("click", () => { + const name = b.dataset.layout; + const opts = name === "cose" + ? { name, animate: true, idealEdgeLength: 80, nodeRepulsion: 12000 } + : name === "breadthfirst" + ? { name, animate: true, directed: true, padding: 10 } + : { name, animate: true }; + cy.layout(opts).run(); + }); + }); + const search = document.getElementById("search"); + search.addEventListener("input", () => { + const q = search.value.trim().toLowerCase(); + cy.batch(() => { + cy.nodes().forEach(n => { + const hit = !q || n.data("id").toLowerCase().includes(q) || + (n.data("raw").description || "").toLowerCase().includes(q); + n.style("opacity", hit ? 1 : 0.15); + }); + }); + }); + } + + async function init() { + try { + graph = await loadGraph(); + renderStats(graph); + renderFilters(graph); + renderGraph(graph); + bindControls(); + } catch (e) { + document.getElementById("stats").textContent = "load failed: " + e.message; + console.error("[umbrella]", e); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})();