2026-03-13 00:18:14 +00:00
{% extends "base.html" %}
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
feat: config surface, NCL contracts, override-layer mutation, on+re update
Config surface — per-project config introspection, coherence verification, and
audited mutation without destroying NCL structure (ADR-008):
- crates/ontoref-daemon/src/config.rs — typed DaemonNclConfig (parse-at-boundary
pattern); all section structs derive ConfigFields + config_section(id, ncl_file)
emitting inventory::submit!(ConfigFieldsEntry{...}) at link time
- crates/ontoref-derive/src/lib.rs — #[derive(ConfigFields)] proc-macro; serde
rename support; serde_rename_of() helper extracted to fix excessive_nesting
- crates/ontoref-daemon/src/main.rs — 3-tuple bootstrap block (nickel_import_path,
loaded_ncl_config: Option<DaemonNclConfig>, stdin_raw); apply_ui_config takes
&UiConfig; NATS call site typed; resolve_asset_dir cfg(feature = "ui")
- crates/ontoref-daemon/src/api.rs — config GET/PUT endpoints, quickref, coherence,
cross-project comparison; index_section_fields() extracted (excessive_nesting)
- crates/ontoref-daemon/src/config_coherence.rs — multi-consumer coherence;
merge_meta_into_section() extracted; and() replaces unnecessary and_then
NCL contracts for ontoref's own config:
- .ontoref/contracts.ncl — LogConfig (LogLevel, LogRotation, PositiveInt) and
DaemonConfig (Port, optional overrides); std.contract.from_validator throughout
- .ontoref/config.ncl — log | C.LogConfig applied
- .ontology/manifest.ncl — contracts_path, log/daemon contract refs, daemon section
with DaemonRuntimeConfig consumer and 7 declared fields
Protocol:
- adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl — NCL contracts
as single validation gate; Rust structs are contract-trusted; override-layer
mutation writes {section}.overrides.ncl + _overrides_meta, never touches source
on+re update:
- .ontology/core.ncl — config-surface node (28 practices); adr-lifecycle extended
to adr-007 + adr-008; 6 new edges (ManifestsIn daemon, DependsOn ontology-crate,
Complements api-catalog-surface/dag-formalized/self-describing/adopt-ontoref)
- .ontology/state.ncl — protocol-maturity blocker and self-description-coverage
catalyst updated for session 2026-03-26
- README.md / CHANGELOG.md updated
2026-03-26 20:20:22 +00:00
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
2026-03-29 08:32:50 +01:00
{% block main_class %}container mx-auto px-4 py-6{% endblock main_class %}
2026-03-13 00:18:14 +00:00
{% block head %}
< script src = "https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js" > < / script >
2026-03-29 00:19:56 +00:00
< script src = "/assets/vendor/cytoscape-navigator.js" > < / script >
2026-03-13 00:18:14 +00:00
< style >
2026-03-29 08:32:50 +01:00
#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 */
}
2026-03-13 00:18:14 +00:00
#graph-root {
display: flex;
height: calc(100vh - 148px);
min-height: 400px;
gap: 0;
user-select: none;
}
2026-03-29 00:19:56 +00:00
2026-03-13 00:18:14 +00:00
#cy-wrapper {
flex: 1 1 auto;
min-width: 220px;
overflow: hidden;
border-radius: 0.5rem;
2026-03-29 00:19:56 +00:00
position: relative;
2026-03-13 00:18:14 +00:00
}
#cy { width: 100%; height: 100%; }
2026-03-29 00:19:56 +00:00
/* 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;
}
2026-03-13 00:18:14 +00:00
#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 %}
2026-03-16 01:48:17 +00:00
< input type = "hidden" id = "graph-slug" value = "{% if slug %}{{ slug }}{% endif %}" >
2026-03-29 00:19:56 +00:00
<!-- 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 >
2026-03-29 08:32:50 +01:00
<!-- Extended levels -->
< section >
< p class = "text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3" > Extended levels — code & 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 >
2026-03-29 00:19:56 +00:00
<!-- 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 >
2026-03-29 08:32:50 +01:00
< 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 >
2026-03-29 00:19:56 +00:00
< / 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 >
2026-03-16 01:48:17 +00:00
2026-03-29 08:32:50 +01:00
< 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" >
2026-03-13 00:18:14 +00:00
< 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 >
2026-03-29 08:32:50 +01:00
< button id = "btn-reset" class = "btn btn-xs btn-ghost" > Reset< / button >
2026-03-29 00:19:56 +00:00
< button id = "btn-legend" class = "btn btn-xs btn-ghost" title = "Graph legend" > ?< / button >
2026-03-13 00:18:14 +00:00
< / div >
< / div >
2026-03-29 08:32:50 +01:00
<!-- 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 >
2026-03-13 00:18:14 +00:00
<!-- Split: graph | drag handle | detail -->
< div id = "graph-root" >
< div id = "cy-wrapper" class = "bg-base-200" >
< div id = "cy" > < / div >
2026-03-29 00:19:56 +00:00
<!-- 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 >
2026-03-13 00:18:14 +00:00
< / div >
< div id = "resize-handle" > < / div >
< div id = "detail-panel" class = "bg-base-200 p-4 hidden" >
2026-03-29 08:32:50 +01:00
<!-- 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 >
2026-03-13 00:18:14 +00:00
< / div >
2026-03-29 08:32:50 +01:00
<!-- 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 >
2026-03-16 01:48:17 +00:00
< / div >
2026-03-29 08:32:50 +01:00
<!-- 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 >
2026-03-13 00:18:14 +00:00
< / div >
< / div >
< / div >
2026-03-29 08:32:50 +01:00
< / div >
2026-03-13 00:18:14 +00:00
{% endblock content %}
{% block scripts %}
< script >
2026-03-29 08:32:50 +01:00
// ── 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
}
2026-03-13 00:18:14 +00:00
const GRAPH = {{ graph_json | safe }};
2026-03-29 00:19:56 +00:00
// ── 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 ────────────────────────────────────────────────
2026-03-29 08:32:50 +01:00
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6", Api: "#10b981", Code: "#6366f1", State: "#ec4899", Config: "#14b8a6", Env: "#f97316" };
2026-03-13 00:18:14 +00:00
const LEVEL_SHAPE = {
2026-03-29 08:32:50 +01:00
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",
2026-03-13 00:18:14 +00:00
};
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" },
2026-03-29 08:32:50 +01:00
CoupledWith: { color: "#ec4899", dashed: true },
2026-03-13 00:18:14 +00:00
};
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 || [],
2026-03-16 01:48:17 +00:00
adrs: n.adrs || [],
2026-03-13 00:18:14 +00:00
color: POLE_COLOR[n.pole] || "#6b7280",
shape: LEVEL_SHAPE[n.level] || "ellipse",
2026-03-29 08:32:50 +01:00
// 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,
2026-03-13 00:18:14 +00:00
}
}));
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,
}
};
});
2026-03-29 00:19:56 +00:00
// ── Cytoscape ─────────────────────────────────────────────────
2026-03-13 00:18:14 +00:00
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: {
2026-03-29 00:19:56 +00:00
"opacity": 1,
"width": 2.5,
"label": "data(kind)",
2026-03-13 00:18:14 +00:00
"font-size": "9px",
2026-03-29 00:19:56 +00:00
"color": "#e5e7eb",
2026-03-13 00:18:14 +00:00
"text-background-color": "#0f172a",
"text-background-opacity": 0.85,
"text-background-padding": "2px",
"text-background-shape": "round-rectangle",
}
},
2026-03-29 08:32:50 +01:00
{
selector: "node.search-dim",
style: { "opacity": 0.12 }
},
{
selector: "node.search-match",
style: { "border-color": "#ffffff", "border-width": 3, "opacity": 1 }
},
2026-03-13 00:18:14 +00:00
],
2026-03-29 00:19:56 +00:00
layout: buildBfsLayout(false),
2026-03-13 00:18:14 +00:00
wheelSensitivity: 0.3,
boxSelectionEnabled: false,
minZoom: 0.1,
maxZoom: 4,
});
2026-03-29 00:19:56 +00:00
// ── 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) {
2026-03-13 00:18:14 +00:00
return {
name: "breadthfirst",
directed: true,
2026-03-29 00:19:56 +00:00
animate: !!animate,
animationDuration: 500,
animationEasing: 'ease-in-out-cubic',
2026-03-13 00:18:14 +00:00
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,
};
}
2026-03-29 00:19:56 +00:00
// ── 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));
2026-03-13 00:18:14 +00:00
2026-03-29 00:19:56 +00:00
document.getElementById('cy-fit').addEventListener('click', () => {
cy.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 350, easing: 'ease-in-out-cubic' });
});
// ── Full-screen ───────────────────────────────────────────────
2026-03-29 08:32:50 +01:00
const graphFsWrapper = document.getElementById('graph-fullscreen-wrapper');
const btnFullscreen = document.getElementById('cy-fullscreen');
2026-03-29 00:19:56 +00:00
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement);
}
btnFullscreen.addEventListener('click', () => {
if (!isFullscreen()) {
2026-03-29 08:32:50 +01:00
const req = graphFsWrapper.requestFullscreen || graphFsWrapper.webkitRequestFullscreen;
if (req) req.call(graphFsWrapper);
2026-03-29 00:19:56 +00:00
} 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 ───────────────────────────────────────────────────
2026-03-29 08:32:50 +01:00
const hiddenLevels = new Set(["Crate", "Route", "Dimension", "WorkspaceCrate", "ConfigSection", "Requirement"]);
2026-03-13 00:18:14 +00:00
const hiddenPoles = new Set();
2026-03-29 08:32:50 +01:00
// 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";
}
});
2026-03-13 00:18:14 +00:00
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();
});
});
2026-03-29 00:19:56 +00:00
// ── Layout buttons ────────────────────────────────────────────
2026-03-13 00:18:14 +00:00
const btnBfs = document.getElementById("btn-bfs");
const btnCose = document.getElementById("btn-cose");
btnBfs.addEventListener("click", () => {
2026-03-29 00:19:56 +00:00
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");
2026-03-13 00:18:14 +00:00
});
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");
});
2026-03-29 00:19:56 +00:00
document.getElementById("btn-legend").addEventListener("click", () => {
document.getElementById("legend-modal").showModal();
});
2026-03-13 00:18:14 +00:00
document.getElementById("btn-reset").addEventListener("click", () => {
cy.elements().removeClass("faded highlighted");
2026-03-29 00:19:56 +00:00
cy.animate(
{ fit: { eles: cy.elements(':visible'), padding: 48 } },
{ duration: 400, easing: 'ease-in-out-cubic' }
);
2026-03-13 00:18:14 +00:00
closePanel();
});
2026-03-29 08:32:50 +01:00
// ── 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 || "";
}
2026-03-13 00:18:14 +00:00
function closePanel() {
panel.classList.add("hidden");
cy.elements().removeClass("faded highlighted");
2026-03-29 08:32:50 +01:00
panelNav.reset();
2026-03-13 00:18:14 +00:00
}
2026-03-29 08:32:50 +01:00
// ── Open node in panel ────────────────────────────────────────
function openNode(id, { push = true, animate = true } = {}) {
const node = cy.getElementById(id);
if (!node.length) return;
const d = node.data();
2026-03-29 00:19:56 +00:00
2026-03-13 00:18:14 +00:00
panel.classList.remove("hidden");
2026-03-29 08:32:50 +01:00
showNodeView();
2026-03-13 00:18:14 +00:00
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");
2026-03-29 08:32:50 +01:00
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("");
2026-03-13 00:18:14 +00:00
} else {
dArtifacts.classList.add("hidden");
}
2026-03-16 01:48:17 +00:00
if (d.adrs.length) {
dAdrs.classList.remove("hidden");
dAdrList.innerHTML = d.adrs.map(a =>
`< li > < span class = "text-success mr-1" > ◆< / span > ` +
2026-03-29 08:32:50 +01:00
`< 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 > `
2026-03-16 01:48:17 +00:00
).join("");
} else {
dAdrs.classList.add("hidden");
}
2026-03-29 00:19:56 +00:00
const conn = node.connectedEdges();
2026-03-13 00:18:14 +00:00
if (conn.length) {
dEdges.classList.remove("hidden");
dEdgeList.innerHTML = conn.map(e => {
2026-03-29 08:32:50 +01:00
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 > `;
2026-03-13 00:18:14 +00:00
}).join("");
} else {
dEdges.classList.add("hidden");
}
2026-03-29 08:32:50 +01:00
renderNodeExtra(d);
2026-03-13 00:18:14 +00:00
cy.elements().addClass("faded").removeClass("highlighted");
2026-03-29 00:19:56 +00:00
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
2026-03-29 08:32:50 +01:00
if (animate) {
cy.animate(
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
{ duration: 350, easing: "ease-in-out-cubic" }
);
}
2026-03-13 00:18:14 +00:00
2026-03-29 08:32:50 +01:00
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(); });
2026-03-13 00:18:14 +00:00
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
// ── Resizable split ───────────────────────────────────────────
2026-03-29 00:19:56 +00:00
const handle = document.getElementById("resize-handle");
const cyWrapper = document.getElementById("cy-wrapper");
2026-03-13 00:18:14 +00:00
let resizing = false;
handle.addEventListener("mousedown", e => {
resizing = true;
handle.classList.add("dragging");
document.body.style.cursor = "col-resize";
e.preventDefault();
});
2026-03-29 08:32:50 +01:00
const graphRoot = document.getElementById("graph-root");
2026-03-13 00:18:14 +00:00
document.addEventListener("mousemove", e => {
if (!resizing) return;
2026-03-29 00:19:56 +00:00
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);
2026-03-13 00:18:14 +00:00
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 = "";
});
2026-03-16 01:48:17 +00:00
2026-03-29 08:32:50 +01:00
// ── Shared helpers ────────────────────────────────────────────
function esc(s) { return String(s ?? "").replace(/&/g,"& ").replace(/< /g,"< ").replace(/>/g,"> ").replace(/"/g,"" "); }
2026-03-16 01:48:17 +00:00
2026-03-29 08:32:50 +01:00
function renderKvSection(data, skipKeys = []) {
return Object.entries(data)
.filter(([k, v]) => !skipKeys.includes(k) & & v !== "" & & v !== null & & v !== undefined)
2026-03-16 01:48:17 +00:00
.map(([k, v]) => {
const label = k.replace(/_/g, " ");
let val;
if (Array.isArray(v)) {
if (v.length === 0) return null;
2026-03-29 08:32:50 +01:00
val = `< ul class = "list-disc pl-4 space-y-0.5 text-base-content/70" > ${v.map(item =>
2026-03-16 01:48:17 +00:00
typeof item === "object"
2026-03-29 08:32:50 +01:00
? `< 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 > `
2026-03-16 01:48:17 +00:00
: `< 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 > `;
2026-03-29 08:32:50 +01:00
} else if (typeof v === "boolean") {
val = `< span class = "badge badge-xs ${v ? " badge-success " : " badge-ghost " } " > ${v}< / span > `;
2026-03-16 01:48:17 +00:00
} else {
val = `< span class = "text-base-content/80" > ${v}< / span > `;
}
2026-03-29 08:32:50 +01:00
return `< div > < p class = "text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5" > ${label}< / p > ${val}< / div > `;
2026-03-16 01:48:17 +00:00
})
.filter(Boolean)
.join("");
}
2026-03-29 08:32:50 +01:00
// ── 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 > `;
2026-03-16 01:48:17 +00:00
2026-03-29 08:32:50 +01:00
const params = new URLSearchParams({ ...(GRAPH_SLUG ? { slug: GRAPH_SLUG } : {}) });
2026-03-16 01:48:17 +00:00
try {
2026-03-29 08:32:50 +01:00
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
2026-03-16 01:48:17 +00:00
const data = await res.json();
2026-03-29 08:32:50 +01:00
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 > `;
2026-03-16 01:48:17 +00:00
} catch (err) {
2026-03-29 08:32:50 +01:00
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");
2026-03-16 01:48:17 +00:00
}
}
2026-03-29 08:32:50 +01:00
// ── Click delegation ──────────────────────────────────────────
2026-03-16 01:48:17 +00:00
document.addEventListener("click", e => {
2026-03-29 08:32:50 +01:00
// 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 }); }
2026-03-16 01:48:17 +00:00
});
2026-03-29 00:19:56 +00:00
2026-03-29 08:32:50 +01:00
// ── 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);
});
}
2026-03-29 00:19:56 +00:00
// ── 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;
}
});
2026-03-13 00:18:14 +00:00
< / script >
{% endblock scripts %}