Jesús Pérez 75892a8eea
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
feat: browser-style panel nav, repo file routing, migration 0007
graph, search, api_catalog pages: back/forward history stack (PanelNav/dpNav).
  File artifact paths open in external tabs via card.repo (Gitea source URL) or
  card.docs (cargo docs for .rs) — openFile/openFileInPanel removed from all pages.
  Tera | safe required for URL values inside <script> blocks (auto-escape of slashes).

  card.ncl: repo field added.
  insert_brand_ctx: injects card_repo/card_docs into Tera context.
  #[onto_api] proc-macro: source_file = file!() emitted; ApiRouteEntry.source_file
  populated in primary catalog handler.

  migration 0007-card-repo-field: check card.ncl for repo field; skip if absent.
2026-03-29 08:32:50 +01:00

1360 lines
57 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% block main_class %}container mx-auto px-4 py-6{% endblock main_class %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="/assets/vendor/cytoscape-navigator.js"></script>
<style>
#graph-fullscreen-wrapper:fullscreen,
#graph-fullscreen-wrapper:-webkit-full-screen {
display: flex;
flex-direction: column;
background: oklch(var(--b1));
padding: 12px 16px;
box-sizing: border-box;
}
#graph-fullscreen-wrapper:fullscreen #graph-root,
#graph-fullscreen-wrapper:-webkit-full-screen #graph-root {
flex: 1 1 auto;
height: 0; /* flex child — grows to fill remaining space */
}
#graph-root {
display: flex;
height: calc(100vh - 148px);
min-height: 400px;
gap: 0;
user-select: none;
}
#cy-wrapper {
flex: 1 1 auto;
min-width: 220px;
overflow: hidden;
border-radius: 0.5rem;
position: relative;
}
#cy { width: 100%; height: 100%; }
/* Floating controls — bottom-right */
#cy-controls {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
}
#cy-controls button {
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid oklch(var(--bc) / 0.18);
background: oklch(var(--b2) / 0.90);
color: oklch(var(--bc));
font-size: 16px;
font-weight: 600;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, border-color 0.12s, color 0.12s;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
#cy-controls button:hover {
background: oklch(var(--p) / 0.85);
border-color: oklch(var(--p));
color: oklch(var(--pc));
}
#cy-controls button svg {
width: 13px;
height: 13px;
flex-shrink: 0;
pointer-events: none;
}
.ctrl-divider {
height: 1px;
background: oklch(var(--bc) / 0.15);
margin: 2px 0;
}
/* Minimap — bottom-left */
#cy-nav {
position: absolute;
bottom: 14px;
left: 14px;
z-index: 10;
width: 160px;
height: 96px;
border-radius: 6px;
overflow: hidden;
border: 1px solid oklch(var(--bc) / 0.15);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* navigator internals */
#cy-nav canvas { display: block; }
.cy-navigator-view {
border: 2px solid oklch(var(--p) / 0.7) !important;
background: oklch(var(--p) / 0.12) !important;
border-radius: 2px !important;
}
#resize-handle {
flex: 0 0 6px;
cursor: col-resize;
background: transparent;
position: relative;
z-index: 20;
transition: background 0.15s;
}
#resize-handle:hover,
#resize-handle.dragging { background: oklch(var(--p) / 0.5); }
#resize-handle::after {
content: "";
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 40px;
border-radius: 99px;
background: oklch(var(--bc) / 0.25);
}
#detail-panel {
flex: 0 0 300px;
min-width: 160px;
max-width: 60%;
overflow-y: auto;
overflow-x: hidden;
border-radius: 0.5rem;
}
</style>
{% endblock head %}
{% block content %}
<input type="hidden" id="graph-slug" value="{% if slug %}{{ slug }}{% endif %}">
<!-- Legend modal -->
<dialog id="legend-modal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<div class="flex justify-between items-center mb-5">
<h3 class="font-bold text-lg">Graph Legend</h3>
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost"></button></form>
</div>
<div class="space-y-6 text-sm overflow-y-auto max-h-[70vh] pr-1">
<!-- Levels -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Levels — abstraction of knowledge</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="btn btn-xs btn-warning flex-shrink-0 pointer-events-none select-none" style="min-width:80px">◆ Axiom</span>
<div>
<p class="font-medium leading-snug">Fundamental invariant</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">What the project holds to be unconditionally true. Axioms cannot change without a formal ADR. They anchor the rest of the graph — every Tension and Practice traces back to at least one Axiom.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs btn-error flex-shrink-0 pointer-events-none select-none" style="min-width:80px">● Tension</span>
<div>
<p class="font-medium leading-snug">Active contradiction</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A force the project holds without resolving. Tensions are not problems to eliminate — they are productive oppositions that generate direction. Each Practice exists because a Tension demands a response.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs btn-success flex-shrink-0 pointer-events-none select-none" style="min-width:80px">▪ Practice</span>
<div>
<p class="font-medium leading-snug">Concrete approach</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">How the project operationalizes a Tension. Practices are the most mutable layer — they evolve as the project learns. They map directly to artifacts, ADRs, and code.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Extended levels -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Extended levels — code &amp; system layers</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#6366f1;border-color:#6366f1;color:#fff;min-width:80px">⬢ Crates</span>
<div>
<p class="font-medium leading-snug">Workspace crate</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A Rust crate declared in the workspace <code>Cargo.toml</code>. Edges show intra-workspace <code>DependsOn</code> relationships — external crates are omitted.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#6366f1;border-color:#6366f1;color:#fff;min-width:80px;opacity:0.8">⬡ API Crate</span>
<div>
<p class="font-medium leading-snug">API surface crate</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A crate that has exported <code>#[onto_api]</code> routes. Generated from <code>artifacts/api-catalog-*.ncl</code>.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#10b981;border-color:#10b981;color:#fff;min-width:80px">⊢ Routes</span>
<div>
<p class="font-medium leading-snug">HTTP route</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">An individual HTTP endpoint annotated with <code>#[onto_api]</code>. Each route is owned by exactly one API Crate node via a <code>Contains</code> edge.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;border-color:#ec4899;color:#fff;min-width:80px">⬡ State</span>
<div>
<p class="font-medium leading-snug">FSM dimension</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A lifecycle dimension from <code>.ontology/state.ncl</code>. Shows <code>current_state</code><code>desired_state</code>. <code>CoupledWith</code> edges link co-dependent dimensions. Invariant flag is set when current equals desired.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#14b8a6;border-color:#14b8a6;color:#fff;min-width:80px">⊓ Config</span>
<div>
<p class="font-medium leading-snug">Config section</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A named section in <code>.ontoref/config.ncl</code> as declared in <code>manifest.config_surface.sections</code>. Includes contract reference when a Nickel type contract is attached.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f97316;border-color:#f97316;color:#fff;min-width:80px">◈ Deps</span>
<div>
<p class="font-medium leading-snug">External requirement</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A tool, service, or infrastructure dependency from <code>manifest.requirements</code>. Invariant nodes are <em>required</em> (missing them breaks the project). Optional nodes degrade gracefully.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Poles -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Poles — which force drives the node</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f59e0b;color:#111;border-color:#f59e0b;min-width:64px">Yang</span>
<div>
<p class="font-medium leading-snug">Active · building · outward</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that assert, create, or push outward. Yang principles drive the definition of what a project <em>does</em> and what it makes available to others.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#3b82f6;color:#fff;border-color:#3b82f6;min-width:64px">Yin</span>
<div>
<p class="font-medium leading-snug">Receptive · constraining · protective</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that receive, limit, or protect. Yin principles define what the project <em>refuses</em>, what boundaries it holds, and what it conserves.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6;min-width:64px">Spiral</span>
<div>
<p class="font-medium leading-snug">Dialectical · both/and · evolving</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that hold both sides simultaneously and move through the tension. Spiral principles cannot be resolved into Yang or Yin — they are the engine of change itself.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;color:#fff;border-color:#ec4899;min-width:64px">State</span>
<div>
<p class="font-medium leading-snug">Lifecycle · FSM · temporal</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent the project's operational state model — FSM dimensions tracking where the project currently is and where it intends to go.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#14b8a6;color:#fff;border-color:#14b8a6;min-width:64px">Config</span>
<div>
<p class="font-medium leading-snug">Configuration · settings · tuneable</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that describe the project's runtime configuration surface — sections that can be overridden without recompiling, validated by Nickel contracts at load time.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f97316;color:#fff;border-color:#f97316;min-width:64px">Env</span>
<div>
<p class="font-medium leading-snug">Environment · external · required tooling</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent external dependencies the project cannot provide for itself — tools, services, and infrastructure that must be present in the environment.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Layouts -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Layouts — how the graph is arranged</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="join flex-shrink-0">
<span class="join-item btn btn-xs btn-primary pointer-events-none select-none">Hierarchy</span>
</div>
<div>
<p class="font-medium leading-snug">Breadth-first top-down</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Axioms at the root, Tensions in the middle, Practices at the leaves. Use this to trace causality: which invariants drive which contradictions, and which contradictions demand which responses.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="join flex-shrink-0">
<span class="join-item btn btn-xs btn-ghost pointer-events-none select-none">Force</span>
</div>
<div>
<p class="font-medium leading-snug">Physics simulation (COSE)</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes are pulled together by edges and pushed apart by repulsion. Heavily connected nodes cluster at the center. Use this to find hubs — nodes that influence many others — and isolated areas that may be undertested or underdocumented.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Visual cues -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Visual cues</p>
<div class="space-y-2.5">
<div class="flex items-center gap-3">
<span class="inline-block w-4 h-4 rounded-sm flex-shrink-0" style="background:#f59e0b;outline:3px solid #f59e0b;outline-offset:1px"></span>
<p class="text-base-content/70"><span class="font-medium text-base-content">Gold border</span> — invariant node. Cannot be modified without creating a formal ADR.</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-block w-4 h-4 rounded-sm flex-shrink-0" style="background:#6b7280;outline:2px solid #fff;outline-offset:1px"></span>
<p class="text-base-content/70"><span class="font-medium text-base-content">White border</span> — currently selected node.</p>
</div>
<div class="flex items-center gap-3">
<span class="inline-block w-4 h-4 rounded-sm flex-shrink-0 opacity-20" style="background:#6b7280"></span>
<p class="text-base-content/70"><span class="font-medium text-base-content">Dimmed nodes</span> — not connected to the selected node. Click the canvas to clear.</p>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Shortcuts -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Keyboard shortcuts</p>
<div class="grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">f</kbd><span class="text-base-content/60">Fit all nodes in view</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">+</kbd><span class="text-base-content/60">Zoom in</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">g</kbd><span class="text-base-content/60">Toggle full screen</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs"></kbd><span class="text-base-content/60">Zoom out</span></div>
<div class="flex gap-2 items-center"><kbd class="kbd kbd-xs">Esc</kbd><span class="text-base-content/60">Close detail panel</span></div>
</div>
</section>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<div id="graph-fullscreen-wrapper">
<!-- Toolbar row 1: title + inline node search + layout controls -->
<div class="flex items-center justify-between mb-1 gap-2">
<h1 class="text-lg font-bold shrink-0">Ontology Graph</h1>
<div class="relative flex-1 max-w-xs min-w-0" id="search-wrap">
<input id="graph-search" type="search" placeholder="Search nodes…"
class="input input-bordered input-xs w-full pr-14 font-mono text-xs"
autocomplete="off" autocorrect="off" spellcheck="false">
<span class="absolute right-2 top-1.5 text-xs text-base-content/30 pointer-events-none select-none"
id="search-count"></span>
<ul id="search-dropdown"
class="hidden absolute left-0 top-full mt-1 w-full bg-base-100 border border-base-content/15 rounded-lg shadow-xl z-50 max-h-56 overflow-y-auto text-xs">
</ul>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="join">
<button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button>
<button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button>
</div>
<button id="btn-reset" class="btn btn-xs btn-ghost">Reset</button>
<button id="btn-legend" class="btn btn-xs btn-ghost" title="Graph legend">?</button>
</div>
</div>
<!-- Toolbar row 2: filters, grouped by category -->
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mb-2 text-xs">
<!-- Ontology core -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Ontology</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button>
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button>
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button>
</div>
<div class="w-px h-4 bg-base-content/15 self-center"></div>
<!-- Code surface -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Code</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs" data-level="WorkspaceCrate" style="background:#6366f1;border-color:#6366f1;color:#fff">⬢ Crates</button>
<button class="filter-btn btn btn-xs" data-level="Crate" style="background:#6366f1;border-color:#6366f1;color:#fff;opacity:0.8">⬡ API Crate</button>
<button class="filter-btn btn btn-xs" data-level="Route" style="background:#10b981;border-color:#10b981;color:#fff">⊢ Routes</button>
</div>
<div class="w-px h-4 bg-base-content/15 self-center"></div>
<!-- System -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">System</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs" data-level="Dimension" style="background:#ec4899;border-color:#ec4899;color:#fff">⬡ State</button>
<button class="filter-btn btn btn-xs" data-level="ConfigSection" style="background:#14b8a6;border-color:#14b8a6;color:#fff">⊓ Config</button>
<button class="filter-btn btn btn-xs" data-level="Requirement" style="background:#f97316;border-color:#f97316;color:#fff">◈ Deps</button>
</div>
<div class="w-px h-4 bg-base-content/15 self-center"></div>
<!-- Poles -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Pole</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button>
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button>
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button>
<button class="filter-btn btn btn-xs" style="background:#ec4899;color:#fff;border-color:#ec4899" data-pole="State">State</button>
<button class="filter-btn btn btn-xs" style="background:#14b8a6;color:#fff;border-color:#14b8a6" data-pole="Config">Config</button>
<button class="filter-btn btn btn-xs" style="background:#f97316;color:#fff;border-color:#f97316" data-pole="Env">Env</button>
</div>
</div>
<!-- Split: graph | drag handle | detail -->
<div id="graph-root">
<div id="cy-wrapper" class="bg-base-200">
<div id="cy"></div>
<!-- Minimap -->
<div id="cy-nav"></div>
<!-- Floating zoom + nav controls -->
<div id="cy-controls">
<button id="cy-zoom-in" title="Zoom in">+</button>
<button id="cy-zoom-out" title="Zoom out"></button>
<div class="ctrl-divider"></div>
<button id="cy-fit" title="Fit to view"></button>
<button id="cy-fullscreen" title="Full screen"></button>
</div>
</div>
<div id="resize-handle"></div>
<div id="detail-panel" class="bg-base-200 p-4 hidden">
<!-- Panel nav header -->
<div class="flex items-center gap-0.5 mb-2">
<button id="nav-back" class="btn btn-xs btn-ghost px-1.5" title="Back" disabled></button>
<button id="nav-forward" class="btn btn-xs btn-ghost px-1.5" title="Forward" disabled></button>
<h3 class="font-bold text-base leading-tight flex-1 truncate mx-1" id="d-name"></h3>
<button id="btn-close-panel" class="btn btn-xs btn-ghost flex-shrink-0"></button>
</div>
<!-- Node detail view -->
<div id="d-node-view">
<div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
<p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p>
<div id="d-artifacts" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Artifacts</p>
<ul id="d-artifact-list" class="text-xs font-mono text-base-content/60 space-y-1 break-all"></ul>
</div>
<div id="d-adrs" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Validated by</p>
<ul id="d-adr-list" class="text-xs font-mono space-y-1"></ul>
</div>
<div id="d-edges" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
</div>
<div id="d-extra" class="hidden space-y-3"></div>
</div>
<!-- Content view: file or ADR rendered in-panel -->
<div id="d-content-view" class="hidden">
<p class="text-xs text-base-content/40 mb-3 font-mono break-all leading-relaxed" id="d-content-subtitle"></p>
<div id="d-content-body" class="text-sm space-y-3"></div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<script>
// ── Source link helper ────────────────────────────────────────────────────────
// Opens file paths in the configured repo (Gitea/GitHub /src/branch/main/{path}).
// Falls back to cargo-doc root for .rs files when docs URL is set.
// option 1 (load file) is intentionally not used.
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
function srcOpen(path) {
if (!path) return;
const ext = path.split('.').pop();
if (ext === 'rs' && CARD_DOCS) {
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
return;
}
if (CARD_REPO) {
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
return;
}
// no URL configured — nothing to open
}
const GRAPH = {{ graph_json | safe }};
// ── Icons ─────────────────────────────────────────────────────
const SVG_FIT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`;
const SVG_FS_ENTER = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>`;
const SVG_FS_EXIT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>`;
document.getElementById('cy-fit').innerHTML = SVG_FIT;
document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER;
// ── Graph data ────────────────────────────────────────────────
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6", Api: "#10b981", Code: "#6366f1", State: "#ec4899", Config: "#14b8a6", Env: "#f97316" };
const LEVEL_SHAPE = {
Axiom: "diamond",
Tension: "ellipse",
Practice: "round-rectangle",
Project: "hexagon",
Moment: "triangle",
Crate: "round-hexagon",
Route: "tag",
Dimension: "hexagon",
WorkspaceCrate: "barrel",
ConfigSection: "cut-rectangle",
Requirement: "round-tag",
};
const EDGE_STYLE = {
ManifestsIn: { color: "#6b7280" },
DependsOn: { color: "#ef4444" },
Resolves: { color: "#22c55e" },
Implies: { color: "#f59e0b" },
Complements: { color: "#3b82f6" },
Contradicts: { color: "#ec4899", dashed: true },
TensionWith: { color: "#f97316", dashed: true },
Contains: { color: "#a3a3a3" },
FlowsTo: { color: "#06b6d4" },
SpiralsWith: { color: "#8b5cf6" },
LimitedBy: { color: "#f43f5e" },
ValidatedBy: { color: "#84cc16" },
CoupledWith: { color: "#ec4899", dashed: true },
};
const nodes = (GRAPH.nodes || []).map(n => ({
data: {
id: n.id,
label: n.name || n.id,
pole: n.pole || "",
level: n.level || "",
description: n.description || "",
invariant: !!n.invariant,
artifact_paths: n.artifact_paths || [],
adrs: n.adrs || [],
color: POLE_COLOR[n.pole] || "#6b7280",
shape: LEVEL_SHAPE[n.level] || "ellipse",
// Route
method: n.method || "",
auth: n.auth || "",
actors: n.actors || [],
tags: n.tags || [],
params: n.params || [],
feature: n.feature || "",
// Dimension (FSM)
current_state: n.current_state || "",
desired_state: n.desired_state || "",
horizon: n.horizon || "",
// WorkspaceCrate
features: n.features || [],
ws_deps: n.ws_deps || [],
// ConfigSection
contract: n.contract || "",
// Requirement
env_kind: n.kind || "",
env_target: n.env || "",
required: !!n.required,
}
}));
const edges = (GRAPH.edges || []).map(e => {
const s = EDGE_STYLE[e.kind] || {};
return {
data: {
id: `${e.from}__${e.to}__${e.kind}`,
source: e.from,
target: e.to,
kind: e.kind || "",
note: e.note || "",
color: s.color || "#6b7280",
dashed: !!s.dashed,
}
};
});
// ── Cytoscape ─────────────────────────────────────────────────
const cy = cytoscape({
container: document.getElementById("cy"),
elements: { nodes, edges },
style: [
{
selector: "node",
style: {
"background-color": "data(color)",
"shape": "data(shape)",
"width": 32,
"height": 32,
"border-width": 2,
"border-color": "#111827",
"label": "data(label)",
"color": "#f9fafb",
"font-size": "10px",
"font-family": "ui-sans-serif, system-ui, sans-serif",
"font-weight": 500,
"text-valign": "bottom",
"text-halign": "center",
"text-margin-y": 6,
"text-wrap": "wrap",
"text-max-width": "100px",
"text-background-color": "#0f172a",
"text-background-opacity": 0.85,
"text-background-shape": "round-rectangle",
"text-background-padding": "3px",
"text-outline-width": 0,
}
},
{
selector: "node[?invariant]",
style: { "border-color": "#f59e0b", "border-width": 3 }
},
{
selector: "node:selected",
style: { "border-color": "#ffffff", "border-width": 3 }
},
{
selector: "node.faded",
style: { "opacity": 0.2, "text-opacity": 0.2 }
},
{
selector: "edge",
style: {
"line-color": "data(color)",
"target-arrow-color": "data(color)",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"width": 1.5,
"arrow-scale": 0.8,
"opacity": 0.6,
}
},
{
selector: "edge[?dashed]",
style: { "line-style": "dashed", "line-dash-pattern": [6, 4] }
},
{
selector: "edge.faded",
style: { "opacity": 0.05 }
},
{
selector: "edge.highlighted",
style: {
"opacity": 1,
"width": 2.5,
"label": "data(kind)",
"font-size": "9px",
"color": "#e5e7eb",
"text-background-color": "#0f172a",
"text-background-opacity": 0.85,
"text-background-padding": "2px",
"text-background-shape": "round-rectangle",
}
},
{
selector: "node.search-dim",
style: { "opacity": 0.12 }
},
{
selector: "node.search-match",
style: { "border-color": "#ffffff", "border-width": 3, "opacity": 1 }
},
],
layout: buildBfsLayout(false),
wheelSensitivity: 0.3,
boxSelectionEnabled: false,
minZoom: 0.1,
maxZoom: 4,
});
// ── Minimap (navigator) ───────────────────────────────────────
if (typeof cy.navigator === 'function') {
cy.navigator({
container: document.getElementById('cy-nav'),
viewLiveFramerate: 0,
thumbnailEventFramerate: 30,
thumbnailLiveFramerate: false,
dblClickDelay: 200,
removeCustomContainer: false,
rerenderDelay: 100,
});
} else {
document.getElementById('cy-nav').style.display = 'none';
}
// ── Layout builders ───────────────────────────────────────────
function buildBfsLayout(animate) {
return {
name: "breadthfirst",
directed: true,
animate: !!animate,
animationDuration: 500,
animationEasing: 'ease-in-out-cubic',
spacingFactor: 2.2,
padding: 48,
fit: true,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
};
}
function buildCoseLayout() {
return {
name: "cose",
animate: false,
randomize: true,
componentSpacing: 120,
nodeRepulsion: 800000,
nodeOverlap: 80,
idealEdgeLength: 220,
edgeElasticity: 20,
nestingFactor: 1.0,
gravity: 2,
numIter: 3000,
initialTemp: 2000,
coolingFactor: 0.99,
minTemp: 1.0,
fit: true,
padding: 60,
};
}
// ── Zoom at cursor ────────────────────────────────────────────
// Track last known mouse position over the canvas for cursor-centered zoom.
let lastCursorViewport = null;
document.getElementById('cy').addEventListener('mousemove', e => {
const rect = e.currentTarget.getBoundingClientRect();
lastCursorViewport = { x: e.clientX - rect.left, y: e.clientY - rect.top };
});
document.getElementById('cy').addEventListener('mouseleave', () => {
lastCursorViewport = null;
});
function zoomAt(factor) {
const oldZoom = cy.zoom();
const newZoom = Math.max(cy.minZoom(), Math.min(cy.maxZoom(), oldZoom * factor));
if (newZoom === oldZoom) return;
if (lastCursorViewport) {
const pan = cy.pan();
const { x, y } = lastCursorViewport;
cy.animate({
zoom: newZoom,
pan: {
x: x - (x - pan.x) * (newZoom / oldZoom),
y: y - (y - pan.y) * (newZoom / oldZoom),
},
}, { duration: 180, easing: 'ease-in-out-quad' });
} else {
cy.animate({ zoom: newZoom }, { duration: 180, easing: 'ease-in-out-quad' });
}
}
// ── Floating controls ─────────────────────────────────────────
document.getElementById('cy-zoom-in').addEventListener('click', () => zoomAt(1.35));
document.getElementById('cy-zoom-out').addEventListener('click', () => zoomAt(1 / 1.35));
document.getElementById('cy-fit').addEventListener('click', () => {
cy.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 350, easing: 'ease-in-out-cubic' });
});
// ── Full-screen ───────────────────────────────────────────────
const graphFsWrapper = document.getElementById('graph-fullscreen-wrapper');
const btnFullscreen = document.getElementById('cy-fullscreen');
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement);
}
btnFullscreen.addEventListener('click', () => {
if (!isFullscreen()) {
const req = graphFsWrapper.requestFullscreen || graphFsWrapper.webkitRequestFullscreen;
if (req) req.call(graphFsWrapper);
} else {
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document);
}
});
function onFullscreenChange() {
const fs = isFullscreen();
btnFullscreen.innerHTML = fs ? SVG_FS_EXIT : SVG_FS_ENTER;
btnFullscreen.title = fs ? 'Exit full screen' : 'Full screen';
requestAnimationFrame(() => { cy.resize(); cy.fit(undefined, 48); });
}
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
// ── Filters ───────────────────────────────────────────────────
const hiddenLevels = new Set(["Crate", "Route", "Dimension", "WorkspaceCrate", "ConfigSection", "Requirement"]);
const hiddenPoles = new Set();
// Reflect initial hidden state on buttons.
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
if (hiddenLevels.has(btn.dataset.level)) {
btn.style.opacity = "0.4";
btn.style.textDecoration = "line-through";
}
});
function applyFilters() {
cy.nodes().forEach(n => {
const hide = hiddenLevels.has(n.data("level")) || hiddenPoles.has(n.data("pole"));
if (hide) n.hide(); else n.show();
});
cy.edges().forEach(e => {
if (e.source().hidden() || e.target().hidden()) e.hide();
else e.show();
});
}
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
btn.addEventListener("click", () => {
const level = btn.dataset.level;
if (hiddenLevels.has(level)) {
hiddenLevels.delete(level);
btn.style.opacity = "1";
btn.style.textDecoration = "";
} else {
hiddenLevels.add(level);
btn.style.opacity = "0.4";
btn.style.textDecoration = "line-through";
}
applyFilters();
});
});
document.querySelectorAll(".filter-btn[data-pole]").forEach(btn => {
btn.addEventListener("click", () => {
const pole = btn.dataset.pole;
if (hiddenPoles.has(pole)) {
hiddenPoles.delete(pole);
btn.style.opacity = "1";
btn.style.textDecoration = "";
} else {
hiddenPoles.add(pole);
btn.style.opacity = "0.4";
btn.style.textDecoration = "line-through";
}
applyFilters();
});
});
// ── Layout buttons ────────────────────────────────────────────
const btnBfs = document.getElementById("btn-bfs");
const btnCose = document.getElementById("btn-cose");
btnBfs.addEventListener("click", () => {
cy.layout(buildBfsLayout(true)).run();
btnBfs.classList.add("btn-primary"); btnBfs.classList.remove("btn-ghost");
btnCose.classList.add("btn-ghost"); btnCose.classList.remove("btn-primary");
});
btnCose.addEventListener("click", () => {
cy.layout(buildCoseLayout()).run();
btnCose.classList.add("btn-primary"); btnCose.classList.remove("btn-ghost");
btnBfs.classList.add("btn-ghost"); btnBfs.classList.remove("btn-primary");
});
document.getElementById("btn-legend").addEventListener("click", () => {
document.getElementById("legend-modal").showModal();
});
document.getElementById("btn-reset").addEventListener("click", () => {
cy.elements().removeClass("faded highlighted");
cy.animate(
{ fit: { eles: cy.elements(':visible'), padding: 48 } },
{ duration: 400, easing: 'ease-in-out-cubic' }
);
closePanel();
});
// ── Panel refs ────────────────────────────────────────────────
const panel = document.getElementById("detail-panel");
const dName = document.getElementById("d-name");
const dBadges = document.getElementById("d-badges");
const dDesc = document.getElementById("d-description");
const dArtifacts = document.getElementById("d-artifacts");
const dList = document.getElementById("d-artifact-list");
const dAdrs = document.getElementById("d-adrs");
const dAdrList = document.getElementById("d-adr-list");
const dEdges = document.getElementById("d-edges");
const dEdgeList = document.getElementById("d-edge-list");
const dExtra = document.getElementById("d-extra");
const dNodeView = document.getElementById("d-node-view");
const dContentView = document.getElementById("d-content-view");
const dContentSub = document.getElementById("d-content-subtitle");
const dContentBody = document.getElementById("d-content-body");
const navBack = document.getElementById("nav-back");
const navForward = document.getElementById("nav-forward");
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
// ── Panel history (browser-style back/forward) ────────────────
const panelNav = {
stack: [],
cursor: -1,
push(entry) {
this.stack = this.stack.slice(0, this.cursor + 1);
this.stack.push(entry);
this.cursor = this.stack.length - 1;
this._sync();
},
back() {
if (this.cursor <= 0) return;
this.cursor--;
this._replay();
this._sync();
},
forward() {
if (this.cursor >= this.stack.length - 1) return;
this.cursor++;
this._replay();
this._sync();
},
reset() {
this.stack = [];
this.cursor = -1;
this._sync();
},
_replay() {
const e = this.stack[this.cursor];
if (e.type === "node") openNode(e.id, { push: false, animate: true });
else if (e.type === "adr") openAdr(e.id, { push: false });
else if (e.type === "file") srcOpen(e.id);
},
_sync() {
navBack.disabled = this.cursor <= 0;
navForward.disabled = this.cursor >= this.stack.length - 1;
}
};
navBack.addEventListener("click", () => panelNav.back());
navForward.addEventListener("click", () => panelNav.forward());
// ── Panel view switching ──────────────────────────────────────
function showNodeView() {
dNodeView.classList.remove("hidden");
dContentView.classList.add("hidden");
}
function showContentView(subtitle) {
dNodeView.classList.add("hidden");
dContentView.classList.remove("hidden");
dContentSub.textContent = subtitle || "";
}
function closePanel() {
panel.classList.add("hidden");
cy.elements().removeClass("faded highlighted");
panelNav.reset();
}
// ── Open node in panel ────────────────────────────────────────
function openNode(id, { push = true, animate = true } = {}) {
const node = cy.getElementById(id);
if (!node.length) return;
const d = node.data();
panel.classList.remove("hidden");
showNodeView();
dName.textContent = d.label;
dBadges.innerHTML =
`<span class="badge badge-xs badge-outline">${d.level}</span>` +
`<span class="badge badge-xs" style="background:${d.color};color:#111;border:none">${d.pole}</span>` +
(d.invariant ? `<span class="badge badge-xs badge-warning">invariant</span>` : "");
dDesc.textContent = d.description;
if (d.artifact_paths.length) {
dArtifacts.classList.remove("hidden");
dList.innerHTML = d.artifact_paths.map(p => {
const ext = p.split(".").pop();
const canView = ["ncl","toml","rs","nu","md","json","html"].includes(ext);
return `<li class="flex items-center gap-1.5 break-all">` +
(canView
? `<button class="artifact-link font-mono text-xs text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 text-left cursor-pointer" data-path="${p}">${p}</button>`
: `<code class="text-xs text-base-content/60">${p}</code>`) +
`</li>`;
}).join("");
} else {
dArtifacts.classList.add("hidden");
}
if (d.adrs.length) {
dAdrs.classList.remove("hidden");
dAdrList.innerHTML = d.adrs.map(a =>
`<li><span class="text-success mr-1">◆</span>` +
`<button class="adr-link font-mono text-xs text-base-content/70 hover:text-primary underline-offset-2 hover:underline cursor-pointer bg-transparent border-none p-0" data-adr="${a}">${a}</button></li>`
).join("");
} else {
dAdrs.classList.add("hidden");
}
const conn = node.connectedEdges();
if (conn.length) {
dEdges.classList.remove("hidden");
dEdgeList.innerHTML = conn.map(e => {
const isSrc = e.data("source") === d.id;
const otherId = isSrc ? e.data("target") : e.data("source");
const other = cy.getElementById(otherId);
const otherLbl = other.data("label") || otherId;
const arrow = isSrc ? "→" : "←";
return `<li class="flex gap-1 items-baseline">` +
`<span class="opacity-40 flex-shrink-0">${arrow}</span>` +
`<span class="text-base-content/80 flex-shrink-0 text-xs font-medium">${e.data("kind")}</span>` +
`<button class="node-jump-link text-xs opacity-60 break-all bg-transparent border-none p-0 cursor-pointer hover:text-primary hover:underline underline-offset-2 text-left" data-node-id="${otherId}">${otherLbl}</button>` +
`</li>`;
}).join("");
} else {
dEdges.classList.add("hidden");
}
renderNodeExtra(d);
cy.elements().addClass("faded").removeClass("highlighted");
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
if (animate) {
cy.animate(
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
{ duration: 350, easing: "ease-in-out-cubic" }
);
}
if (push) panelNav.push({ type: "node", id });
}
cy.on("tap", "node", evt => { openNode(evt.target.data("id")); });
cy.on("tap", evt => { if (evt.target === cy) closePanel(); });
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
// ── Resizable split ───────────────────────────────────────────
const handle = document.getElementById("resize-handle");
const cyWrapper = document.getElementById("cy-wrapper");
let resizing = false;
handle.addEventListener("mousedown", e => {
resizing = true;
handle.classList.add("dragging");
document.body.style.cursor = "col-resize";
e.preventDefault();
});
const graphRoot = document.getElementById("graph-root");
document.addEventListener("mousemove", e => {
if (!resizing) return;
const rect = graphRoot.getBoundingClientRect();
const hW = handle.offsetWidth;
const cyW = Math.max(220, e.clientX - rect.left);
const panW = Math.max(160, rect.width - cyW - hW);
if (cyW + panW + hW <= rect.width + 2) {
cyWrapper.style.flex = `0 0 ${cyW}px`;
if (!panel.classList.contains("hidden")) {
panel.style.flex = `0 0 ${panW}px`;
}
cy.resize();
}
});
document.addEventListener("mouseup", () => {
if (!resizing) return;
resizing = false;
handle.classList.remove("dragging");
document.body.style.cursor = "";
});
// ── Shared helpers ────────────────────────────────────────────
function esc(s) { return String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
function renderKvSection(data, skipKeys = []) {
return Object.entries(data)
.filter(([k, v]) => !skipKeys.includes(k) && v !== "" && v !== null && v !== undefined)
.map(([k, v]) => {
const label = k.replace(/_/g, " ");
let val;
if (Array.isArray(v)) {
if (v.length === 0) return null;
val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70">${v.map(item =>
typeof item === "object"
? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded mt-0.5">${JSON.stringify(item, null, 2)}</pre></li>`
: `<li>${item}</li>`
).join("")}</ul>`;
} else if (typeof v === "object" && v !== null) {
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${JSON.stringify(v, null, 2)}</pre>`;
} else if (typeof v === "boolean") {
val = `<span class="badge badge-xs ${v ? "badge-success" : "badge-ghost"}">${v}</span>`;
} else {
val = `<span class="text-base-content/80">${v}</span>`;
}
return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${label}</p>${val}</div>`;
})
.filter(Boolean)
.join("");
}
// ── Open ADR in panel ─────────────────────────────────────────
async function openAdr(id, { push = true } = {}) {
panel.classList.remove("hidden");
showContentView("Architecture Decision Record");
dName.textContent = id;
dContentBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
const params = new URLSearchParams({ ...(GRAPH_SLUG ? { slug: GRAPH_SLUG } : {}) });
try {
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
const data = await res.json();
dContentBody.innerHTML = data.error
? `<p class="text-error text-sm">${esc(data.error)}</p>`
: renderKvSection(data, ["id"]) || `<p class="text-base-content/50 text-sm">No details.</p>`;
} catch (err) {
dContentBody.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
}
if (push) panelNav.push({ type: "adr", id });
}
// openFile is replaced by srcOpen — opens in repo/docs tab, no panel loading.
// ── Node-type extra sections in the side panel ────────────────
function renderNodeExtra(d) {
const el = dExtra;
el.innerHTML = "";
el.classList.add("hidden");
const sec = (label, content) =>
`<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">${label}</p>${content}</div>`;
const badge = (text, color) =>
`<span class="badge badge-xs" style="background:${color};color:#fff;border:none">${text}</span>`;
let html = "";
if (d.level === "Route") {
const authColor = { none: "#6b7280", viewer: "#f59e0b", bearer: "#3b82f6", admin: "#ef4444" };
const authBadge = badge(d.auth || "none", authColor[d.auth] || "#6b7280");
const methodBadge = badge(d.method, "#10b981");
html += sec("Endpoint",
`<div class="flex flex-wrap gap-1">${methodBadge}${authBadge}` +
(d.feature ? `<span class="badge badge-xs badge-outline">feature:${d.feature}</span>` : "") +
`</div>`);
if (d.actors?.length)
html += sec("Actors", d.actors.map(a => `<span class="badge badge-xs badge-ghost">${a}</span>`).join(" "));
if (d.tags?.length)
html += sec("Tags", d.tags.map(t => `<code class="text-xs bg-base-300 px-1 rounded">${t}</code>`).join(" "));
if (d.params?.length) {
const rows = d.params.map(p =>
`<li class="flex flex-wrap gap-1 items-baseline">` +
`<code class="text-xs font-bold">${p.name}</code>` +
`<span class="text-xs text-base-content/40">${p.kind}</span>` +
`<span class="badge badge-xs badge-ghost">${p.constraint}</span>` +
(p.description ? `<span class="text-xs text-base-content/50">— ${p.description}</span>` : "") +
`</li>`
).join("");
html += sec("Parameters", `<ul class="space-y-1">${rows}</ul>`);
}
} else if (d.level === "Dimension") {
const reached = d.current_state && d.current_state === d.desired_state;
const statusBadge = reached
? `<span class="badge badge-xs badge-success">reached</span>`
: `<span class="badge badge-xs badge-warning">in progress</span>`;
html += sec("FSM State",
`<div class="space-y-1 text-xs">` +
`<div class="flex gap-2 items-center">${statusBadge}<span class="badge badge-xs badge-ghost">${d.horizon}</span></div>` +
`<div><span class="text-base-content/40">current: </span><code>${d.current_state}</code></div>` +
`<div><span class="text-base-content/40">desired: </span><code>${d.desired_state}</code></div>` +
`</div>`);
} else if (d.level === "WorkspaceCrate") {
if (d.features?.length)
html += sec("Features", d.features.map(f =>
`<span class="badge badge-xs badge-outline font-mono">${f}</span>`).join(" "));
if (d.ws_deps?.length) {
html += sec("Depends on",
d.ws_deps.map(dep => {
const depId = dep;
const depNode = cy.nodes(`[id = "${depId}"]`);
const label = depNode.length ? depNode.data("label") : depId.replace("ws:", "");
return `<button class="node-jump-link badge badge-xs badge-ghost cursor-pointer" data-node-id="${depId}">${label}</button>`;
}).join(" "));
}
} else if (d.level === "ConfigSection") {
if (d.contract)
html += sec("Contract",
`<code class="text-xs bg-base-300 px-1.5 py-0.5 rounded">${d.contract}</code>`);
} else if (d.level === "Requirement") {
html += sec("Details",
`<div class="flex flex-wrap gap-1">` +
(d.env_kind ? badge(d.env_kind, "#6366f1") : "") +
(d.env_target ? badge(d.env_target, "#8b5cf6") : "") +
(d.required ? `<span class="badge badge-xs badge-error">required</span>`
: `<span class="badge badge-xs badge-ghost">optional</span>`) +
`</div>`);
}
if (html) {
el.innerHTML = html;
el.classList.remove("hidden");
}
}
// ── Click delegation ──────────────────────────────────────────
document.addEventListener("click", e => {
// Close search dropdown on outside click
if (!e.target.closest("#search-wrap")) {
searchDropdown.classList.add("hidden");
}
const adrBtn = e.target.closest(".adr-link");
if (adrBtn) { openAdr(adrBtn.dataset.adr); return; }
const artBtn = e.target.closest(".artifact-link");
if (artBtn) { srcOpen(artBtn.dataset.path); return; }
const jumpBtn = e.target.closest(".node-jump-link");
if (jumpBtn) { openNode(jumpBtn.dataset.nodeId, { push: true, animate: true }); }
});
// ── Inline node search ────────────────────────────────────────
const graphSearch = document.getElementById("graph-search");
const searchCount = document.getElementById("search-count");
const searchDropdown = document.getElementById("search-dropdown");
let searchMatches = [];
function clearGraphSearch() {
cy.nodes().removeClass("search-match search-dim");
searchMatches = [];
searchCount.textContent = "";
searchDropdown.classList.add("hidden");
searchDropdown.innerHTML = "";
}
function doGraphSearch(q) {
clearGraphSearch();
if (!q) return;
const ql = q.toLowerCase();
searchMatches = cy.nodes(":visible").filter(n =>
(n.data("label") || "").toLowerCase().includes(ql) ||
(n.data("id") || "").toLowerCase().includes(ql) ||
(n.data("description") || "").toLowerCase().includes(ql)
).toArray();
searchCount.textContent = `${searchMatches.length}`;
if (searchMatches.length === 0) return;
// Highlight in graph
cy.nodes().addClass("search-dim");
searchMatches.forEach(n => n.removeClass("search-dim").addClass("search-match"));
// Dropdown list (cap 12)
const shown = searchMatches.slice(0, 12);
searchDropdown.innerHTML = shown.map((n, i) =>
`<li class="search-hit cursor-pointer px-3 py-2 hover:bg-base-300 transition-colors flex items-center gap-2" data-idx="${i}">` +
`<span class="badge badge-xs flex-shrink-0" style="background:${n.data("color")};border:none;color:#fff">${esc(n.data("level"))}</span>` +
`<span class="truncate">${esc(n.data("label"))}</span>` +
`</li>`
).join("");
if (searchMatches.length > 12) {
searchDropdown.innerHTML +=
`<li class="px-3 py-1.5 text-xs text-base-content/40">+${searchMatches.length - 12} more…</li>`;
}
searchDropdown.classList.remove("hidden");
}
graphSearch.addEventListener("input", () => {
const q = graphSearch.value.trim();
if (!q) { clearGraphSearch(); return; }
doGraphSearch(q);
});
graphSearch.addEventListener("keydown", e => {
if (e.key === "Escape") { graphSearch.value = ""; clearGraphSearch(); e.preventDefault(); return; }
if (e.key === "Enter" && searchMatches.length > 0) {
openNode(searchMatches[0].data("id"));
searchDropdown.classList.add("hidden");
e.preventDefault();
}
});
searchDropdown.addEventListener("click", e => {
const li = e.target.closest(".search-hit");
if (!li) return;
openNode(searchMatches[parseInt(li.dataset.idx, 10)].data("id"));
searchDropdown.classList.add("hidden");
});
// ── URL param auto-open ───────────────────────────────────────
const urlNode = new URLSearchParams(location.search).get("node");
if (urlNode) {
cy.ready(() => {
// Wait for layout to finish before centering
setTimeout(() => openNode(urlNode, { push: true, animate: true }), 600);
});
}
// ── Keyboard shortcuts ────────────────────────────────────────
document.addEventListener('keydown', e => {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return;
switch (e.key) {
case 'f':
case 'F':
e.preventDefault();
cy.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 350, easing: 'ease-in-out-cubic' });
break;
case '+':
case '=':
e.preventDefault();
zoomAt(1.35);
break;
case '-':
e.preventDefault();
zoomAt(1 / 1.35);
break;
case 'g':
case 'G':
e.preventDefault();
btnFullscreen.click();
break;
case 'Escape':
// Only close panel; fullscreen exit is handled natively by the browser
if (!isFullscreen() && !panel.classList.contains('hidden')) {
e.preventDefault();
closePanel();
}
break;
}
});
</script>
{% endblock scripts %}