OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / render.js

4054 lines · javascript ref:
import { NODE_VIEW_CSS, NODE_VIEW_HTML, NODE_VIEW_JS } from './render-nodes.js'

export function renderPage (tree, prefix) {
  // `<` is escaped to its JSON unicode form so nothing in the scanned data —
  // a JSDoc comment, a file path, a role string — can close the <script> tag
  // it is embedded in and truncate the whole application.
  const treeJSON = JSON.stringify(tree).replace(/</g, '\\u003c')
  const apiPrefix = prefix || ''

  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>codetree — ${tree.system.modules.length} modules</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  font-size: 13px;
  line-height: 1.5;
  background: #0d1117;
  color: #c9d1d9;
}
#app { display: flex; height: 100vh; }

/* Resizable panels */
.panel {
  overflow-y: auto;
  flex-shrink: 0;
  position: relative;
  min-width: 100px;
}
.resize-handle {
  width: 6px;
  height: 100%;
  cursor: col-resize;
  z-index: 30;
  background: #21262d;
  flex-shrink: 0;
  transition: background 0.15s;
}
.resize-handle:hover, .resize-handle.dragging {
  background: #58a6ff;
}

#sidebar {
  width: 340px;
  border-right: 1px solid #21262d;
  padding: 12px;
  padding-right: 16px; /* extra space so scrollbar doesn't cover resize handle */
  overflow-y: auto;
  overflow-x: hidden;
}
#sidebar::-webkit-scrollbar { width: 6px; }
#sidebar::-webkit-scrollbar-track { background: transparent; }
#sidebar::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
#sidebar::-webkit-scrollbar-thumb:hover { background: #484f58; }
#main {
  flex: 1;
  overflow-y: auto;
  padding: 16px 24px;
  position: relative;
  min-width: 200px;
}
#right-panel {
  width: 360px;
  border-left: 1px solid #21262d;
  padding: 12px;
  padding-left: 16px;
  overflow-y: auto;
  overflow-x: hidden;
  display: none;
}
#right-panel.active { display: block; }
#right-panel::-webkit-scrollbar { width: 6px; }
#right-panel::-webkit-scrollbar-track { background: transparent; }
#right-panel::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
#right-panel::-webkit-scrollbar-thumb:hover { background: #484f58; }
#right-panel h2 { font-size: 14px; color: #58a6ff; margin-bottom: 8px; }
.val-summary {
  display: flex; gap: 12px; margin-bottom: 12px; padding: 8px;
  background: #161b22; border-radius: 6px; font-size: 12px;
}
.val-pass { color: #3fb950; }
.val-fail { color: #ff7b72; }
.val-warn { color: #d29922; }
.val-count { font-weight: bold; font-size: 16px; display: block; }
.val-check {
  padding: 6px 8px;
  margin: 2px 0;
  border-radius: 4px;
  font-size: 11px;
  line-height: 1.4;
  border-left: 3px solid transparent;
}
.val-check.pass { border-left-color: #3fb950; background: #0d1f12; }
.val-check.fail { border-left-color: #ff7b72; background: #2d1014; }
.val-check.warn { border-left-color: #d29922; background: #2d2200; }
.val-check .val-claim { color: #c9d1d9; }
.val-check .val-detail { color: #8b949e; font-size: 10px; margin-top: 2px; }
.val-check .val-icon { margin-right: 4px; }
.val-section { margin-top: 12px; }
.val-section h3 {
  font-size: 12px; color: #8b949e; margin-bottom: 4px;
  text-transform: uppercase; letter-spacing: 0.5px;
}
.val-toggle {
  font-size: 11px; color: #58a6ff; cursor: pointer; float: right;
}
.val-filter { display: flex; gap: 4px; margin-bottom: 8px; }
.val-filter button {
  font-size: 10px; padding: 2px 8px; border-radius: 4px; cursor: pointer;
  font-family: inherit; border: 1px solid #30363d; background: #161b22; color: #8b949e;
}
.val-filter button.active { border-color: #58a6ff; color: #58a6ff; }

/* File viewer */
.file-viewer {
  background: #0d1117;
  border: 1px solid #21262d;
  border-radius: 6px;
  overflow: auto;
  max-height: calc(100vh - 120px);
  font-size: 12px;
  line-height: 1.6;
  margin-top: 8px;
}
#right-panel .file-viewer {
  max-height: calc(100vh - 100px);
}
.file-viewer table { border-collapse: collapse; width: 100%; }
.file-viewer tr:hover { background: #161b22; }
.file-viewer tr.highlight { background: #1a2a3a; }
.file-viewer tr.highlight .line-no { color: #00e5ff; }
.file-viewer .line-no {
  padding: 0 12px 0 8px;
  text-align: right;
  color: #484f58;
  user-select: none;
  vertical-align: top;
  white-space: nowrap;
  width: 1px;
  border-right: 1px solid #21262d;
}
.file-viewer .line-code {
  padding: 0 8px;
  white-space: pre;
  font-family: 'SF Mono', 'Fira Code', monospace;
  color: #c9d1d9;
}
.file-viewer-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 6px 12px;
  background: #161b22;
  border-bottom: 1px solid #21262d;
  border-radius: 6px 6px 0 0;
  font-size: 12px;
}
.file-viewer-header .fv-path { color: #58a6ff; }
.file-viewer-header .fv-info { color: #8b949e; }

/* Syntax colors (basic) */
.syn-kw { color: #ff7b72; }
.syn-str { color: #a5d6ff; }
.syn-num { color: #79c0ff; }
.syn-cmt { color: #8b949e; font-style: italic; }
.syn-fn { color: #d2a8ff; }
.syn-cls { color: #ffa657; }
h1 { font-size: 16px; color: #58a6ff; margin-bottom: 8px; }
h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; }

/* Tabs */
.tabs {
  display: flex;
  gap: 2px;
  margin-bottom: 10px;
  border-bottom: 1px solid #21262d;
  padding-bottom: 0;
}
.tab {
  padding: 5px 14px;
  cursor: pointer;
  color: #8b949e;
  border: 1px solid transparent;
  border-bottom: none;
  border-radius: 6px 6px 0 0;
  font-family: inherit;
  font-size: 12px;
  background: none;
}
.tab:hover { color: #c9d1d9; }
.tab.active {
  color: #c9d1d9;
  background: #161b22;
  border-color: #21262d;
  position: relative;
}
.tab.active::after {
  content: '';
  position: absolute;
  bottom: -1px;
  left: 0;
  right: 0;
  height: 1px;
  background: #161b22;
}
.tab-content { display: none; }
.tab-content.active { display: block; }

.search {
  width: 100%;
  padding: 6px 10px;
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 6px;
  color: #c9d1d9;
  font-family: inherit;
  font-size: 13px;
  margin-bottom: 12px;
}
.search:focus { outline: none; border-color: #58a6ff; }

/* Tree */
.tree { list-style: none; }
.tree ul { list-style: none; padding-left: 16px; }
.tree li { position: relative; }
.tree-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-bottom: 1px solid #21262d;
  background: #161b22;
  position: sticky;
  top: 0;
  z-index: 1;
}
.tree-header .tree-up {
  background: #21262d;
  border: 1px solid #30363d;
  color: #c9d1d9;
  cursor: pointer;
  font-family: inherit;
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 3px;
}
.tree-header .tree-up:hover { background: #30363d; }
.tree-header .tree-path {
  color: #8b949e;
  font-size: 11px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  flex: 1;
}
.node {
  display: flex;
  align-items: center;
  padding: 2px 4px;
  cursor: pointer;
  border-radius: 4px;
  gap: 6px;
  white-space: nowrap;
}
.node:hover { background: #161b22; }
.node.selected { background: #1f2937; }
.toggle {
  width: 14px; height: 14px;
  display: inline-flex; align-items: center; justify-content: center;
  color: #484f58; font-size: 10px; flex-shrink: 0;
}
.icon { width: 16px; text-align: center; flex-shrink: 0; }
.name { color: #c9d1d9; }
.badge {
  font-size: 10px; padding: 0 5px; border-radius: 10px;
  background: #21262d; color: #8b949e;
}
.collapsed > ul { display: none; }
/* Validation badges on nodes */
.val-badge {
  font-size: 9px;
  padding: 1px 5px;
  border-radius: 4px;
  margin-left: 3px;
  font-weight: bold;
}
.val-badge-fail { background: #ff7b72; color: #0d1117; }
.val-badge-warn { background: #d29922; color: #0d1117; }
.val-badge-pass { background: #3fb950; color: #0d1117; }
.val-badge-none { background: #21262d; color: #8b949e; }

.node.selected-module {
  background: #0a2a3a !important;
  border: 1px solid #00e5ff;
  margin: 1px 0;
}
.node.selected-module .name { color: #00e5ff !important; font-weight: bold; }
.node.selected-module::after {
  content: 'SELECTED';
  font-size: 9px;
  padding: 1px 5px;
  border-radius: 4px;
  background: #00e5ff;
  color: #0d1117;
  margin-left: 6px;
  font-weight: bold;
}
.node.dep-highlight {
  background: #1a3a0a !important;
  border: 1px solid #39ff14;
  margin: 1px 0;
}
.node.dep-highlight .name { color: #39ff14 !important; font-weight: bold; }
.node.dep-highlight::after {
  content: 'DEP';
  font-size: 9px;
  padding: 1px 5px;
  border-radius: 4px;
  background: #39ff14;
  color: #0d1117;
  margin-left: 6px;
  font-weight: bold;
}
.node.used-by-highlight {
  background: #3a3a0a !important;
  border: 1px solid #ffff00;
  margin: 1px 0;
}
.node.used-by-highlight .name { color: #ffff00 !important; font-weight: bold; }
.node.used-by-highlight::after {
  content: 'USES THIS';
  font-size: 9px;
  padding: 1px 5px;
  border-radius: 4px;
  background: #ffff00;
  color: #0d1117;
  margin-left: 6px;
  font-weight: bold;
}
.icon-module { color: #f0883e; }
.icon-file { color: #8b949e; }
.icon-class { color: #d2a8ff; }
.icon-method { color: #79c0ff; }
.icon-getter { color: #7ee787; }
.icon-function { color: #79c0ff; }
.icon-const { color: #ffa657; }
.icon-export { color: #3fb950; }

/* Detail panel */
.detail { margin-bottom: 16px; }
.detail h3 {
  font-size: 13px; color: #58a6ff; margin-bottom: 4px;
  border-bottom: 1px solid #21262d; padding-bottom: 4px;
}
.detail pre {
  background: #161b22; padding: 8px 12px; border-radius: 6px;
  overflow-x: auto; margin: 4px 0 8px;
}
.detail code { font-family: inherit; }
.tag {
  display: inline-block; font-size: 11px; padding: 1px 6px;
  border-radius: 4px; margin: 1px 2px;
}
.tag-async { background: #1f3a2d; color: #7ee787; }
.tag-static { background: #2d1f3a; color: #d2a8ff; }
.tag-generator { background: #3a2d1f; color: #ffa657; }
.tag-arrow { background: #1f2d3a; color: #79c0ff; }
.stats {
  display: flex; gap: 16px; padding: 8px 0;
  border-bottom: 1px solid #21262d; margin-bottom: 12px;
  color: #8b949e; font-size: 12px;
}
.stat-value { color: #c9d1d9; font-weight: bold; }

/* Graph */
#graph-container {
  position: absolute;
  top: 0; left: 0; right: 0; bottom: 0;
  display: none;
  overflow: hidden;
}
#graph-container.active { display: block; }
#graph-svg { width: 100%; height: 100%; cursor: grab; }
#graph-svg.dragging { cursor: grabbing; }
.graph-node { cursor: pointer; }
.graph-node circle { transition: opacity 0.2s; }
.graph-node.dimmed circle { opacity: 0.15; }
.graph-node.dimmed text { opacity: 0.15; }
.graph-node.focused circle { stroke: #f0883e; stroke-width: 3; filter: drop-shadow(0 0 6px rgba(240,136,62,0.5)); }
.graph-node text {
  font-family: 'SF Mono', monospace;
  fill: #c9d1d9;
  pointer-events: none;
}
.graph-edge { stroke: #30363d; stroke-width: 1.2; fill: none; marker-end: url(#arrowhead); }
.graph-edge.dimmed { stroke: #161b22; stroke-width: 0.5; }
.graph-edge.dep-of { stroke: #58a6ff; stroke-width: 2; }
.graph-edge.depends-on { stroke: #3fb950; stroke-width: 2; }
.graph-legend {
  position: absolute;
  bottom: 12px; left: 12px;
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 11px;
  color: #8b949e;
  pointer-events: none;
}
.graph-legend .leg-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
.graph-legend .leg-line { width: 20px; height: 2px; display: inline-block; }
.graph-controls {
  position: absolute;
  top: 12px; right: 12px;
  display: flex; gap: 4px;
}
.graph-controls button {
  background: #161b22; border: 1px solid #30363d; border-radius: 4px;
  color: #c9d1d9; font-size: 14px; width: 28px; height: 28px;
  cursor: pointer; font-family: inherit; display: flex; align-items: center; justify-content: center;
}
.graph-controls button:hover { background: #21262d; }
.graph-tooltip {
  position: absolute;
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 12px;
  pointer-events: none;
  display: none;
  z-index: 10;
  max-width: 320px;
  line-height: 1.6;
}
.graph-tooltip .tt-deps { color: #58a6ff; }
.graph-tooltip .tt-used { color: #3fb950; }

/* Graph settings panel */
.graph-settings {
  position: absolute;
  bottom: 12px; right: 12px;
  background: #161b22;
  border: 1px solid #30363d;
  border-radius: 6px;
  padding: 10px 14px;
  font-size: 11px;
  color: #8b949e;
  z-index: 5;
  max-width: 240px;
}
.graph-settings h4 { color: #c9d1d9; margin-bottom: 6px; font-size: 11px; }
.gs-row {
  display: flex; align-items: center; gap: 6px;
  padding: 3px 0;
}
.gs-row label { flex: 1; cursor: pointer; }
.gs-row select, .gs-row input[type=range] {
  background: #0d1117; border: 1px solid #30363d; border-radius: 3px;
  color: #c9d1d9; font-size: 10px; padding: 2px 4px; font-family: inherit;
}
.gs-row select { width: 100px; }
.gs-row input[type=range] { width: 80px; accent-color: #58a6ff; }
.gs-row input[type=checkbox] { accent-color: #58a6ff; }
.gs-divider { border-top: 1px solid #21262d; margin: 6px 0; }

/* Category regions */
.graph-region {
  fill-opacity: 0.12;
  stroke-opacity: 0.5;
  stroke-width: 2;
}
.graph-region-label {
  font-size: 14px;
  fill-opacity: 0.9;
  font-weight: bold;
  pointer-events: none;
  text-shadow: 0 0 8px #0d1117, 0 0 16px #0d1117;
}

/* Edge labels on hover */
.edge-hover-label {
  font-size: 9px;
  fill: #58a6ff;
  pointer-events: none;
  display: none;
}
.edge-hover-label.visible { display: block; }
${NODE_VIEW_CSS}
.graph-breadcrumb {
  position: absolute;
  top: 12px; left: 12px;
  display: flex; gap: 4px; align-items: center;
  font-size: 13px; z-index: 5;
}
.graph-breadcrumb span {
  color: #8b949e; cursor: pointer; padding: 4px 8px;
  background: #161b22; border: 1px solid #30363d; border-radius: 4px;
}
.graph-breadcrumb span:hover { color: #c9d1d9; border-color: #58a6ff; }
.graph-breadcrumb span.current { color: #00e5ff; border-color: #00e5ff; cursor: default; }
.graph-breadcrumb .sep { background: none; border: none; color: #484f58; cursor: default; padding: 0; }
</style>
</head>
<body>
<div id="app">
  <div id="sidebar" class="panel">
    <h1 style="display:flex;justify-content:space-between;align-items:center">codetree</h1>
    <div class="tabs">
      <button class="tab main-tab active" data-tab="tree">Tree</button>
      <button class="tab main-tab" data-tab="graph">Graph</button>
      <button class="tab main-tab" data-tab="nodes">Nodes</button>
      <button class="tab main-tab" data-tab="validate">Validate</button>
      <button class="tab main-tab" data-tab="docs">Docs</button>
    </div>
    <input class="search" id="search" type="text" placeholder="Filter modules, files, symbols...">
    <div style="display:flex;gap:4px;margin-bottom:8px">
      <button style="background:#161b22;border:1px solid #30363d;border-radius:4px;color:#8b949e;font-size:10px;padding:2px 8px;cursor:pointer;font-family:inherit" onclick="expandAll()">Expand All</button>
      <button style="background:#161b22;border:1px solid #30363d;border-radius:4px;color:#8b949e;font-size:10px;padding:2px 8px;cursor:pointer;font-family:inherit" onclick="collapseAll()">Collapse All</button>
    </div>
    <div id="tree-root"></div>
  </div>
  <div id="resize-left" class="resize-handle"></div>
  <div id="main">
    <div id="detail">
      <div class="stats" id="stats"></div>
      <div id="detail-content">
        <h2>Select a node to view details</h2>
      </div>
    </div>
    <div id="graph-container">
      <svg id="graph-svg">
        <defs>
          <marker id="arrowhead" viewBox="0 0 10 7" refX="10" refY="3.5"
            markerWidth="8" markerHeight="6" orient="auto-start-reverse">
            <polygon points="0 0, 10 3.5, 0 7" fill="#30363d" />
          </marker>
          <marker id="arrowhead-blue" viewBox="0 0 10 7" refX="10" refY="3.5"
            markerWidth="8" markerHeight="6" orient="auto-start-reverse">
            <polygon points="0 0, 10 3.5, 0 7" fill="#58a6ff" />
          </marker>
          <marker id="arrowhead-green" viewBox="0 0 10 7" refX="10" refY="3.5"
            markerWidth="8" markerHeight="6" orient="auto-start-reverse">
            <polygon points="0 0, 10 3.5, 0 7" fill="#3fb950" />
          </marker>
        </defs>
      </svg>
      <div class="graph-breadcrumb" id="graph-breadcrumb"></div>
      <div class="graph-controls">
        <button id="zoom-in" title="Zoom in">+</button>
        <button id="zoom-out" title="Zoom out">-</button>
        <button id="zoom-reset" title="Reset view">R</button>
      </div>
      <div class="graph-legend">
        <div class="leg-item"><span class="leg-line" style="background:#58a6ff"></span> depends on (selected)</div>
        <div class="leg-item"><span class="leg-line" style="background:#3fb950"></span> used by (selected)</div>
        <div class="leg-item"><span class="leg-line" style="background:#30363d"></span> other dependency</div>
        <div style="margin-top:4px; font-size:10px">Click to focus. Double-click to drill in. Scroll to zoom. Drag to pan.</div>
      </div>
      <div class="graph-tooltip" id="graph-tooltip"></div>
    </div>
${NODE_VIEW_HTML}
  </div>
  <div id="resize-right" class="resize-handle" style="display:none"></div>
  <div id="right-panel" class="panel" style="width:360px;border-left:1px solid #21262d;display:none;overflow-y:auto;padding:12px 12px 12px 16px">
    <div id="right-panel-tabs" style="display:flex;gap:2px;margin-bottom:8px;border-bottom:1px solid #21262d">
      <button class="tab active" data-rp="structure" onclick="switchRightPanel(&quot;structure&quot;)">Structure</button>
      <button class="tab" data-rp="validation" onclick="switchRightPanel(&quot;validation&quot;)">Validation</button>
      <button class="tab" data-rp="docs" onclick="switchRightPanel(&quot;docs&quot;)">Docs</button>
    </div>
    <div id="rp-structure" style="display:block">
      <div id="structure-content"><h2 style="color:#8b949e">Select a file to see structure</h2></div>
    </div>
    <div id="rp-validation" style="display:none">
      <div id="val-content">No validation data.</div>
    </div>
    <div id="rp-docs" style="display:none">
      <div id="docs-content"><h2 style="color:#8b949e">No spec file for this module</h2></div>
    </div>
  </div>
</div>

<script>
const tree = ${treeJSON};
const API = '${apiPrefix}';
let currentTab = 'tree'

// ====== TAB SWITCHING ======
document.querySelectorAll('.main-tab').forEach(tab => {
  tab.addEventListener('click', () => {
    document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'))
    tab.classList.add('active')
    currentTab = tab.dataset.tab

    const detail = document.getElementById('detail')
    const graphContainer = document.getElementById('graph-container')
    const nodeContainer = document.getElementById('node-container')
    const valPanel = document.getElementById('right-panel')

    // In graph mode, hide the detail/file-viewer panel — right panel handles everything
    detail.style.display = (currentTab === 'graph' || currentTab === 'nodes') ? 'none' : ''
    graphContainer.classList.toggle('active', currentTab === 'graph')
    nodeContainer.classList.toggle('active', currentTab === 'nodes')

    const rightPanel = document.getElementById('right-panel')
    const resizeRight = document.getElementById('resize-right')
    if (currentTab === 'validate') {
      // Validate tab = open right panel with validation, keep current center view
      rightPanel.style.display = 'block'
      resizeRight.style.display = ''
      if (!valInitialized) initValidation()
      switchRightPanel('validation')
      // Don't change center content — revert to previous tab's center
      detail.style.display = ''
      graphContainer.classList.remove('active')
    } else if (currentTab === 'docs') {
      // Docs tab = show doc cruft report in the center pane
      rightPanel.style.display = 'none'
      resizeRight.style.display = 'none'
      detail.style.display = ''
      graphContainer.classList.remove('active')
      renderDocsCruft()
    } else if (currentTab === 'graph') {
      if (!graphInitialized) { initGraph(); updateBreadcrumb() }
    } else if (currentTab === 'nodes') {
      rightPanel.style.display = 'none'
      resizeRight.style.display = 'none'
      nvInit()
    }
  })
})

// ====== TREE VIEW (File Manager) ======
//
// Flat current-path view. Mirrors the graph: shows ONLY what's at the current
// location. Click a directory → descend; click a module → focus + show
// details panel. Stays in sync with the graph (rebuildGraph calls buildTree).
//
// The previous implementation built a full nested DOM for every module + every
// file at page load, which made monorepo-scale scans crawl. This avoids that
// entirely — at any moment we only have N items in the DOM where N is the
// number of children at the current path (typically 5-50).
let selectedTreeNode = null

function buildTree () {
  const root = document.getElementById('tree-root')
  root.innerHTML = ''

  if (!tree.dirTree) {
    // No dirTree (e.g. very small scan with no children) — fallback to flat module list.
    const ul = el('ul', { className: 'tree' })
    for (const name of tree.system.modules) {
      ul.appendChild(buildModuleNode(name, tree.modules[name]))
    }
    root.appendChild(ul)
    updateStats()
    return
  }

  const path = (typeof getCurrentPath === 'function') ? getCurrentPath() : ''
  const items = (typeof getNodesAtPath === 'function')
    ? getNodesAtPath(path)
    : tree.dirTree.children.filter(c => c.type !== 'file').map(c => ({
        name: c.module || c.name,
        type: c.module ? 'module' : 'dir',
        dirEntry: c,
        mod: c.module ? tree.modules[c.module] : null
      }))

  // Header: up button + path
  const header = el('div', { className: 'tree-header' })
  if (path) {
    const upBtn = el('button', { className: 'tree-up', textContent: '↑ ..', title: 'Go up' })
    upBtn.addEventListener('click', () => navigateGraph(graphStack.length - 1))
    header.appendChild(upBtn)
  }
  const pathLabel = el('div', { className: 'tree-path' })
  pathLabel.textContent = path ? '/' + path : '/'
  header.appendChild(pathLabel)
  root.appendChild(header)

  // Flat list of items at this path
  const ul = el('ul', { className: 'tree' })
  for (const item of items) {
    ul.appendChild(buildPathItemNode(item))
  }
  root.appendChild(ul)
  updateStats()

  // Click background to deselect
  root.addEventListener('click', (e) => {
    if (e.target === root || e.target === ul) deselectAll()
  })
}

function buildPathItemNode (item) {
  const li = el('li')
  const node = el('div', { className: 'node', 'data-type': item.type, 'data-name': item.name })

  if (item.type === 'dir') {
    node.innerHTML = '<span class="icon" style="color:#8b949e">D</span>' +
      '<span class="name" style="color:#8b949e">' + item.name + '/</span>' +
      '<span class="badge">' + (item.packageCount || 0) + ' pkg</span>'
    node.addEventListener('click', (e) => {
      e.stopPropagation()
      descendIntoDir(item.name)
    })
  } else {
    const mod = item.mod
    const fileCount = mod ? Object.keys(mod.files).length : 0
    node.innerHTML = '<span class="icon icon-module">M</span>' +
      '<span class="name">' + item.name + '</span>' +
      '<span class="badge">' + (mod ? mod.version : '?') + '</span>' +
      '<span class="badge">' + fileCount + ' files</span>' +
      (mod ? '<span class="badge">' + mod.publicAPI.length + ' exports</span>' : '') +
      valBadgeHTML(item.name)
    node.addEventListener('click', (e) => {
      e.stopPropagation()
      if (mod) selectModule(item.name, mod, node)
    })
  }

  li.appendChild(node)
  return li
}

function deselectAll () {
  if (selectedTreeNode) {
    selectedTreeNode.classList.remove('selected-module')
  }
  selectedTreeNode = null
  document.querySelectorAll('.dep-highlight, .used-by-highlight, .selected-module').forEach(el => {
    el.classList.remove('dep-highlight', 'used-by-highlight', 'selected-module')
  })
  document.getElementById('detail-content').innerHTML = '<h2>Select a node to view details</h2>'
  document.getElementById('right-panel').style.display = 'none'
  document.getElementById('resize-right').style.display = 'none'
}

function buildDirNode (dirEntry) {
  // Is this a module directory?
  if (dirEntry.module && tree.modules[dirEntry.module]) {
    return buildModuleNode(dirEntry.module, tree.modules[dirEntry.module], dirEntry)
  }

  // Plain directory
  const li = el('li', { className: 'collapsed' })
  const hasChildren = dirEntry.children && dirEntry.children.length > 0
  const node = el('div', { className: 'node', 'data-type': 'dir', 'data-name': dirEntry.name })
  node.innerHTML = '<span class="toggle">' + (hasChildren ? '+' : ' ') + '</span>' +
    '<span class="icon" style="color:#8b949e">D</span>' +
    '<span class="name" style="color:#8b949e">' + dirEntry.name + '/</span>'
  li.appendChild(node)

  // Lazy child population: build the child DOM only on first expansion. For a
  // recursive monorepo scan this avoids creating tens of thousands of DOM
  // elements at page load (the reason the page was sluggish).
  let childrenBuilt = false
  function buildChildrenIfNeeded () {
    if (childrenBuilt || !hasChildren) return
    childrenBuilt = true
    const childUl = el('ul')
    for (const child of dirEntry.children) {
      if (child.type === 'dir' || child.type === 'module-dir') {
        childUl.appendChild(buildDirNode(child))
      }
    }
    for (const child of dirEntry.children) {
      if (child.type === 'file') {
        childUl.appendChild(buildFileNode(child.path, child, null))
      }
    }
    li.appendChild(childUl)
  }

  node.addEventListener('click', (e) => {
    e.stopPropagation()
    if (!hasChildren) return
    if (li.classList.contains('collapsed')) {
      buildChildrenIfNeeded()  // build on first expand
      li.classList.remove('collapsed')
      node.querySelector('.toggle').textContent = '-'
    } else {
      li.classList.add('collapsed')
      node.querySelector('.toggle').textContent = '+'
    }
  })

  return li
}

function buildModuleNode (name, mod, dirEntry) {
  const li = el('li', { className: 'collapsed' })
  const fileCount = Object.keys(mod.files).length
  const node = el('div', { className: 'node', 'data-type': 'module', 'data-name': name })
  node.innerHTML = '<span class="toggle">+</span><span class="icon icon-module">M</span>' +
    '<span class="name">' + name + '</span>' +
    '<span class="badge">' + mod.version + '</span>' +
    '<span class="badge">' + fileCount + ' files</span>' +
    '<span class="badge">' + mod.publicAPI.length + ' exports</span>' +
    valBadgeHTML(name)
  li.appendChild(node)

  const childUl = el('ul')

  // Merge non-JS files from dirTree into file list
  const allFiles = Object.assign({}, mod.files)
  if (dirEntry && dirEntry.children) {
    addDirTreeFiles(dirEntry, '', allFiles)
  }

  // Show files grouped by directory structure within the module
  const byDir = groupFilesByDir(allFiles)
  for (const [dir, files] of byDir) {
    if (dir === '.') {
      // Root-level files
      for (const [path, file] of files) {
        childUl.appendChild(buildFileNode(path, file, name))
      }
    } else {
      // Subdirectory
      const dirLi = el('li', { className: 'collapsed' })
      const dirNode = el('div', { className: 'node', 'data-type': 'dir' })
      dirNode.innerHTML = '<span class="toggle">+</span>' +
        '<span class="icon" style="color:#8b949e">D</span>' +
        '<span class="name" style="color:#8b949e">' + dir + '/</span>' +
        '<span class="badge">' + files.length + '</span>'
      dirLi.appendChild(dirNode)

      const subUl = el('ul')
      for (const [path, file] of files) {
        subUl.appendChild(buildFileNode(path, file, name))
      }
      dirLi.appendChild(subUl)

      dirNode.addEventListener('click', (e) => {
        e.stopPropagation()
        dirLi.classList.toggle('collapsed')
        dirNode.querySelector('.toggle').textContent = dirLi.classList.contains('collapsed') ? '+' : '-'
      })

      childUl.appendChild(dirLi)
    }
  }

  li.appendChild(childUl)
  node.addEventListener('click', (e) => {
    e.stopPropagation()
    if (selectedTreeNode === node) {
      // Second click: deselect + collapse
      deselectAll()
      li.classList.add('collapsed')
      node.querySelector('.toggle').textContent = '+'
    } else {
      // First click: select + expand
      li.classList.remove('collapsed')
      node.querySelector('.toggle').textContent = '-'
      selectModule(name, mod, node)
    }
  })
  return li
}

function selectModule (name, mod, nodeEl) {
  deselectAll()
  selectedTreeNode = nodeEl
  nodeEl.classList.add('selected-module')
  showDetail('module', { name, mod })
  highlightTreeDeps(name)
  showValidationForModule(name)
}

function addDirTreeFiles (dirNode, prefix, allFiles) {
  if (!dirNode.children) return
  for (const child of dirNode.children) {
    if (child.type === 'file') {
      const key = prefix ? prefix + '/' + child.name : child.name
      if (!allFiles[key]) {
        allFiles[key] = { name: child.name, ext: child.ext, size: child.size, role: null }
      }
    } else if (child.type === 'dir' && !['node_modules', '.git', 'vendor'].includes(child.name)) {
      const subPrefix = prefix ? prefix + '/' + child.name : child.name
      addDirTreeFiles(child, subPrefix, allFiles)
    }
  }
}

function groupFilesByDir (files) {
  const dirs = new Map()
  const entries = Object.entries(files).sort(([a], [b]) => a.localeCompare(b))

  for (const [path, file] of entries) {
    const parts = path.split('/')
    const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.'
    if (!dirs.has(dir)) dirs.set(dir, [])
    dirs.get(dir).push([path, file])
  }

  // Sort: root first, then alphabetical
  const sorted = new Map()
  if (dirs.has('.')) sorted.set('.', dirs.get('.'))
  for (const [dir, files] of [...dirs].sort(([a], [b]) => a.localeCompare(b))) {
    if (dir !== '.') sorted.set(dir, files)
  }
  return sorted
}

function buildFileNode (path, file, moduleName) {
  const li = el('li', { className: 'collapsed' })
  const fileName = path.includes('/') ? path.split('/').pop() : path

  // file might be a parsed JS file (with classes/functions) or a raw dirTree file entry
  const isParsed = file.exports || file.classes || file.functions || file.signals || file.uniforms || file.buffers
  const hasChildren = isParsed && ((file.classes && file.classes.length > 0) || (file.functions && file.functions.length > 0) || (file.signals && file.signals.length > 0))

  const counts = []
  if (file.classes) counts.push(file.classes.length + ' cls')
  if (file.functions) counts.push(file.functions.length + ' fn')

  const ext = fileName.includes('.') ? fileName.split('.').pop() : ''
  const icon = fileIcon(ext, file.role)

  const node = el('div', { className: 'node', 'data-type': 'file', 'data-name': path })
  node.innerHTML = '<span class="toggle">' + (hasChildren ? '+' : ' ') + '</span>' +
    '<span class="icon" style="color:' + icon.color + '">' + icon.char + '</span>' +
    '<span class="name">' + fileName + '</span>' +
    (counts.length ? '<span class="badge">' + counts.join(', ') + '</span>' : '') +
    (file.size ? '<span class="badge" style="color:#484f58">' + formatSize(file.size) + '</span>' : '')
  li.appendChild(node)

  if (hasChildren) {
    const childUl = el('ul')
    if (file.classes) for (const cls of file.classes) childUl.appendChild(buildClassNode(cls))
    if (file.functions) for (const fn of file.functions) childUl.appendChild(buildFunctionNode(fn))
    li.appendChild(childUl)
  }

  node.addEventListener('click', (e) => {
    e.stopPropagation()
    if (hasChildren) {
      li.classList.toggle('collapsed')
      node.querySelector('.toggle').textContent = li.classList.contains('collapsed') ? '+' : '-'
    }
    currentFileContext = { path, moduleName }
    const modPath = moduleName ? tree.modules[moduleName].path + '/' : ''
    loadFileViewer(modPath + path, moduleName)
    if (isParsed) showStructurePanel(path, file, moduleName)
  })
  return li
}

function fileIcon (ext, role) {
  const icons = {
    js: { char: 'JS', color: '#f0db4f' },
    mjs: { char: 'JS', color: '#f0db4f' },
    cjs: { char: 'CJ', color: '#f0db4f' },
    json: { char: '{}', color: '#8b949e' },
    md: { char: 'MD', color: '#58a6ff' },
    yml: { char: 'YM', color: '#ff7b72' },
    yaml: { char: 'YM', color: '#ff7b72' },
    toml: { char: 'TM', color: '#ffa657' },
    sh: { char: 'SH', color: '#3fb950' },
    c: { char: 'C', color: '#79c0ff' },
    h: { char: 'H', color: '#79c0ff' },
    txt: { char: 'TX', color: '#8b949e' },
    lock: { char: 'LK', color: '#484f58' },
    css: { char: 'CS', color: '#d2a8ff' },
    html: { char: 'HT', color: '#f0883e' }
  }
  if (role === 'entry') return { char: 'E', color: '#f0883e' }
  return icons[ext] || { char: ext.slice(0, 2).toUpperCase() || 'F', color: '#484f58' }
}

function formatSize (bytes) {
  if (bytes < 1024) return bytes + 'B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
  return (bytes / 1024 / 1024).toFixed(1) + 'MB'
}

// Track current file context for source viewing
let currentFileContext = null // { path, moduleName }

function buildClassNode (cls) {
  const li = el('li', { className: 'collapsed' })
  const memberCount = (cls.methods ? cls.methods.length : 0) + (cls.getters ? cls.getters.length : 0)
  const node = el('div', { className: 'node', 'data-type': 'class', 'data-name': cls.name })
  node.innerHTML = '<span class="toggle">' + (memberCount > 0 ? '+' : ' ') + '</span>' +
    '<span class="icon icon-class">C</span>' +
    '<span class="name">' + cls.name + '</span>' +
    (cls.extends ? '<span class="badge">extends ' + cls.extends + '</span>' : '') +
    '<span class="badge">' + memberCount + ' members</span>'
  li.appendChild(node)

  if (memberCount > 0) {
    const childUl = el('ul')
    if (cls.constructor) {
      const cLi = el('li')
      const cNode = el('div', { className: 'node' })
      cNode.innerHTML = '<span class="toggle"> </span><span class="icon icon-method">f</span>' +
        '<span class="name">constructor(' + cls.constructor.params.join(', ') + ')</span>'
      cNode.addEventListener('click', (e) => {
        e.stopPropagation()
        if (currentFileContext && cls.constructor.line) {
          const modPath = currentFileContext.moduleName ? tree.modules[currentFileContext.moduleName].path + '/' : ''
          loadFileViewer(modPath + currentFileContext.path, currentFileContext.moduleName, cls.constructor.line)
        } else {
          showDetail('constructor', { cls, constructor: cls.constructor })
        }
      })
      cLi.appendChild(cNode)
      childUl.appendChild(cLi)
    }
    if (cls.getters) for (const g of cls.getters) {
      const gLi = el('li')
      const gNode = el('div', { className: 'node' })
      gNode.innerHTML = '<span class="toggle"> </span><span class="icon icon-getter">g</span><span class="name">get ' + g.name + '</span>'
      gLi.appendChild(gNode)
      childUl.appendChild(gLi)
    }
    if (cls.methods) for (const m of cls.methods) {
      if (m.kind === 'field') continue
      const mLi = el('li')
      const mNode = el('div', { className: 'node', 'data-name': m.name })
      const tags = (m.async ? '<span class="tag tag-async">async</span>' : '') +
        (m.generator ? '<span class="tag tag-generator">*</span>' : '')
      mNode.innerHTML = '<span class="toggle"> </span><span class="icon icon-method">f</span>' +
        '<span class="name">' + m.name + '(' + m.params.join(', ') + ')</span>' + tags
      mNode.addEventListener('click', (e) => {
        e.stopPropagation()
        if (currentFileContext && m.line) {
          const modPath = currentFileContext.moduleName ? tree.modules[currentFileContext.moduleName].path + '/' : ''
          loadFileViewer(modPath + currentFileContext.path, currentFileContext.moduleName, m.line)
        } else {
          showDetail('method', { cls, method: m })
        }
      })
      mLi.appendChild(mNode)
      childUl.appendChild(mLi)
    }
    li.appendChild(childUl)
  }

  node.addEventListener('click', (e) => {
    e.stopPropagation()
    if (memberCount > 0) {
      li.classList.toggle('collapsed')
      node.querySelector('.toggle').textContent = li.classList.contains('collapsed') ? '+' : '-'
    }
    if (currentFileContext && cls.line) {
      const modPath = currentFileContext.moduleName ? tree.modules[currentFileContext.moduleName].path + '/' : ''
      loadFileViewer(modPath + currentFileContext.path, currentFileContext.moduleName, cls.line)
    } else {
      showDetail('class', { cls })
    }
  })
  return li
}

function buildFunctionNode (fn) {
  const li = el('li')
  const tags = (fn.async ? '<span class="tag tag-async">async</span>' : '') +
    (fn.arrow ? '<span class="tag tag-arrow">=&gt;</span>' : '')
  const node = el('div', { className: 'node', 'data-name': fn.name })
  node.innerHTML = '<span class="toggle"> </span><span class="icon icon-function">f</span>' +
    '<span class="name">' + fn.name + '(' + fn.params.join(', ') + ')</span>' + tags
  node.addEventListener('click', (e) => {
    e.stopPropagation()
    if (currentFileContext && fn.line) {
      const modPath = currentFileContext.moduleName ? tree.modules[currentFileContext.moduleName].path + '/' : ''
      loadFileViewer(modPath + currentFileContext.path, currentFileContext.moduleName, fn.line)
    } else {
      showDetail('function', { fn })
    }
  })
  li.appendChild(node)
  return li
}

// ====== DETAIL PANEL ======
function showDetail (type, data) {
  const panel = document.getElementById('detail-content')
  let html = ''
  switch (type) {
    case 'module': {
      const { name, mod } = data
      html = '<h2>' + name + '@' + mod.version + '</h2>'
      html += '<div class="detail"><h3>Public API</h3><pre><code>' + (mod.publicAPI.join('\\n') || '(none)') + '</code></pre></div>'
      html += '<div class="detail"><h3>Dependencies</h3><pre><code>' + (Object.entries(mod.dependencies).map(([k,v]) => k + ': ' + v).join('\\n') || '(none)') + '</code></pre></div>'
      if (mod.importMaps && Object.keys(mod.importMaps).length > 0) {
        html += '<div class="detail"><h3>Import Maps</h3><pre><code>' + JSON.stringify(mod.importMaps, null, 2) + '</code></pre></div>'
      }
      if (mod.testCommand) html += '<div class="detail"><h3>Test</h3><pre><code>' + mod.testCommand + '</code></pre></div>'
      break
    }
    case 'file': {
      const { path, file, moduleName } = data
      const modPath = moduleName ? tree.modules[moduleName].path + '/' : ''
      html = '<h2>' + moduleName + '/' + path + '</h2><div class="badge">' + file.role + '</div>'
      html += ' <span class="badge" style="cursor:pointer;color:#58a6ff;border:1px solid #58a6ff" onclick="loadFileViewer(&quot;' + modPath + path + '&quot;,&quot;' + (moduleName||'') + '&quot;)">View Source</span>'
      if (file.imports && file.imports.length) html += '<div class="detail"><h3>Imports</h3><pre><code>' + file.imports.map(i => i.names.join(', ') + ' from ' + i.specifier).join('\\n') + '</code></pre></div>'
      if (file.exports && file.exports.length) html += '<div class="detail"><h3>Exports</h3><pre><code>' + file.exports.map(e => e.name + (e.type ? ' (' + e.type + ')' : '') + (e.from ? ' from ' + e.from : '')).join('\\n') + '</code></pre></div>'
      if (file.state && file.state.length) html += '<div class="detail"><h3>State</h3><pre><code>' + file.state.join('\\n') + '</code></pre></div>'
      if (file.delegates && file.delegates.length) html += '<div class="detail"><h3>Delegation</h3><pre><code>' + file.delegates.map(d => d.field + ' -> ' + d.methods.join(', ')).join('\\n') + '</code></pre></div>'
      if (file.errorContracts && file.errorContracts.length) html += '<div class="detail"><h3>Errors</h3><pre><code>' + file.errorContracts.join('\\n') + '</code></pre></div>'
      break
    }
    case 'class': {
      const { cls } = data
      html = '<h2>class ' + cls.name + (cls.extends ? ' extends ' + cls.extends : '') + '</h2>'
      html += sourceLink(cls.line)
      if (cls.jsdoc) html += '<div class="detail"><h3>JSDoc</h3><pre><code>' + escapeHtml(cls.jsdoc) + '</code></pre></div>'
      if (cls.constructor) html += '<div class="detail"><h3>Constructor</h3><pre><code>constructor(' + cls.constructor.params.join(', ') + ')</code></pre></div>'
      html += '<div class="detail"><h3>Methods (' + cls.methods.length + ')</h3><pre><code>' + cls.methods.filter(m => m.kind !== 'field').map(m => (m.async ? 'async ' : '') + m.name + '(' + m.params.join(', ') + ')' + (m.line ? '  :' + m.line : '')).join('\\n') + '</code></pre></div>'
      if (cls.getters && cls.getters.length) html += '<div class="detail"><h3>Getters (' + cls.getters.length + ')</h3><pre><code>' + cls.getters.map(g => 'get ' + g.name + (g.line ? '  :' + g.line : '')).join('\\n') + '</code></pre></div>'
      break
    }
    case 'method': case 'constructor': {
      const m = data.method || data.constructor
      const label = data.method ? data.method.name : 'constructor'
      html = '<h2>' + data.cls.name + '.' + label + '</h2>'
      html += sourceLink(m.line)
      html += '<pre><code>' + (m.async ? 'async ' : '') + label + '(' + m.params.join(', ') + ')</code></pre>'
      if (m.jsdoc) html += '<div class="detail"><h3>JSDoc</h3><pre><code>' + escapeHtml(m.jsdoc) + '</code></pre></div>'
      break
    }
    case 'function': {
      const { fn } = data
      html = '<h2>' + fn.name + '</h2>'
      html += sourceLink(fn.line)
      html += '<pre><code>' + (fn.async ? 'async ' : '') + fn.name + '(' + fn.params.join(', ') + ')</code></pre>'
      if (fn.jsdoc) html += '<div class="detail"><h3>JSDoc</h3><pre><code>' + escapeHtml(fn.jsdoc) + '</code></pre></div>'
      break
    }
    case 'rawFile': {
      const { path, file } = data
      const ext = path.includes('.') ? path.split('.').pop() : ''
      html = '<h2>' + path + '</h2>'
      html += '<div class="badge">' + ext + '</div>'
      if (file.size) html += ' <span class="badge">' + formatSize(file.size) + '</span>'
      break
    }
  }
  panel.innerHTML = html
}

// ====== CONNECTOR INDEX ======
//
// Imports are minus connectors, exports are plus. An edge is the MATING of one
// germ's minus-name with another germ's plus-name. What matters visually is
// the connectors that DON'T mate: an unmated plus is an export nothing takes,
// an unmated minus is an import nothing supplies. Those are drawn as stubs.
//
// NOTE: no backticks anywhere below — this whole file body is one template
// literal, so a raw backtick terminates it.
//
// Fetched from /api/sections rather than inlined: the tree JSON embedded in
// this page is already large, and connectors would multiply it.
let sectionData = null

function loadSections () {
  if (sectionData) return Promise.resolve(sectionData)
  return fetch(API + '/api/sections')
    .then(r => r.json())
    .then(data => {
      if (data && data.perModule) sectionData = data
      return sectionData
    })
    .catch(() => null)
}

function misfitsFor (name) {
  if (!sectionData || !sectionData.perModule) return null
  return sectionData.perModule[name] || null
}

// One short line for the tooltip.
function misfitSummaryFor (name) {
  const m = misfitsFor(name)
  if (!m) return ''
  const parts = []
  if (m.dead) parts.push('<span style="color:#d29922">' + m.dead + ' dead export' + (m.dead === 1 ? '' : 's') + '</span>')
  if (m.unresolved) parts.push('<span style="color:#ff7b72">' + m.unresolved + ' unresolved</span>')
  if (m.leak) parts.push('<span style="color:#ff7b72">' + m.leak + ' undeclared dep' + (m.leak === 1 ? '' : 's') + '</span>')
  if (m.unused) parts.push('<span style="color:#8b949e">' + m.unused + ' unused dep' + (m.unused === 1 ? '' : 's') + '</span>')
  return parts.join(' &middot; ')
}

// ====== GRAPH VIEW ======
let graphInitialized = false
const graphNodes = []
const graphEdges = []
let focusedNode = null
let zoom = 1, panX = 0, panY = 0
let isPanning = false, panStartX = 0, panStartY = 0
let graphGroup = null

// --- Path-based navigation ---
//
// The graph shows ONLY what's at the current location in the directory tree.
// Navigate down by clicking a directory node; navigate up via the breadcrumb.
// This keeps each view bounded (typically 5-50 nodes) so force-directed
// simulation stays fast and the graph stays readable.

function getCurrentPath () {
  // Path entries on the graphStack accumulate as the user descends.
  return graphStack.filter(e => e.type === 'path').map(e => e.name).join('/')
}

// These three walked tree.dirTree here while the floor walked tree.modules for
// the same question, with nothing keeping the two answers in step. They now
// delegate to the shared module; the local names stay so every call site is
// unchanged.
function findDirTreeNode (path) {
  return window.__ct ? window.__ct.findDirTreeNode(tree, path) : null
}

function countPackagesUnder (dirEntry) {
  return window.__ct ? window.__ct.countPackagesUnder(dirEntry) : 0
}

// Returns: array of { name, type: 'dir'|'module', mod?, dirEntry, packageCount? }
// Each entry is one node to render at the current location.
function getNodesAtPath (path) {
  return window.__ct ? window.__ct.nodesAtPath(tree, path) : []
}

// Click handler for a directory node: descend into it.
window.descendIntoDir = function (dirName) {
  graphStack.push({ type: 'path', name: dirName, label: dirName })
  rebuildGraph()
  syncTreeWithGraph()
  if (typeof pushStateForStack === 'function') pushStateForStack()
}

function initGraph () {
  graphInitialized = true
  const svg = document.getElementById('graph-svg')
  const rect = svg.getBoundingClientRect()
  const W = rect.width
  const H = rect.height
  const cx = W / 2
  const cy = H / 2

  // Category groups come from the scanner, which resolves them ONCE into
  // tree.moduleCategories precisely so the CLI and the views cannot drift. This
  // used to reimplement that derivation against tree.categories — a second
  // answer to the same question, kept in step by nothing.
  const categories = tree.categories || {}
  const moduleToCategory = {}
  if (window.__ct) {
    for (const name of tree.system.modules) {
      moduleToCategory[name] = window.__ct.categoryOf(tree, name)
    }
  } else {
    for (const [cat, mods] of Object.entries(categories)) {
      for (const m of mods) moduleToCategory[m] = cat
    }
  }

  if (gfx.groupBy === 'deps') {
    // Override: cluster by most-shared dependency
    for (const k of Object.keys(moduleToCategory)) delete moduleToCategory[k]
    const shared = tree.system.sharedDeps || {}
    const topDeps = Object.entries(shared).sort((a, b) => b[1].length - a[1].length).slice(0, 6)
    for (const [dep, users] of topDeps) {
      for (const u of users) {
        if (!moduleToCategory[u]) moduleToCategory[u] = dep + ' users'
      }
    }
    for (const name of tree.system.modules) {
      if (!moduleToCategory[name]) moduleToCategory[name] = 'Other'
    }
  }
  // No else branch any more: the shared resolution above already applies the
  // curated groupings, then the shared-dependency fallback, then "Other".
  for (const name of tree.system.modules) {
    if (!moduleToCategory[name]) moduleToCategory[name] = 'Other'
  }

  // Colour EVERY category in use, not only the curated ones: a derived category
  // ("b4a users") used to fall through to a per-module colour, so the grouping
  // it belonged to lost its visual cue entirely.
  const categoryColors = {}
  for (const cat of new Set(Object.values(moduleToCategory))) {
    categoryColors[cat] = window.__ct ? window.__ct.categoryColor(tree, cat) : '#58a6ff'
  }

  // Build nodes for what's at the current path. This is the file-manager
  // model: each level of the dir tree is a separate, bounded view.
  const items = getNodesAtPath(getCurrentPath())
  const nameToIdx = {}

  // Categorization keeps the existing semantic data:
  //   • SYSTEM.md categories (curated, e.g. "Crypto & Identity")
  //   • Global shared-deps fallback (e.g. "hyperswarm users")
  // Pure directory nodes (no module entry) get a synthetic "Directories"
  // category so they cluster together visually.
  //
  // Only categories that actually apply to VISIBLE items are kept — labels
  // for off-screen modules don't pollute the legend.
  const catGroups = {}
  for (let i = 0; i < items.length; i++) {
    const item = items[i]
    let cat
    if (item.type === 'dir') {
      cat = 'Directories'
    } else {
      cat = (gfx.groupBy !== 'none' ? moduleToCategory[item.name] : null) || 'Other'
    }
    if (!catGroups[cat]) catGroups[cat] = []
    catGroups[cat].push(i)
  }
  const catList = Object.keys(catGroups)

  for (let i = 0; i < items.length; i++) {
    const item = items[i]
    const isDir = item.type === 'dir'
    const name = item.name
    const mod = item.mod

    // One definition of these counts, shared with every other view. This used
    // to count only class methods and getters, so a module of free functions
    // reported zero members here and a real number in the file view.
    const stats = window.__ct.moduleStats(mod)
    const fileCount = stats.fileCount
    const memberCount = stats.memberCount

    const cat = isDir ? 'Directories' : (moduleToCategory[name] || 'Other')
    const catI = catList.indexOf(cat)
    const group = catGroups[cat]
    const posInGroup = group.indexOf(i)

    // Position: spread categories around the center
    const spaceMult = 0.5 + (gfx.spacing / 100) * 2
    const catAngle = (catI / catList.length) * Math.PI * 2 - Math.PI / 2
    const catRadius = (Math.min(W, H) * 0.2 + catList.length * 8) * spaceMult
    const catCx = cx + Math.cos(catAngle) * catRadius
    const catCy = cy + Math.sin(catAngle) * catRadius
    const nodeAngle = (posInGroup / group.length) * Math.PI * 2
    const nodeRadius = Math.min(80, 20 + group.length * 10) * (0.7 + spaceMult * 0.3)

    // Dir nodes get a different size (based on how much they contain) and color.
    const exportCount = mod ? mod.publicAPI.length : 0
    const r = isDir
      ? Math.max(20, Math.min(50, 12 + Math.sqrt(item.packageCount) * 4))
      : calcNodeSize({ memberCount, usedBy: [], mod, exportCount })

    graphNodes.push({
      idx: i, name, mod,
      type: isDir ? 'dir' : 'module',
      packageCount: item.packageCount,
      dirEntry: item.dirEntry,
      x: catCx + Math.cos(nodeAngle) * nodeRadius,
      y: catCy + Math.sin(nodeAngle) * nodeRadius,
      vx: 0, vy: 0,
      r,
      fileCount, memberCount,
      exportCount,
      color: isDir ? '#484f58' : (categoryColors[cat] || moduleColor(name)),
      category: cat
    })
    nameToIdx[name] = i
  }

  // Build edges between visible MODULE nodes only. (Dir-to-dir aggregated edges
  // are a future enhancement; for now keep it simple.)
  for (let i = 0; i < graphNodes.length; i++) {
    const n = graphNodes[i]
    if (!n.mod) continue
    for (const dep of Object.keys(n.mod.dependencies)) {
      if (nameToIdx[dep] !== undefined) {
        graphEdges.push({ source: i, target: nameToIdx[dep] })
      }
    }
  }

  for (const n of graphNodes) {
    n.dependsOn = graphEdges.filter(e => e.source === n.idx).map(e => e.target)
    n.usedBy = graphEdges.filter(e => e.target === n.idx).map(e => e.source)
    // Recalc size now that usedBy is known
    n.r = calcNodeSize(n)
  }

  // SVG structure
  const defs = svg.querySelector('defs').outerHTML
  svg.innerHTML = defs + '<g id="graph-world"><g id="graph-regions"></g><g id="graph-edges"></g><g id="graph-nodes"></g></g>'

  // Draw category region backgrounds. Categories are now derived from the
  // VISIBLE items only (see visibleCategoryFor above), so labels at any path
  // accurately reflect what's in the bubble. Bubbles appear at root + any
  // sub-path with mixed dirs/packages.
  if (gfx.showRegions && gfx.groupBy !== 'none' && catList.length > 1) {
    const regionGroup = document.getElementById('graph-regions')
    for (const cat of catList) {
      if (cat === 'Other' && catGroups[cat].length < 2) continue
      const nodes = catGroups[cat].map(i => graphNodes[i])
      const color = categoryColors[cat] || '#484f58'

      // Compute bounding circle
      let avgX = 0, avgY = 0
      for (const n of nodes) { avgX += n.x; avgY += n.y }
      avgX /= nodes.length; avgY /= nodes.length
      let maxDist = 0
      for (const n of nodes) {
        const d = Math.sqrt((n.x - avgX) ** 2 + (n.y - avgY) ** 2) + n.r
        if (d > maxDist) maxDist = d
      }

      const region = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      region.classList.add('graph-region')
      region.setAttribute('cx', avgX)
      region.setAttribute('cy', avgY)
      region.setAttribute('r', maxDist + 30)
      region.setAttribute('fill', color)
      region.setAttribute('stroke', color)
      region.dataset.cat = cat
      regionGroup.appendChild(region)

      // Label with background rect for readability
      const labelG = document.createElementNS('http://www.w3.org/2000/svg', 'g')
      labelG.classList.add('graph-region-label')
      labelG.dataset.cat = cat

      const labelBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
      labelBg.setAttribute('fill', '#0d1117')
      labelBg.setAttribute('fill-opacity', '0.8')
      labelBg.setAttribute('rx', '4')
      const textWidth = cat.length * 8 + 16
      labelBg.setAttribute('x', avgX - textWidth / 2)
      labelBg.setAttribute('y', avgY - maxDist - 28)
      labelBg.setAttribute('width', textWidth)
      labelBg.setAttribute('height', 22)
      labelG.appendChild(labelBg)

      const label = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      label.textContent = cat
      label.setAttribute('x', avgX)
      label.setAttribute('y', avgY - maxDist - 12)
      label.setAttribute('text-anchor', 'middle')
      label.setAttribute('fill', color)
      label.setAttribute('font-size', '13')
      label.setAttribute('font-weight', 'bold')
      labelG.appendChild(label)

      regionGroup.appendChild(labelG)
    }
  }
  graphGroup = document.getElementById('graph-world')
  const edgeGroup = document.getElementById('graph-edges')
  const nodeGroup = document.getElementById('graph-nodes')

  // Draw edges as lines with arrows
  for (const edge of graphEdges) {
    const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    line.classList.add('graph-edge')
    line.dataset.source = edge.source
    line.dataset.target = edge.target
    edgeGroup.appendChild(line)
    edge.el = line
  }

  // Set by the document-level mousemove handler registered further down, read
  // by every node's click handler. It MUST live out here: when it was declared
  // inside the loop below, the mousemove handler — registered after the loop
  // closes — assigned to an implicit global instead, so the per-node copy was
  // never true and releasing a drag also fired the node's click action.
  let didDrag = false

  // Draw nodes
  for (let i = 0; i < graphNodes.length; i++) {
    const n = graphNodes[i]
    const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
    g.classList.add('graph-node')
    g.dataset.idx = i

    // Validation ring (outer glow)
    const valStatus = getModuleValidation(n.name)
    if (valStatus) {
      const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      ring.setAttribute('r', n.r + 4)
      ring.setAttribute('fill', 'none')
      const ringColor = valStatus.fail > 0 ? '#ff7b72' : valStatus.warn > 0 ? '#d29922' : '#3fb950'
      ring.setAttribute('stroke', ringColor)
      ring.setAttribute('stroke-width', '2.5')
      ring.setAttribute('stroke-dasharray', valStatus.fail > 0 ? 'none' : '4,3')
      ring.setAttribute('opacity', '0.7')
      g.appendChild(ring)
    }

    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
    circle.setAttribute('r', n.r)
    circle.setAttribute('fill', n.color)
    circle.setAttribute('stroke', '#30363d')
    circle.setAttribute('stroke-width', '1.5')
    g.appendChild(circle)

    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
    text.textContent = n.name
    text.setAttribute('dy', n.r + 14)
    text.setAttribute('text-anchor', 'middle')
    text.setAttribute('font-size', '11')
    if (!gfx.showLabels) text.style.display = 'none'
    g.appendChild(text)
    n.labelEl = text

    // Validation count below name
    if (valStatus && (valStatus.fail > 0 || valStatus.warn > 0)) {
      const valText = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      const parts = []
      if (valStatus.fail > 0) parts.push(valStatus.fail + 'F')
      if (valStatus.warn > 0) parts.push(valStatus.warn + 'W')
      valText.textContent = parts.join(' ')
      valText.setAttribute('dy', n.r + 25)
      valText.setAttribute('text-anchor', 'middle')
      valText.setAttribute('font-size', '9')
      valText.setAttribute('fill', valStatus.fail > 0 ? '#ff7b72' : '#d29922')
      g.appendChild(valText)
    }

    // Inner label: "Nm" for module member count, "Np" for dir's package count
    const countText = document.createElementNS('http://www.w3.org/2000/svg', 'text')
    countText.textContent = n.type === 'dir' ? (n.packageCount + 'p') : (n.memberCount + 'm')
    countText.setAttribute('dy', '4')
    countText.setAttribute('text-anchor', 'middle')
    countText.setAttribute('font-size', '9')
    countText.setAttribute('fill', n.type === 'dir' ? '#c9d1d9' : '#0d1117')
    countText.setAttribute('font-weight', 'bold')
    g.appendChild(countText)

    nodeGroup.appendChild(g)
    n.el = g

    // Node drag
    g.addEventListener('mousedown', (e) => {
      didDrag = false
      n.fixed = true
      e.stopPropagation()
      e.preventDefault()
    })

    // Click behaviour depends on node type:
    //   directory → descend into it (single click)
    //   module    → toggle focus (single click), drill into files (double click)
    g.addEventListener('click', (e) => {
      e.stopPropagation()
      if (didDrag) return
      if (n.type === 'dir') {
        descendIntoDir(n.name)
        return
      }
      if (focusedNode === i) {
        unfocusAll()
        document.getElementById('right-panel').style.display = 'none'
        document.getElementById('resize-right').style.display = 'none'
      } else {
        focusModule(i)
        showGraphModulePanel(n.name, n.mod)
      }
    })
    g.addEventListener('dblclick', (e) => {
      e.stopPropagation()
      if (n.type === 'dir') {
        descendIntoDir(n.name)
      } else {
        drillIntoModule(n.name)
      }
    })

    // Hover tooltip
    g.addEventListener('mouseenter', () => showTooltip(n))
    g.addEventListener('mouseleave', hideTooltip)
  }

  // --- Zoom / Pan ---
  function updateTransform () {
    graphGroup.setAttribute('transform',
      'translate(' + panX.toFixed(1) + ',' + panY.toFixed(1) + ') scale(' + zoom.toFixed(3) + ')')
  }

  // Scroll to zoom
  svg.addEventListener('wheel', (e) => {
    e.preventDefault()
    const delta = e.deltaY > 0 ? 0.9 : 1.1
    const newZoom = Math.max(0.1, Math.min(5, zoom * delta))
    // Zoom toward cursor
    const svgRect = svg.getBoundingClientRect()
    const mx = e.clientX - svgRect.left
    const my = e.clientY - svgRect.top
    panX = mx - (mx - panX) * (newZoom / zoom)
    panY = my - (my - panY) * (newZoom / zoom)
    zoom = newZoom
    updateTransform()
  }, { passive: false })

  // Pan with mouse drag on background
  svg.addEventListener('mousedown', (e) => {
    if (e.target === svg || e.target.tagName === 'line') {
      isPanning = true
      panStartX = e.clientX - panX
      panStartY = e.clientY - panY
      svg.classList.add('dragging')
    }
  })

  document.addEventListener('mousemove', (e) => {
    const tooltip = document.getElementById('graph-tooltip')
    tooltip.style.left = (e.clientX + 14) + 'px'
    tooltip.style.top = (e.clientY + 14) + 'px'

    if (isPanning) {
      panX = e.clientX - panStartX
      panY = e.clientY - panStartY
      updateTransform()
      return
    }

    // Node dragging (transform mouse coords to graph space)
    for (const n of graphNodes) {
      if (n.fixed) {
        const svgRect = svg.getBoundingClientRect()
        n.x = (e.clientX - svgRect.left - panX) / zoom
        n.y = (e.clientY - svgRect.top - panY) / zoom
        didDrag = true
      }
    }
  })

  document.addEventListener('mouseup', () => {
    isPanning = false
    svg.classList.remove('dragging')
    for (const n of graphNodes) n.fixed = false
  })

  // Click background to unfocus
  svg.addEventListener('click', (e) => {
    if (e.target === svg) unfocusAll()
  })

  // Zoom buttons
  document.getElementById('zoom-in').addEventListener('click', () => {
    zoom = Math.min(5, zoom * 1.3)
    updateTransform()
  })
  document.getElementById('zoom-out').addEventListener('click', () => {
    zoom = Math.max(0.1, zoom * 0.7)
    updateTransform()
  })
  document.getElementById('zoom-reset').addEventListener('click', () => {
    zoom = 1; panX = 0; panY = 0
    updateTransform()
    unfocusAll()
  })

  // --- Force simulation (label-aware) ---
  // Estimate label width per node
  for (const n of graphNodes) {
    n.labelW = n.name.length * 7 + n.r * 2 + 10
  }

  // Simulation control: alpha cools the system over time. Below SIM_FREEZE
  // the simulation halts entirely. Without this, an N-node graph runs O(N^2)
  // every frame forever — at 400+ nodes that's 80k+ pair calcs per frame
  // and the graph "explodes" visually because forces never settle.
  let simAlpha = 1.0
  const SIM_DECAY = 0.985        // multiplies each frame, ~300 frames to settle
  const SIM_FREEZE = 0.02        // stop simulating below this
  let simRafId = null

  // Re-heat on user interaction (drag, focus, restart). Cheap public hook.
  window.restartSim = function (alpha) {
    simAlpha = alpha != null ? alpha : Math.max(simAlpha, 0.6)
    if (simRafId === null) simRafId = requestAnimationFrame(simulate)
  }

  function simulate () {
    simRafId = null
    const a = simAlpha

    const attraction = 0.003 * a
    const damping = 0.85
    const centerForce = 0.006 * a

    // Category-aware forces: same group attracts, different group repels more
    for (let i = 0; i < graphNodes.length; i++) {
      for (let j = i + 1; j < graphNodes.length; j++) {
        const na = graphNodes[i], nb = graphNodes[j]
        let dx = nb.x - na.x, dy = nb.y - na.y
        let dist = Math.sqrt(dx * dx + dy * dy) || 1
        const sameGroup = na.category && nb.category && na.category === nb.category
        const minDist = (na.labelW + nb.labelW) / 2 + (sameGroup ? 5 : 30)

        let force
        if (dist < minDist) {
          // Overlap: push apart (stronger for different groups)
          force = (minDist - dist) * (sameGroup ? 0.06 : 0.15)
        } else if (sameGroup && dist > 80) {
          // Same group but far apart: pull together
          force = -(dist - 80) * 0.002
        } else {
          // General repulsion (stronger between groups)
          force = (sameGroup ? 1000 : 3000) / (dist * dist)
        }
        force *= a
        const fx = (dx / dist) * force, fy = (dy / dist) * force
        if (!na.fixed) { na.vx -= fx; na.vy -= fy }
        if (!nb.fixed) { nb.vx += fx; nb.vy += fy }
      }
    }

    for (const edge of graphEdges) {
      const na = graphNodes[edge.source], nb = graphNodes[edge.target]
      const dx = nb.x - na.x, dy = nb.y - na.y
      const dist = Math.sqrt(dx * dx + dy * dy) || 1
      const force = (dist - 180) * attraction
      const fx = (dx / dist) * force, fy = (dy / dist) * force
      if (!na.fixed) { na.vx += fx; na.vy += fy }
      if (!nb.fixed) { nb.vx -= fx; nb.vy -= fy }
    }

    for (const n of graphNodes) {
      if (n.fixed) continue
      n.vx += (cx - n.x) * centerForce
      n.vy += (cy - n.y) * centerForce
      n.vx *= damping; n.vy *= damping
      n.x += n.vx; n.y += n.vy
    }

    // Update positions
    for (const n of graphNodes) {
      n.el.setAttribute('transform', 'translate(' + n.x.toFixed(1) + ',' + n.y.toFixed(1) + ')')
    }
    for (const edge of graphEdges) {
      const a = graphNodes[edge.source], b = graphNodes[edge.target]
      // Shorten line so arrow doesn't overlap circle
      const dx = b.x - a.x, dy = b.y - a.y
      const dist = Math.sqrt(dx * dx + dy * dy) || 1
      const offsetA = a.r + 2, offsetB = b.r + 6
      edge.el.setAttribute('x1', (a.x + dx / dist * offsetA).toFixed(1))
      edge.el.setAttribute('y1', (a.y + dy / dist * offsetA).toFixed(1))
      edge.el.setAttribute('x2', (b.x - dx / dist * offsetB).toFixed(1))
      edge.el.setAttribute('y2', (b.y - dy / dist * offsetB).toFixed(1))
    }

    // Update region positions to follow their nodes
    if (gfx.showRegions) {
      document.querySelectorAll('.graph-region').forEach(region => {
        const cat = region.dataset.cat
        const group = catGroups[cat]
        if (!group) return
        const nodes = group.map(i => graphNodes[i])
        let ax = 0, ay = 0
        for (const n of nodes) { ax += n.x; ay += n.y }
        ax /= nodes.length; ay /= nodes.length
        let maxDist = 0
        for (const n of nodes) {
          const d = Math.sqrt((n.x - ax) ** 2 + (n.y - ay) ** 2) + n.r
          if (d > maxDist) maxDist = d
        }
        region.setAttribute('cx', ax)
        region.setAttribute('cy', ay)
        region.setAttribute('r', maxDist + 30)
      })
      document.querySelectorAll('g.graph-region-label').forEach(labelG => {
        const cat = labelG.dataset.cat
        const region = document.querySelector('.graph-region[data-cat="' + cat + '"]')
        if (!region) return
        const rcx = parseFloat(region.getAttribute('cx'))
        const rcy = parseFloat(region.getAttribute('cy'))
        const rr = parseFloat(region.getAttribute('r'))
        const textEl = labelG.querySelector('text')
        const bgEl = labelG.querySelector('rect')
        if (textEl) {
          textEl.setAttribute('x', rcx)
          textEl.setAttribute('y', rcy - rr - 12)
        }
        if (bgEl) {
          const tw = parseFloat(bgEl.getAttribute('width'))
          bgEl.setAttribute('x', rcx - tw / 2)
          bgEl.setAttribute('y', rcy - rr - 28)
        }
      })
    }

    // Decay alpha and decide whether to keep simulating
    simAlpha *= SIM_DECAY
    if (simAlpha > SIM_FREEZE) {
      simRafId = requestAnimationFrame(simulate)
    } else {
      simAlpha = 0  // frozen — restartSim() will re-heat
      simRafId = null
    }
  }

  // For large graphs, skip force simulation entirely. The O(N^2) pairwise
  // calculation per frame becomes unworkable above ~150 nodes (90k+ ops
  // per frame). The initial category-ring layout is good enough; users can
  // drag nodes to refine. Edges still render; nodes are just static.
  //
  // Adjust BIG_GRAPH_THRESHOLD if your machine is faster/slower.
  const BIG_GRAPH_THRESHOLD = 150
  if (graphNodes.length > BIG_GRAPH_THRESHOLD) {
    simAlpha = 0
    // Render once to position elements at their initial coordinates
    for (const n of graphNodes) {
      n.el.setAttribute('transform', 'translate(' + n.x.toFixed(1) + ',' + n.y.toFixed(1) + ')')
    }
    for (const edge of graphEdges) {
      const na = graphNodes[edge.source], nb = graphNodes[edge.target]
      const dx = nb.x - na.x, dy = nb.y - na.y
      const dist = Math.sqrt(dx * dx + dy * dy) || 1
      const offsetA = na.r + 2, offsetB = nb.r + 6
      edge.el.setAttribute('x1', (na.x + dx / dist * offsetA).toFixed(1))
      edge.el.setAttribute('y1', (na.y + dy / dist * offsetA).toFixed(1))
      edge.el.setAttribute('x2', (nb.x - dx / dist * offsetB).toFixed(1))
      edge.el.setAttribute('y2', (nb.y - dy / dist * offsetB).toFixed(1))
    }
  } else {
    simRafId = requestAnimationFrame(simulate)
  }
  showGraphSettings()
  loadSections()

  // Deep-linkable focus, like the other views, and the only way to review what
  // focusing actually shows without a mouse: ?focus=<module name>.
  const wantFocus = new URLSearchParams(location.search).get('focus')
  if (wantFocus) {
    for (const node of graphNodes) {
      if (node.name === wantFocus) { focusModule(node.idx); break }
    }
  }
}

// --- Focus a module: highlight its connections ---
function focusModule (idx) {
  focusedNode = idx
  const n = graphNodes[idx]
  const connected = new Set([idx])
  for (const t of n.dependsOn) connected.add(t)
  for (const s of n.usedBy) connected.add(s)

  // The FULL dependency closure, not one hop. A graph edge here points consumer
  // -> supplier, the opposite of the shared convention, so it is flipped on the
  // way in rather than reasoning about direction twice.
  let closure = null
  if (window.__ct) {
    const asLinks = graphEdges.map(e => ({ from: String(e.target), to: String(e.source) }))
    closure = window.__ct.dependencyClosure(asLinks, String(idx))
    for (const node of graphNodes) {
      if (closure.nodes.get(String(node.idx)) !== undefined) connected.add(node.idx)
    }
  }

  for (const node of graphNodes) {
    node.el.classList.toggle('dimmed', !connected.has(node.idx))
    node.el.classList.toggle('focused', node.idx === idx)
  }

  for (const edge of graphEdges) {
    const isDepOf = edge.source === idx  // focused depends on target
    const isUsedBy = edge.target === idx // source depends on focused
    const depth = closure ? closure.edges.get(String(edge.target) + ' -> ' + String(edge.source)) : undefined
    const isConnected = isDepOf || isUsedBy || depth !== undefined

    edge.el.classList.toggle('dimmed', !isConnected)
    edge.el.classList.toggle('depends-on', isDepOf)
    edge.el.classList.toggle('dep-of', isUsedBy)

    // Distance in the colour: amber one hop out, cooling as it goes.
    if (depth !== undefined && depth > 1 && window.__ct) {
      edge.el.setAttribute('stroke', window.__ct.depthColor(depth, closure.maxDepth))
    } else {
      edge.el.removeAttribute('stroke')
    }

    if (isDepOf) edge.el.setAttribute('marker-end', 'url(#arrowhead-green)')
    else if (isUsedBy) edge.el.setAttribute('marker-end', 'url(#arrowhead-blue)')
    else edge.el.setAttribute('marker-end', 'url(#arrowhead)')
  }

  // Show detail
  showDetail('module', { name: n.name, mod: n.mod })

  // Also show connections in detail panel
  const panel = document.getElementById('detail-content')
  let extra = ''
  if (n.dependsOn.length > 0) {
    extra += '<div class="detail"><h3 style="color:#3fb950">Depends on (' + n.dependsOn.length + ')</h3><pre><code>'
    extra += n.dependsOn.map(t => graphNodes[t].name).join('\\n')
    extra += '</code></pre></div>'
  }
  if (n.usedBy.length > 0) {
    extra += '<div class="detail"><h3 style="color:#58a6ff">Used by (' + n.usedBy.length + ')</h3><pre><code>'
    extra += n.usedBy.map(s => graphNodes[s].name).join('\\n')
    extra += '</code></pre></div>'
  }
  // The FULL declared list, not just the part with a node on screen. On this
  // repo's own scan 684 dependencies are declared and only 105 have both ends
  // in the scan, so the drawing alone says a module with ten external
  // dependencies depends on nothing.
  if (n.mod) {
    const declared = Object.keys(n.mod.dependencies || {}).sort()
    const inScanSet = new Set(tree.system.modules)
    const drawn = declared.filter(d => inScanSet.has(d))
    extra += '<div class="detail"><h3>All declared dependencies (' + declared.length + ')</h3>' +
      '<div style="color:#8b949e;font-size:11px;margin-bottom:6px">' + drawn.length +
      ' drawn as edges &middot; ' + (declared.length - drawn.length) + ' external</div><pre><code>'
    extra += declared.map(d => (inScanSet.has(d) ? '   ' : ' ~ ') + escapeHtml(d)).join('\\n')
    extra += '</code></pre></div>'
  }

  // And what it reaches THROUGH those, with hop counts.
  if (closure) {
    const reached = []
    for (const node of graphNodes) {
      const depth = closure.nodes.get(String(node.idx))
      if (depth === undefined || depth < 2) continue
      reached.push(node.name + '  (' + depth + ' hops)')
    }
    if (reached.length > 0) {
      extra += '<div class="detail"><h3 style="color:#d2a8ff">Reached through those (' +
        reached.length + ')</h3><pre><code>' +
        reached.sort().map(escapeHtml).join('\\n') + '</code></pre></div>'
    }
  }

  if (extra) panel.innerHTML += extra
}

function unfocusAll () {
  focusedNode = null
  for (const node of graphNodes) {
    node.el.classList.remove('dimmed', 'focused')
  }
  for (const edge of graphEdges) {
    edge.el.classList.remove('dimmed', 'depends-on', 'dep-of')
    edge.el.setAttribute('marker-end', 'url(#arrowhead)')
  }
}

function showTooltip (n) {
  const tooltip = document.getElementById('graph-tooltip')

  // Directory nodes carry mod === null. Reading n.mod.version unconditionally
  // threw on every hover over a directory, killing the tooltip entirely.
  if (!n.mod) {
    tooltip.innerHTML = '<strong>' + escapeHtml(n.name) + '/</strong>' +
      '<br>' + (n.packageCount || 0) + ' packages inside' +
      '<br><span style="color:#484f58">click to descend</span>'
    tooltip.style.display = 'block'
    return
  }

  const depsOn = (n.dependsOn || []).map(t => graphNodes[t].name)
  const usedBy = (n.usedBy || []).map(s => graphNodes[s].name)
  const api = n.mod.publicAPI || []
  const misfit = misfitSummaryFor(n.name)

  tooltip.innerHTML = '<strong>' + escapeHtml(n.name) + '</strong> ' + escapeHtml(n.mod.version) +
    '<br>' + n.fileCount + ' files, ' + n.memberCount + ' members, ' + api.length + ' exports' +
    (misfit ? '<br>' + misfit : '') +
    (depsOn.length ? '<br><span class="tt-deps">depends on: ' + escapeHtml(depsOn.join(', ')) + '</span>' : '') +
    (usedBy.length ? '<br><span class="tt-used">used by: ' + escapeHtml(usedBy.join(', ')) + '</span>' : '') +
    (api.length ? '<br>API: ' + escapeHtml(api.slice(0, 6).join(', ')) + (api.length > 6 ? '...' : '') : '')
  tooltip.style.display = 'block'
}

function hideTooltip () {
  document.getElementById('graph-tooltip').style.display = 'none'
}

function moduleColor (name) {
  const colors = ['#f0883e', '#58a6ff', '#d2a8ff', '#7ee787', '#ff7b72', '#79c0ff',
    '#ffa657', '#3fb950', '#bc8cff', '#f778ba', '#56d364', '#79c0ff']
  let hash = 0
  for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0
  return colors[Math.abs(hash) % colors.length]
}

// ====== GRAPH DRILL-DOWN ======
let graphLevel = 'system' // 'system' | 'module' | 'file'
let graphStack = [] // breadcrumb trail

// ====== URL ROUTING (Phase 0d) ======
// Every navigable view maps to a URL. Path-based, not hash-based. pushState
// on navigation, popstate re-derives the stack. Deep-link friendly: opening
// /module/hyper-orgs in a cold browser lands on the same view as clicking
// through to it from /.
//
// URL grammar:
//   /                        → empty stack (system overview)
//   /system                  → alias for /
//   /module/<name>           → module detail
//   /module/<name>/file/<p>  → file drill-in (path can contain slashes)
//   /path/<p1>/<p2>/...      → directory navigation (one entry per segment)

function _basePrefix () {
  // API is set to the apiPrefix (possibly empty). Routes are pathed relative
  // to whatever prefix the server is mounted behind.
  return API || ''
}

// The URL is built by the SHARED serializer, so this view and the floor cannot
// disagree about what a place is called.
function urlFromGraphStack (stack) {
  return window.__ctLoc
    ? window.__ctLoc.locationToUrl(locationFromStack(stack), _basePrefix())
    : _basePrefix() + '/'
}

// graphStack stays as this view's working state; it converts to the shared
// location on the way out.
function locationFromStack (stack) {
  const loc = {
    surface: 'spa', kind: 'system', module: null, file: null, symbol: null, path: [], extra: {}
  }
  if (!stack || stack.length === 0) return loc

  const first = stack[0]
  if (first.type === 'module') {
    loc.kind = 'module'
    loc.module = first.name
    const second = stack[1]
    if (second && second.type === 'file') {
      loc.kind = 'file'
      loc.file = second.name
    }
    return loc
  }
  if (first.type === 'path') {
    loc.kind = 'path'
    loc.path = stack.filter(e => e.type === 'path').map(e => e.name)
  }
  return loc
}

// And the inverse, so a URL becomes this view's stack.
function stackFromLocation (loc) {
  if (!loc) return []
  if (loc.kind === 'module' || loc.kind === 'file' || loc.kind === 'symbol') {
    const stack = [{ type: 'module', name: loc.module, label: loc.module }]
    if (loc.file) {
      stack.push({ type: 'file', name: loc.file, label: loc.file, moduleName: loc.module })
    }
    return stack
  }
  if (loc.kind === 'path') {
    return loc.path.map(seg => ({ type: 'path', name: seg, label: seg }))
  }
  return []
}

function graphStackFromUrl (pathname) {
  return routeFromUrl(pathname).stack
}

// Parse URL into a full route object.
//   kind:  'system' | 'module-graph' | 'path-graph' | 'validation' | 'docs' | 'suggestions'
//   stack: graph stack (only meaningful for *-graph kinds)
//   extra: { moduleName (validation), docPath (docs), suggestPath (suggestions) }
function routeFromUrl (pathname) {
  // Parsed by the SHARED location module: the same string means the same place
  // here and on the floor. The graph-shaped kinds are translated back into this
  // view's stack; validation/docs/suggestions pass their extras through.
  if (!window.__ctLoc) return { kind: 'system', stack: [], extra: {} }
  const loc = window.__ctLoc.parseLocation(pathname, _basePrefix())

  const kind = loc.kind === 'module' || loc.kind === 'file' || loc.kind === 'symbol'
    ? 'module-graph'
    : loc.kind === 'path' ? 'path-graph' : loc.kind

  return { kind, stack: stackFromLocation(loc), extra: loc.extra || {} }
}
function pushStateForStack () {
  if (_suppressPushState) return
  const url = urlFromGraphStack(graphStack)
  if (window.location.pathname === url) return
  try {
    window.history.pushState({ stack: JSON.parse(JSON.stringify(graphStack)) }, '', url)
  } catch {
    // Some browsers / environments reject pushState in niche contexts
    // (sandboxed iframe, file://). Fail open — navigation still works.
  }
}

function replaceStateForStack () {
  const url = urlFromGraphStack(graphStack)
  try {
    window.history.replaceState({ stack: JSON.parse(JSON.stringify(graphStack)) }, '', url)
  } catch {}
}

// Apply a graphStack derived from a URL. Used on initial page load and on
// popstate. Doesn't push state (popstate is already at the target URL).
function applyStackFromUrl (stack) {
  _suppressPushState = true
  try {
    if (stack.length === 0) {
      graphStack = []
      graphLevel = 'system'
      rebuildGraph()
      syncTreeWithGraph()
    } else {
      const first = stack[0]
      if (first.type === 'module') {
        graphStack = stack.slice()
        drillIntoModule(first.name, true)
        if (stack[1] && stack[1].type === 'file') {
          drillIntoFile(stack[1].moduleName, stack[1].name, true)
        }
      } else if (first.type === 'path') {
        graphStack = stack.slice()
        rebuildGraph()
        syncTreeWithGraph()
      }
    }
  } finally {
    _suppressPushState = false
  }
}

// Apply a full route object (the post-0e extension of applyStackFromUrl).
// Activates the right main tab and renders whatever view the URL describes.
function applyRouteFromUrl (route) {
  _suppressPushState = true
  try {
    switch (route.kind) {
      case 'validation': {
        activateMainTab('validate')
        if (!valInitialized) initValidation()
        switchRightPanel('validation')
        if (route.extra.moduleName) {
          renderValidation(route.extra.moduleName)
        } else {
          renderValidation(null)
        }
        break
      }
      case 'docs': {
        activateMainTab('docs')
        renderDocsCruft(route.extra.docPath)
        break
      }
      case 'suggestions': {
        // Phase 0e leaves suggestions as a SSR-style placeholder — codetree
        // serve can't currently run --suggest per file on demand. Show a
        // friendly placeholder that points users at the CLI.
        activateMainTab('docs')
        const detail = document.getElementById('detail')
        if (detail) {
          detail.innerHTML = '<div style="padding:20px;color:#8b949e;max-width:800px;margin:0 auto">' +
            '<h1 style="color:#58a6ff;font-size:1.3rem">Suggested fixes for ' +
            escapeHtml(route.extra.suggestPath || '') + '</h1>' +
            '<p>Run <code style="background:#161b22;padding:2px 6px;border-radius:3px">' +
            'codetree docs . --suggest --target ' + escapeHtml(route.extra.suggestPath || '') + '</code> ' +
            'to generate a patch preview on the command line. Live per-file suggestions via /suggestions/* ' +
            'will land in a later phase.</p>' +
            '</div>'
        }
        break
      }
      case 'module-graph':
      case 'path-graph':
      case 'system':
      default:
        applyStackFromUrl(route.stack)
        break
    }
  } finally {
    _suppressPushState = false
  }
}

function activateMainTab (name) {
  const tabs = document.querySelectorAll('.main-tab')
  for (const t of tabs) {
    if (t.dataset.tab === name && !t.classList.contains('active')) {
      t.click()
      return
    }
  }
}

// Programmatic nav helpers — used by links inside rendered panels.
window.navigateToValidation = function (moduleName) {
  const base = _basePrefix()
  const url = moduleName
    ? base + '/validation/module/' + encodeURIComponent(moduleName)
    : base + '/validation'
  try {
    window.history.pushState({}, '', url)
  } catch {}
  applyRouteFromUrl(routeFromUrl(url))
}

window.navigateToDocs = function (docPath) {
  const base = _basePrefix()
  const url = docPath
    ? base + '/docs/' + docPath.split('/').map(encodeURIComponent).join('/')
    : base + '/docs'
  try {
    window.history.pushState({}, '', url)
  } catch {}
  applyRouteFromUrl(routeFromUrl(url))
}

function updateBreadcrumb () {
  const bc = document.getElementById('graph-breadcrumb')
  // Render breadcrumb segments as real <a href> so middle-click /
  // ctrl-click open in a new tab (standard anchor semantics). JS
  // intercepts plain left-click for SPA navigation via pushState.
  const systemHref = urlFromGraphStack([])
  let html = '<a href="' + systemHref + '" data-nav-level="0">System</a>'
  for (let i = 0; i < graphStack.length; i++) {
    const segmentStack = graphStack.slice(0, i + 1)
    const href = urlFromGraphStack(segmentStack)
    html += '<span class="sep">&gt;</span>'
    const isFinal = i === graphStack.length - 1
    html += '<a href="' + href + '" class="' + (isFinal ? 'current' : '') + '" data-nav-level="' + (i + 1) + '">' + escapeHtml(graphStack[i].label) + '</a>'
  }
  // The SAME place, drawn as the floor. This is what makes the two surfaces
  // co-related rather than merely similar: one location, two renderings.
  if (window.__ctLoc) {
    const floorHref = window.__ctLoc.otherSurface(locationFromStack(graphStack), _basePrefix())
    html += '<a href="' + floorHref + '" class="other-surface" ' +
      'style="margin-left:12px;color:#58a6ff">open in the floor view &rsaquo;</a>'
  }
  bc.innerHTML = html

  for (const a of bc.querySelectorAll('a')) {
    if (a.classList.contains('other-surface')) continue  // a real navigation
    a.addEventListener('click', (e) => {
      if (e.button !== 0) return
      if (e.metaKey || e.ctrlKey || e.shiftKey) return
      e.preventDefault()
      const level = parseInt(a.dataset.navLevel, 10)
      if (!isNaN(level)) navigateGraph(level)
    })
  }
}

function escapeHtml (s) {
  return String(s).replace(/[&<>"']/g, ch => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  }[ch]))
}

// Expose for onclick / direct calls
window.navigateGraph = function (level) {
  if (level === 0) {
    graphStack = []
    graphLevel = 'system'
    rebuildGraph()
    syncTreeWithGraph()
  } else if (level <= graphStack.length) {
    graphStack = graphStack.slice(0, level)
    const entry = graphStack[graphStack.length - 1]
    if (entry.type === 'module') drillIntoModule(entry.name, true)
    else if (entry.type === 'file') drillIntoFile(entry.moduleName, entry.name, true)
    else if (entry.type === 'path') { rebuildGraph(); syncTreeWithGraph() }
  }
  pushStateForStack()
}

function rebuildGraph () {
  graphInitialized = false
  graphNodes.length = 0
  graphEdges.length = 0
  focusedNode = null
  zoom = 1; panX = 0; panY = 0
  initGraph()
  updateBreadcrumb()
  // Note: tree sidebar is NOT re-synced here. Sliders (spacing, forceStrength,
  // groupBy) call rebuildGraph many times per second; tree sync would make
  // them chug. Call syncTreeWithGraph() instead when the PATH changes.
}

// Throttle full rebuilds (e.g. groupBy change) to one per animation frame.
let pendingRebuild = false
function scheduleRebuild () {
  if (pendingRebuild) return
  pendingRebuild = true
  requestAnimationFrame(() => {
    pendingRebuild = false
    if (graphStack.length === 0) {
      rebuildGraph()
    } else {
      const top = graphStack[0]
      if (top.type === 'module') drillIntoModule(top.name, true)
      else rebuildGraph()
    }
  })
}

// Fast reposition: recompute node coordinates from the current gfx.spacing
// value and update SVG transforms. No teardown, no rebuild. Throttled to rAF
// so a slider drag becomes one repaint per frame regardless of how many
// oninput events fire.
let pendingReposition = false
function repositionForSpacing () {
  if (pendingReposition) return
  pendingReposition = true
  requestAnimationFrame(() => {
    pendingReposition = false
    if (graphNodes.length === 0) return

    const svg = document.getElementById('graph-svg')
    const rect = svg.getBoundingClientRect()
    const W = rect.width, H = rect.height
    const cx = W / 2, cy = H / 2
    const spaceMult = 0.5 + (gfx.spacing / 100) * 2

    // Re-derive cat groups from current node categories
    const catGroups = {}
    for (const n of graphNodes) {
      if (!catGroups[n.category]) catGroups[n.category] = []
      catGroups[n.category].push(n)
    }
    const catList = Object.keys(catGroups)

    for (const n of graphNodes) {
      const catI = catList.indexOf(n.category)
      const group = catGroups[n.category]
      const posInGroup = group.indexOf(n)
      const catAngle = (catI / catList.length) * Math.PI * 2 - Math.PI / 2
      const catRadius = (Math.min(W, H) * 0.2 + catList.length * 8) * spaceMult
      const nodeAngle = (posInGroup / group.length) * Math.PI * 2
      const nodeRadius = Math.min(80, 20 + group.length * 10) * (0.7 + spaceMult * 0.3)
      n.x = cx + Math.cos(catAngle) * catRadius + Math.cos(nodeAngle) * nodeRadius
      n.y = cy + Math.sin(catAngle) * catRadius + Math.sin(nodeAngle) * nodeRadius
      n.vx = 0; n.vy = 0
      if (n.el) n.el.setAttribute('transform', 'translate(' + n.x.toFixed(1) + ',' + n.y.toFixed(1) + ')')
    }

    for (const edge of graphEdges) {
      const na = graphNodes[edge.source], nb = graphNodes[edge.target]
      const dx = nb.x - na.x, dy = nb.y - na.y
      const dist = Math.sqrt(dx * dx + dy * dy) || 1
      const offsetA = na.r + 2, offsetB = nb.r + 6
      if (edge.el) {
        edge.el.setAttribute('x1', (na.x + dx / dist * offsetA).toFixed(1))
        edge.el.setAttribute('y1', (na.y + dy / dist * offsetA).toFixed(1))
        edge.el.setAttribute('x2', (nb.x - dx / dist * offsetB).toFixed(1))
        edge.el.setAttribute('y2', (nb.y - dy / dist * offsetB).toFixed(1))
      }
    }

    // Re-heat the simulation if it was running, so any post-spacing forces
    // settle naturally instead of snapping. No-op if sim is permanently off
    // (large-graph case).
    if (typeof window.restartSim === 'function') window.restartSim(0.4)
  })
}

// Re-sync the file-tree sidebar with the current graph location. Called on
// path navigation, not on slider tweaks.
function syncTreeWithGraph () {
  buildTree()
}

// --- Toggle state ---
// Graph settings — all toggleable
const gfx = {
  showLabels: true,
  dimTests: false,
  showRegions: true,      // category background regions
  sizeBy: 'members',      // 'members' | 'deps' | 'exports'
  groupBy: 'category',    // 'category' | 'deps' | 'none'
  forceStrength: 0,       // 0 = hierarchy, 100 = force
  spacing: 50,            // 0 = tight, 100 = max spread
  showEdgeLabels: false,  // show import names on edges
  labelPos: 'below',      // 'below' | 'right' | 'radial'
}
let expandedNodes = new Set()

function drillIntoModule (moduleName, fromNav) {
  const mod = tree.modules[moduleName]
  if (!mod) return

  // If the module came from a shallow scan, fetch the deep version on demand.
  // Marker: shallow-scanned modules have only ~1 entry in files (the entry file)
  // and tree.shallow === true. Avoid re-fetching once we've already loaded deep.
  if (tree.shallow && !mod.__deepLoaded && Object.keys(mod.files).length <= 1) {
    fetch(API + '/api/deep-scan?module=' + encodeURIComponent(moduleName))
      .then(r => r.json())
      .then(data => {
        if (data && data.mod) {
          // Replace the placeholder with the full module data, mark loaded,
          // then re-enter drillIntoModule to render with the new data.
          data.mod.__deepLoaded = true
          tree.modules[moduleName] = data.mod
          drillIntoModule(moduleName, fromNav)
        }
      })
      .catch(() => {})
    // Render a "loading" stub immediately so user sees feedback
  }

  if (!fromNav) {
    graphStack = [{ type: 'module', name: moduleName, label: moduleName }]
    if (typeof pushStateForStack === 'function') pushStateForStack()
  }
  graphLevel = 'module'
  expandedNodes.clear()

  const svg = document.getElementById('graph-svg')
  const rect = svg.getBoundingClientRect()
  const W = rect.width, H = rect.height
  const cx = W / 2, cy = H / 2

  graphNodes.length = 0
  graphEdges.length = 0
  focusedNode = null
  zoom = 1; panX = 0; panY = 0

  // Build file nodes with import-depth based radial layout
  const files = Object.entries(mod.files)
  const nameToIdx = {}
  const fileData = []

  for (let i = 0; i < files.length; i++) {
    const [path, file] = files[i]
    nameToIdx[path] = i
    const memberCount = window.__ct.fileMembers(file)
    const exportCount = file.exports ? file.exports.length : 0
    fileData.push({ path, file, memberCount, exportCount, role: file.role || 'lib' })
  }

  // Build edge list first to compute depths
  const edgeList = []
  for (let i = 0; i < files.length; i++) {
    const file = fileData[i].file
    if (!file.imports) continue
    for (const imp of file.imports) {
      for (let j = 0; j < files.length; j++) {
        if (i === j) continue
        const tp = fileData[j].path
        if (imp.specifier.endsWith(tp) || imp.specifier === './' + tp ||
            tp.endsWith(imp.specifier.replace('./', ''))) {
          edgeList.push({ source: i, target: j, labels: imp.names || [] })
        }
      }
    }
  }

  // BFS from entry to compute depth
  const entryIdx = fileData.findIndex(f => f.role === 'entry')
  const depth = new Array(files.length).fill(-1)
  if (entryIdx >= 0) {
    depth[entryIdx] = 0
    const queue = [entryIdx]
    while (queue.length > 0) {
      const cur = queue.shift()
      for (const e of edgeList) {
        if (e.source === cur && depth[e.target] === -1) {
          depth[e.target] = depth[cur] + 1
          queue.push(e.target)
        }
      }
    }
  }
  // Assign unvisited nodes (tests, disconnected) to outer ring
  const maxDepth = Math.max(1, ...depth.filter(d => d >= 0))
  for (let i = 0; i < depth.length; i++) {
    if (depth[i] === -1) depth[i] = maxDepth + (fileData[i].role === 'test' ? 1 : 0) + 1
  }

  // Group by depth
  const byDepth = {}
  for (let i = 0; i < files.length; i++) {
    const d = depth[i]
    if (!byDepth[d]) byDepth[d] = []
    byDepth[d].push(i)
  }

  const depthLevels = Object.keys(byDepth).map(Number).sort((a, b) => a - b)

  // Estimate label width: ~7px per char
  function labelWidth (name) {
    const short = name.includes('/') ? name.split('/').pop() : name
    return short.length * 7 + 30 // text + padding + node radius
  }

  // Compute hierarchy positions (top-down layered)
  const rowHeight = 90
  const startY = 60

  for (const d of depthLevels) {
    const group = byDepth[d]
    const widths = group.map(i => labelWidth(fileData[i].path))
    const totalWidth = widths.reduce((a, b) => a + b, 0) + (group.length - 1) * 20
    let xPos = cx - totalWidth / 2

    for (let j = 0; j < group.length; j++) {
      const i = group[j]
      const fd = fileData[i]
      const nodeR = Math.max(8, Math.min(30, 6 + Math.sqrt(fd.memberCount) * 2.5))
      const w = widths[j]
      const hx = xPos + w / 2
      const hy = startY + d * rowHeight

      graphNodes.push({
        idx: i, name: fd.path, path: fd.path, file: fd.file, moduleName,
        x: hx, y: hy,
        hierX: hx, hierY: hy, // stored hierarchy position
        vx: 0, vy: 0,
        r: nodeR,
        labelW: w,
        memberCount: fd.memberCount,
        exportCount: fd.exportCount,
        depth: d,
        color: roleColor(fd.role),
        role: fd.role,
        dependsOn: [], usedBy: []
      })
      xPos += w + 20
    }
  }

  // Build edges with label info
  for (const e of edgeList) {
    graphEdges.push({ source: e.source, target: e.target, labels: e.labels })
    graphNodes[e.source].dependsOn.push(e.target)
    graphNodes[e.target].usedBy.push(e.source)
  }

  renderSubGraph(svg, cx, cy, W, H, 'file')
  showGraphSettings()
  updateBreadcrumb()
}

function showGraphSettings () {
  let panel = document.getElementById('graph-settings')
  if (!panel) {
    panel = document.createElement('div')
    panel.id = 'graph-settings'
    panel.className = 'graph-settings'
    document.getElementById('graph-container').appendChild(panel)
  }

  panel.innerHTML =
    '<h4>Layout</h4>' +
    '<div class="gs-row"><label>Hierarchy</label>' +
    '<input type="range" min="0" max="100" value="' + gfx.forceStrength + '" oninput="gfxSet(&quot;forceStrength&quot;,+this.value)">' +
    '<label>Force</label></div>' +

    '<div class="gs-row"><label>Spacing</label>' +
    '<input type="range" min="0" max="100" value="' + gfx.spacing + '" oninput="gfxSet(&quot;spacing&quot;,+this.value)">' +
    '</div>' +

    '<div class="gs-divider"></div><h4>Grouping</h4>' +
    '<div class="gs-row"><label>Group by</label>' +
    '<select onchange="gfxSet(&quot;groupBy&quot;,this.value)">' +
    '<option value="category"' + (gfx.groupBy === 'category' ? ' selected' : '') + '>Spec Categories</option>' +
    '<option value="deps"' + (gfx.groupBy === 'deps' ? ' selected' : '') + '>Shared Dependencies</option>' +
    '<option value="none"' + (gfx.groupBy === 'none' ? ' selected' : '') + '>None</option>' +
    '</select></div>' +
    '<div class="gs-row"><input type="checkbox"' + (gfx.showRegions ? ' checked' : '') + ' onchange="gfxSet(&quot;showRegions&quot;,this.checked)"><label>Show region backgrounds</label></div>' +

    '<div class="gs-divider"></div><h4>Node Size</h4>' +
    '<div class="gs-row"><label>Size by</label>' +
    '<select onchange="gfxSet(&quot;sizeBy&quot;,this.value)">' +
    '<option value="members"' + (gfx.sizeBy === 'members' ? ' selected' : '') + '>Member Count</option>' +
    '<option value="deps"' + (gfx.sizeBy === 'deps' ? ' selected' : '') + '>Dependents (who uses this)</option>' +
    '<option value="exports"' + (gfx.sizeBy === 'exports' ? ' selected' : '') + '>Export Count</option>' +
    '</select></div>' +

    '<div class="gs-divider"></div><h4>Labels</h4>' +
    '<div class="gs-row"><label>Position</label>' +
    '<select onchange="gfxSet(&quot;labelPos&quot;,this.value)">' +
    '<option value="below"' + (gfx.labelPos === 'below' ? ' selected' : '') + '>Below</option>' +
    '<option value="right"' + (gfx.labelPos === 'right' ? ' selected' : '') + '>Right</option>' +
    '<option value="radial"' + (gfx.labelPos === 'radial' ? ' selected' : '') + '>Radial (outward)</option>' +
    '</select></div>' +
    '<div class="gs-row"><input type="checkbox"' + (gfx.showEdgeLabels ? ' checked' : '') + ' onchange="gfxSet(&quot;showEdgeLabels&quot;,this.checked)"><label>Show edge import names</label></div>' +
    '<div class="gs-row"><input type="checkbox"' + (gfx.showLabels ? ' checked' : '') + ' onchange="gfxSet(&quot;showLabels&quot;,this.checked)"><label>Show node labels</label></div>' +
    '<div class="gs-row"><input type="checkbox"' + (gfx.dimTests ? ' checked' : '') + ' onchange="gfxSet(&quot;dimTests&quot;,this.checked)"><label>Dim test files</label></div>'
}

window.gfxSet = function (key, value) {
  gfx[key] = value

  // Immediate effects
  if (key === 'forceStrength' && value === 0) {
    for (const n of graphNodes) {
      if (n.hierX !== undefined) { n.x = n.hierX; n.y = n.hierY; n.vx = 0; n.vy = 0 }
    }
  }
  if (key === 'showLabels') {
    for (const n of graphNodes) {
      if (n.labelEl) n.labelEl.style.display = value ? '' : 'none'
    }
  }
  if (key === 'dimTests') {
    for (const n of graphNodes) {
      if (n.role === 'test') n.el.style.opacity = value ? '0.2' : '1'
    }
  }
  if (key === 'showEdgeLabels') {
    document.querySelectorAll('.edge-label').forEach(el => {
      el.style.display = value ? '' : 'none'
    })
  }
  if (key === 'showRegions') {
    document.querySelectorAll('.graph-region, .graph-region-label').forEach(el => {
      el.style.display = value ? '' : 'none'
    })
  }

  // Recalculate node sizes
  if (key === 'sizeBy') {
    for (const n of graphNodes) {
      const newR = calcNodeSize(n)
      n.r = newR
      const circle = n.el.querySelector('circle')
      if (circle) circle.setAttribute('r', newR)
      // Update label position
      if (n.labelEl) n.labelEl.setAttribute('dy', newR + 14)
    }
  }

  // Spacing change: repositions existing nodes based on the new value. No
  // SVG teardown, no force sim rebuild. Just compute new x/y for each node
  // (the calculation that initGraph would do) and update the transform.
  // Slider input is decoupled from graph rebuild — input updates state,
  // state change triggers a cheap repaint.
  if (key === 'spacing') {
    repositionForSpacing()
  }
  // Regroup actually changes which nodes are in which group, so it needs
  // a real rebuild. Still throttled to rAF so rapid changes coalesce.
  if (key === 'groupBy') {
    scheduleRebuild()
  }

  // Update label positions
  if (key === 'labelPos') updateLabelPositions()

  // DON'T call showGraphSettings() here. It rebuilds the entire settings
  // panel HTML — including the slider input element. If the user is mid-drag,
  // their pointer capture is on the soon-to-be-destroyed input, so the
  // browser cancels the drag after one tick. That was the "barely slides"
  // regression. Settings panel only needs to re-render on initial graph
  // build (which still calls showGraphSettings).
}

function calcNodeSize (n) {
  let val = 0
  if (gfx.sizeBy === 'members') val = n.memberCount || 0
  else if (gfx.sizeBy === 'deps') val = (n.usedBy ? n.usedBy.length : 0) * 5
  else if (gfx.sizeBy === 'exports') val = (n.mod ? n.mod.publicAPI.length : (n.exportCount || 0)) * 3
  return Math.max(8, Math.min(35, 6 + Math.sqrt(val) * 2))
}

function updateLabelPositions () {
  const cx = document.getElementById('graph-svg').getBoundingClientRect().width / 2
  const cy = document.getElementById('graph-svg').getBoundingClientRect().height / 2

  for (const n of graphNodes) {
    if (!n.labelEl) continue
    if (gfx.labelPos === 'below') {
      n.labelEl.setAttribute('dy', n.r + 14)
      n.labelEl.setAttribute('dx', '0')
      n.labelEl.setAttribute('text-anchor', 'middle')
    } else if (gfx.labelPos === 'right') {
      n.labelEl.setAttribute('dy', '4')
      n.labelEl.setAttribute('dx', n.r + 6)
      n.labelEl.setAttribute('text-anchor', 'start')
    } else if (gfx.labelPos === 'radial') {
      // Point outward from center
      const dx = n.x - cx
      const dy = n.y - cy
      const angle = Math.atan2(dy, dx)
      const dist = n.r + 10
      n.labelEl.setAttribute('dy', Math.sin(angle) * dist + 4)
      n.labelEl.setAttribute('dx', Math.cos(angle) * dist)
      n.labelEl.setAttribute('text-anchor', Math.cos(angle) > 0.3 ? 'start' : Math.cos(angle) < -0.3 ? 'end' : 'middle')
    }
  }
}

function drillIntoFile (moduleName, filePath, fromNav) {
  const mod = tree.modules[moduleName]
  if (!mod || !mod.files[filePath]) return
  const file = mod.files[filePath]

  if (!fromNav) {
    if (graphStack.length === 0 || graphStack[graphStack.length - 1].type !== 'module') {
      graphStack = [{ type: 'module', name: moduleName, label: moduleName }]
    }
    graphStack.push({ type: 'file', name: filePath, moduleName, label: filePath.split('/').pop() })
    if (typeof pushStateForStack === 'function') pushStateForStack()
  }
  graphLevel = 'file'

  const svg = document.getElementById('graph-svg')
  const rect = svg.getBoundingClientRect()
  const W = rect.width, H = rect.height
  const cx = W / 2, cy = H / 2

  graphNodes.length = 0
  graphEdges.length = 0
  focusedNode = null
  zoom = 1; panX = 0; panY = 0

  // Build nodes: classes, functions, constants
  let idx = 0
  const items = []

  if (file.classes) for (const cls of file.classes) {
    items.push({ type: 'class', name: cls.name, data: cls, memberCount: cls.methods.length + (cls.getters ? cls.getters.length : 0), color: '#d2a8ff' })
  }
  if (file.functions) for (const fn of file.functions) {
    items.push({ type: 'function', name: fn.name, data: fn, memberCount: 0, color: '#79c0ff' })
  }
  if (file.constants) for (const c of file.constants) {
    if (c.type === 'class' || c.type === 'function') continue
    items.push({ type: 'const', name: c.name, data: c, memberCount: 0, color: '#ffa657' })
  }

  for (let i = 0; i < items.length; i++) {
    const item = items[i]
    const angle = (i / items.length) * Math.PI * 2
    const radius = Math.min(W, H) * 0.25

    graphNodes.push({
      idx: i, name: item.name, itemType: item.type, data: item.data,
      x: cx + Math.cos(angle) * radius,
      y: cy + Math.sin(angle) * radius,
      vx: 0, vy: 0,
      r: Math.max(8, Math.min(25, 8 + Math.sqrt(item.memberCount) * 3)),
      memberCount: item.memberCount,
      color: item.color,
      dependsOn: [], usedBy: []
    })
  }

  // Edges: delegation relationships
  if (file.delegates) {
    for (const del of file.delegates) {
      // Find source class (the one with the delegate field)
      const srcIdx = graphNodes.findIndex(n => n.itemType === 'class' && n.data.state && n.data.state.includes(del.field))
      if (srcIdx < 0) continue
      // No target in same file usually, but mark delegation as self-edge label
    }
  }

  renderSubGraph(svg, cx, cy, W, H, 'item')
  updateBreadcrumb()
}

function renderSubGraph (svg, cx, cy, W, H, nodeType) {
  const defs = svg.querySelector('defs').outerHTML
  svg.innerHTML = defs + '<g id="graph-world"><g id="graph-edges"></g><g id="graph-edge-labels"></g><g id="graph-nodes"></g><g id="graph-expanded"></g></g>'
  graphGroup = document.getElementById('graph-world')
  const edgeGroup = document.getElementById('graph-edges')
  const labelGroup = document.getElementById('graph-edge-labels')
  const nodeGroup = document.getElementById('graph-nodes')

  // Draw edges with labels
  for (const edge of graphEdges) {
    const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    line.classList.add('graph-edge')
    edgeGroup.appendChild(line)
    edge.el = line

    // Edge label showing imported names
    if (edge.labels && edge.labels.length > 0) {
      const label = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      label.classList.add('edge-label')
      label.textContent = edge.labels.slice(0, 3).join(', ') + (edge.labels.length > 3 ? '...' : '')
      label.setAttribute('font-size', '9')
      label.setAttribute('fill', '#484f58')
      label.setAttribute('text-anchor', 'middle')
      label.style.display = gfx.showLabels ? '' : 'none'
      labelGroup.appendChild(label)
      edge.labelEl = label
    }
  }

  // Shared by every node's click handler and the moveHandler registered below.
  // Declared out here for the same reason as in initGraph: a per-node copy is
  // invisible to a handler created after the loop closes.
  let didDrag = false

  // Draw nodes
  for (let i = 0; i < graphNodes.length; i++) {
    const n = graphNodes[i]
    const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
    g.classList.add('graph-node')

    // Shape by role/type
    if (nodeType === 'file' && n.role === 'entry') {
      // Diamond for entry
      const d = n.r
      const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon')
      poly.setAttribute('points', '0,-'+d+' '+d+',0 0,'+d+' -'+d+',0')
      poly.setAttribute('fill', n.color)
      poly.setAttribute('stroke', '#30363d')
      poly.setAttribute('stroke-width', '1.5')
      g.appendChild(poly)
    } else if (nodeType === 'file' && n.role === 'test') {
      // Square for test
      const s = n.r * 0.8
      const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
      rect.setAttribute('x', -s); rect.setAttribute('y', -s)
      rect.setAttribute('width', s*2); rect.setAttribute('height', s*2)
      rect.setAttribute('rx', '3')
      rect.setAttribute('fill', n.color)
      rect.setAttribute('stroke', '#30363d')
      rect.setAttribute('stroke-width', '1.5')
      g.appendChild(rect)
    } else {
      // Circle for everything else
      const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      circle.setAttribute('r', n.r)
      circle.setAttribute('fill', n.color)
      circle.setAttribute('stroke', '#30363d')
      circle.setAttribute('stroke-width', '1.5')
      g.appendChild(circle)
    }

    // Label
    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
    const shortName = n.name.includes('/') ? n.name.split('/').pop() : n.name
    text.textContent = shortName
    text.setAttribute('dy', n.r + 14)
    text.setAttribute('text-anchor', 'middle')
    text.setAttribute('font-size', '11')
    g.appendChild(text)

    // Member count inside node
    if (n.memberCount > 0) {
      const ct = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      ct.textContent = n.memberCount
      ct.setAttribute('dy', '4')
      ct.setAttribute('text-anchor', 'middle')
      ct.setAttribute('font-size', '9')
      ct.setAttribute('fill', '#0d1117')
      ct.setAttribute('font-weight', 'bold')
      g.appendChild(ct)
    }

    // Dim tests if toggle is on
    if (gfx.dimTests && n.role === 'test') g.style.opacity = '0.2'

    nodeGroup.appendChild(g)
    n.el = g

    // Drag
    g.addEventListener('mousedown', (e) => {
      n.fixed = true; didDrag = false
      e.stopPropagation(); e.preventDefault()
    })

    g.addEventListener('click', (e) => {
      e.stopPropagation()
      if (didDrag) return

      if (nodeType === 'file') {
        currentFileContext = { path: n.path, moduleName: n.moduleName }
        // In graph mode: show structure in right panel, load file source in docs tab
        if (n.file && (n.file.classes || n.file.functions || n.file.exports)) {
          showStructurePanel(n.path, n.file, n.moduleName)
        }
        // Load file source into docs tab
        const modPath = n.moduleName ? tree.modules[n.moduleName].path + '/' : ''
        loadFileIntoDocsTab(modPath + n.path)
        toggleExpandNode(n, i)
      } else if (n.itemType === 'class') {
        showGraphItemPanel('class', n.data, n.name)
      } else if (n.itemType === 'function') {
        showGraphItemPanel('function', n.data, n.name)
      }
      focusModule(i)
    })

    g.addEventListener('dblclick', (e) => {
      e.stopPropagation()
      if (nodeType === 'file' && n.file) {
        drillIntoFile(n.moduleName, n.path)
      }
    })

    g.addEventListener('mouseenter', () => {
      const tooltip = document.getElementById('graph-tooltip')
      let html = '<strong>' + n.name + '</strong>'
      if (n.role) html += ' <span style="color:' + n.color + '">' + n.role + '</span>'
      if (n.memberCount) html += '<br>' + n.memberCount + ' members'
      if (n.exportCount) html += ', ' + n.exportCount + ' exports'
      if (n.file && n.file.classes) {
        html += '<br>Classes: ' + n.file.classes.map(c => c.name).join(', ')
      }
      if (n.file && n.file.functions) {
        html += '<br>Functions: ' + n.file.functions.slice(0, 5).map(f => f.name).join(', ')
        if (n.file.functions.length > 5) html += '...'
      }
      if (n.itemType) html += '<br>type: ' + n.itemType
      if (n.data && n.data.params) html += '<br>params: ' + n.data.params.join(', ')
      html += '<br><em style="color:#484f58">click to expand, dblclick to drill in</em>'
      tooltip.innerHTML = html
      tooltip.style.display = 'block'
    })
    g.addEventListener('mouseleave', hideTooltip)
  }

  // Pan/zoom handler
  const svgEl = document.getElementById('graph-svg')
  const moveHandler = (e) => {
    const tooltip = document.getElementById('graph-tooltip')
    tooltip.style.left = (e.clientX + 14) + 'px'
    tooltip.style.top = (e.clientY + 14) + 'px'

    if (isPanning) {
      panX = e.clientX - panStartX
      panY = e.clientY - panStartY
      graphGroup.setAttribute('transform', 'translate(' + panX.toFixed(1) + ',' + panY.toFixed(1) + ') scale(' + zoom.toFixed(3) + ')')
      return
    }
    for (const nd of graphNodes) {
      if (nd.fixed) {
        const sr = svgEl.getBoundingClientRect()
        nd.x = (e.clientX - sr.left - panX) / zoom
        nd.y = (e.clientY - sr.top - panY) / zoom
        didDrag = true
      }
    }
  }
  document.removeEventListener('mousemove', graphMoveHandler)
  document.addEventListener('mousemove', moveHandler)
  graphMoveHandler = moveHandler

  // Force simulation — blends hierarchy pull vs force physics via slider
  function sim () {
    const damp = 0.8
    const t = gfx.forceStrength / 100 // 0 = pure hierarchy, 1 = full force
    const hierPull = 1 - t // how strongly to pull toward hierarchy positions

    // Hierarchy spring: pull toward stored hierarchy positions
    if (hierPull > 0.01) {
      for (const n of graphNodes) {
        if (n.fixed || n.hierX === undefined) continue
        n.vx += (n.hierX - n.x) * hierPull * 0.15
        n.vy += (n.hierY - n.y) * hierPull * 0.15
      }
    }

    // Force physics (scaled by t)
    if (t > 0.01) {
      // Label-aware repulsion
      for (let i = 0; i < graphNodes.length; i++) {
        for (let j = i + 1; j < graphNodes.length; j++) {
          const a = graphNodes[i], b = graphNodes[j]
          let dx = b.x - a.x, dy = b.y - a.y
          let dist = Math.sqrt(dx * dx + dy * dy) || 1
          const minDist = (a.labelW || a.r * 2) / 2 + (b.labelW || b.r * 2) / 2 + 15
          if (dist < minDist) {
            const f = (minDist - dist) * 0.08 * t
            if (!a.fixed) { a.vx -= (dx/dist)*f; a.vy -= (dy/dist)*f }
            if (!b.fixed) { b.vx += (dx/dist)*f; b.vy += (dy/dist)*f }
          }
        }
      }

      // Edge attraction
      for (const edge of graphEdges) {
        const a = graphNodes[edge.source], b = graphNodes[edge.target]
        const dx = b.x-a.x, dy = b.y-a.y, dist = Math.sqrt(dx*dx+dy*dy)||1
        if (dist > 180) {
          const f = (dist - 180) * 0.001 * t
          if (!a.fixed) { a.vx += (dx/dist)*f; a.vy += (dy/dist)*f }
          if (!b.fixed) { b.vx -= (dx/dist)*f; b.vy -= (dy/dist)*f }
        }
      }
    }

    // Always: overlap prevention (even in hierarchy mode)
    for (let i = 0; i < graphNodes.length; i++) {
      for (let j = i + 1; j < graphNodes.length; j++) {
        const a = graphNodes[i], b = graphNodes[j]
        let dx = b.x - a.x, dy = b.y - a.y
        let dist = Math.sqrt(dx * dx + dy * dy) || 1
        const minDist = (a.labelW || a.r * 2) / 2 + (b.labelW || b.r * 2) / 2 + 10
        if (dist < minDist) {
          const f = (minDist - dist) * 0.02
          if (!a.fixed) { a.vx -= (dx/dist)*f; a.vy -= (dy/dist)*f }
          if (!b.fixed) { b.vx += (dx/dist)*f; b.vy += (dy/dist)*f }
        }
      }
    }

    for (const n of graphNodes) {
      if (n.fixed) continue
      n.vx *= damp; n.vy *= damp
      n.x += n.vx; n.y += n.vy
    }

    // Update SVG
    for (const n of graphNodes) {
      n.el.setAttribute('transform', 'translate('+n.x.toFixed(1)+','+n.y.toFixed(1)+')')
    }
    for (const edge of graphEdges) {
      const a = graphNodes[edge.source], b = graphNodes[edge.target]
      const dx=b.x-a.x, dy=b.y-a.y, dist=Math.sqrt(dx*dx+dy*dy)||1
      const x1 = a.x+dx/dist*(a.r+2), y1 = a.y+dy/dist*(a.r+2)
      const x2 = b.x-dx/dist*(b.r+6), y2 = b.y-dy/dist*(b.r+6)
      edge.el.setAttribute('x1', x1.toFixed(1))
      edge.el.setAttribute('y1', y1.toFixed(1))
      edge.el.setAttribute('x2', x2.toFixed(1))
      edge.el.setAttribute('y2', y2.toFixed(1))
      // Position label at midpoint
      if (edge.labelEl) {
        edge.labelEl.setAttribute('x', ((x1+x2)/2).toFixed(1))
        edge.labelEl.setAttribute('y', ((y1+y2)/2 - 4).toFixed(1))
      }
    }

    // Update expanded satellite positions
    for (const n of graphNodes) {
      if (n.satellites) {
        for (const sat of n.satellites) {
          sat.el.setAttribute('transform', 'translate('+(n.x+sat.ox).toFixed(1)+','+(n.y+sat.oy).toFixed(1)+')')
        }
      }
    }

    requestAnimationFrame(sim)
  }
  sim()
}

// --- Expand node in-place: show classes/methods as satellites ---
function toggleExpandNode (n, idx) {
  const expandGroup = document.getElementById('graph-expanded')
  if (!expandGroup) return

  if (expandedNodes.has(idx)) {
    // Collapse
    expandedNodes.delete(idx)
    if (n.satellites) {
      for (const sat of n.satellites) sat.el.remove()
      n.satellites = null
    }
    return
  }

  expandedNodes.add(idx)
  const file = n.file
  if (!file) return

  const items = []
  if (file.classes) for (const cls of file.classes) {
    items.push({ name: cls.name, type: 'class', sub: cls.methods.slice(0, 5).map(m => m.name) })
  }
  if (file.functions) for (const fn of file.functions) {
    items.push({ name: fn.name, type: 'fn' })
  }

  if (items.length === 0) return

  n.satellites = []
  const satRadius = n.r + 35

  for (let i = 0; i < items.length; i++) {
    const item = items[i]
    const angle = (i / items.length) * Math.PI * 2 - Math.PI / 2
    const ox = Math.cos(angle) * satRadius
    const oy = Math.sin(angle) * satRadius

    const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
    g.classList.add('graph-node')

    const r = item.type === 'class' ? 6 : 4
    const color = item.type === 'class' ? '#d2a8ff' : '#79c0ff'
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
    circle.setAttribute('r', r)
    circle.setAttribute('fill', color)
    circle.setAttribute('stroke', '#30363d')
    circle.setAttribute('stroke-width', '1')
    g.appendChild(circle)

    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
    text.textContent = item.name
    text.setAttribute('dx', r + 4)
    text.setAttribute('dy', '3')
    text.setAttribute('font-size', '9')
    text.setAttribute('fill', color)
    g.appendChild(text)

    // Show methods as sub-text for classes
    if (item.sub && item.sub.length > 0) {
      for (let si = 0; si < item.sub.length; si++) {
        const st = document.createElementNS('http://www.w3.org/2000/svg', 'text')
        st.textContent = '.' + item.sub[si] + '()'
        st.setAttribute('dx', r + 8)
        st.setAttribute('dy', (si + 1) * 11 + 3)
        st.setAttribute('font-size', '8')
        st.setAttribute('fill', '#484f58')
        g.appendChild(st)
      }
    }

    expandGroup.appendChild(g)
    n.satellites.push({ el: g, ox, oy })
  }
}

let graphMoveHandler = () => {}

function roleColor (role) {
  const colors = { entry: '#f0883e', lib: '#58a6ff', schema: '#d2a8ff', test: '#7ee787', bin: '#ff7b72', script: '#ffa657' }
  return colors[role] || '#8b949e'
}

// ====== FILE VIEWER ======
async function loadFileViewer (filePath, moduleName, scrollToLine) {
  const panel = document.getElementById('detail-content')
  panel.innerHTML = '<h2>Loading...</h2>'

  try {
    const resp = await fetch(API + '/api/file?path=' + encodeURIComponent(filePath))
    const data = await resp.json()
    if (data.error) {
      panel.innerHTML = '<h2>Error</h2><pre>' + escapeHtml(data.error) + '</pre>'
      return
    }

    const lines = data.content.split('\\n')
    let html = '<div class="file-viewer-header">'
    html += '<span class="fv-path">' + (moduleName ? moduleName + '/' : '') + data.path + '</span>'
    html += '<span class="fv-info">' + lines.length + ' lines</span>'
    html += '</div>'
    html += '<div class="file-viewer"><table>'

    for (let i = 0; i < lines.length; i++) {
      const lineNum = i + 1
      const highlighted = scrollToLine && lineNum === scrollToLine ? ' highlight' : ''
      html += '<tr id="L' + lineNum + '" class="' + highlighted + '">'
      html += '<td class="line-no">' + lineNum + '</td>'
      html += '<td class="line-code">' + syntaxHighlight(escapeHtml(lines[i])) + '</td>'
      html += '</tr>'
    }

    html += '</table></div>'
    panel.innerHTML = html

    // Scroll to highlighted line
    if (scrollToLine) {
      const row = document.getElementById('L' + scrollToLine)
      if (row) row.scrollIntoView({ block: 'center', behavior: 'smooth' })
    }
  } catch (err) {
    panel.innerHTML = '<h2>Error</h2><pre>' + escapeHtml(err.message) + '</pre>'
  }
}

var synCmt = new RegExp('(' + '/' + '/.*$)')
var synStr = new RegExp("(&quot;[^&]*&quot;|&#39;[^&]*&#39;|'[^']*'|" + '"[^"]*")', 'g')
var synKw = new RegExp('\\\\b(import|export|from|const|let|var|function|class|extends|return|if|else|for|while|switch|case|break|continue|new|this|async|await|throw|try|catch|typeof|instanceof|default|yield)\\\\b', 'g')
var synNum = new RegExp('\\\\b(\\\\d+\\\\.?\\\\d*)\\\\b', 'g')
var synFn = new RegExp('\\\\b([a-zA-Z_]\\\\w*)\\\\s*\\\\(', 'g')

function syntaxHighlight (line) {
  line = line.replace(synCmt, '<span class="syn-cmt">$1</span>')
  line = line.replace(synStr, '<span class="syn-str">$1</span>')
  line = line.replace(synKw, '<span class="syn-kw">$1</span>')
  line = line.replace(synNum, '<span class="syn-num">$1</span>')
  line = line.replace(synFn, '<span class="syn-fn">$1</span>(')
  return line
}

// ====== GRAPH -> RIGHT PANEL INTEGRATION ======
function showGraphModulePanel (name, mod) {
  const rp = document.getElementById('right-panel')
  rp.style.display = 'block'
  document.getElementById('resize-right').style.display = ''

  // Show structure tab with module overview
  const panel = document.getElementById('structure-content')
  let html = '<h2 style="font-size:13px;color:#f0883e;margin-bottom:8px">' + name + ' <span style="color:#8b949e">' + mod.version + '</span></h2>'

  // Public API
  if (mod.publicAPI.length > 0) {
    html += '<div class="detail"><h3>Public API (' + mod.publicAPI.length + ')</h3>'
    for (const exp of mod.publicAPI) {
      html += '<div style="padding:1px 0;font-size:12px;color:#3fb950">' + exp + '</div>'
    }
    html += '</div>'
  }

  // Dependencies (local only)
  const localDeps = Object.entries(mod.dependencies).filter(([d]) => tree.modules[d])
  const externalDeps = Object.entries(mod.dependencies).filter(([d]) => !tree.modules[d])
  if (localDeps.length > 0) {
    html += '<div class="detail"><h3 style="color:#3fb950">Local Dependencies</h3>'
    for (const [dep, ver] of localDeps) {
      html += '<div style="padding:1px 0;font-size:12px"><span style="color:#58a6ff">' + dep + '</span> <span style="color:#484f58">' + ver + '</span></div>'
    }
    html += '</div>'
  }
  if (externalDeps.length > 0) {
    html += '<div class="detail"><h3>External Dependencies (' + externalDeps.length + ')</h3>'
    for (const [dep, ver] of externalDeps) {
      html += '<div style="padding:1px 0;font-size:11px;color:#8b949e">' + dep + ' ' + ver + '</div>'
    }
    html += '</div>'
  }

  // Files overview
  const files = Object.entries(mod.files)
  html += '<div class="detail"><h3>Files (' + files.length + ')</h3>'
  for (const [path, file] of files) {
    const counts = []
    if (file.classes) counts.push(file.classes.length + ' cls')
    if (file.functions) counts.push(file.functions.length + ' fn')
    html += '<div style="padding:1px 0;font-size:11px"><span style="color:#8b949e">' + path + '</span>' +
      (counts.length ? ' <span style="color:#484f58">' + counts.join(', ') + '</span>' : '') + '</div>'
  }
  html += '</div>'

  // Test command
  if (mod.testCommand) {
    html += '<div class="detail"><h3>Test</h3><pre style="font-size:11px"><code>' + mod.testCommand + '</code></pre></div>'
  }

  panel.innerHTML = html
  switchRightPanel('structure')

  // Also populate validation if available
  if (tree.validation && tree.validation.modules[name]) {
    renderValidation(name)
  }

  // Load spec file into docs tab
  loadSpecForModule(name)
}

function showGraphItemPanel (type, data, name) {
  const rp = document.getElementById('right-panel')
  rp.style.display = 'block'
  document.getElementById('resize-right').style.display = ''

  const panel = document.getElementById('structure-content')
  let html = ''

  if (type === 'class') {
    const cls = data
    html = '<h2 style="font-size:13px;color:#d2a8ff;margin-bottom:8px">class ' + cls.name + (cls.extends ? ' <span style="color:#8b949e">extends ' + cls.extends + '</span>' : '') + '</h2>'

    if (cls.jsdoc) {
      html += '<div class="detail"><h3>JSDoc</h3><pre style="font-size:11px"><code>' + escapeHtml(cls.jsdoc) + '</code></pre></div>'
    }

    if (cls.constructor) {
      html += '<div class="detail"><h3 style="color:#ffa657">Constructor</h3>'
      html += '<div style="font-size:12px">constructor(' + cls.constructor.params.join(', ') + ')</div>'
      if (cls.constructor.jsdoc) html += '<pre style="font-size:10px;margin-top:4px"><code>' + escapeHtml(cls.constructor.jsdoc) + '</code></pre>'
      html += '</div>'
    }

    if (cls.getters && cls.getters.length > 0) {
      html += '<div class="detail"><h3>Getters (' + cls.getters.length + ')</h3>'
      for (const g of cls.getters) html += '<div style="padding:1px 0;font-size:11px"><span style="color:#7ee787">get</span> ' + g.name + '</div>'
      html += '</div>'
    }

    html += '<div class="detail"><h3>Methods (' + cls.methods.filter(m => m.kind !== 'field').length + ')</h3>'
    for (const m of cls.methods) {
      if (m.kind === 'field') continue
      const tags = m.async ? ' <span style="color:#7ee787;font-size:9px">async</span>' : ''
      const lineRef = m.line ? ' <span style="color:#484f58;font-size:9px;cursor:pointer" onclick="scrollFileToLine(' + m.line + ')">:' + m.line + '</span>' : ''
      html += '<div style="padding:2px 0;font-size:11px"><span style="color:#79c0ff">' + m.name + '</span>(' + m.params.join(', ') + ')' + tags + lineRef
      if (m.jsdoc) html += '<div style="font-size:9px;color:#484f58;margin:2px 0 4px;padding-left:8px">' + escapeHtml(m.jsdoc).slice(0, 120) + (m.jsdoc.length > 120 ? '...' : '') + '</div>'
      html += '</div>'
    }
    html += '</div>'
  } else if (type === 'function') {
    const fn = data
    html = '<h2 style="font-size:13px;color:#79c0ff;margin-bottom:8px">' + fn.name + '</h2>'
    const tags = (fn.async ? 'async ' : '') + (fn.arrow ? '=> ' : '')
    html += '<pre style="font-size:12px"><code>' + tags + fn.name + '(' + fn.params.join(', ') + ')</code></pre>'
    if (fn.jsdoc) {
      html += '<div class="detail"><h3>JSDoc</h3><pre style="font-size:11px"><code>' + escapeHtml(fn.jsdoc) + '</code></pre></div>'
    }
  }

  panel.innerHTML = html
  switchRightPanel('structure')

  // Also load file source into docs tab scrolled to the item
  if (currentFileContext && data.line) {
    const modPath = currentFileContext.moduleName ? tree.modules[currentFileContext.moduleName].path + '/' : ''
    loadFileIntoDocsTab(modPath + currentFileContext.path, data.line)
  }
}

// ====== DOCS LOADING ======
async function loadSpecForModule (moduleName) {
  const docsPanel = document.getElementById('docs-content')

  // Build candidate spec locations. Prefer the discovered specDir (from scanner.js
  // findAllDocs()) — this is the project's actual spec directory, not a hardcoded path.
  const specPaths = []
  if (tree.docs && tree.docs.specDir) {
    // specDir is absolute; convert to project-relative for the /api/file endpoint
    const rel = tree.docs.specDir.startsWith(tree.root)
      ? tree.docs.specDir.slice(tree.root.length + 1)
      : tree.docs.specDir
    specPaths.push(rel + '/modules/' + moduleName + '.md')
  }
  // Conventional fallbacks (no hardcoded project names)
  specPaths.push(
    'spec/modules/' + moduleName + '.md',
    'docs/spec/modules/' + moduleName + '.md',
    moduleName + '/README.md'
  )

  for (const specPath of specPaths) {
    try {
      const resp = await fetch(API + '/api/file?path=' + encodeURIComponent(specPath))
      const data = await resp.json()
      if (data.error) continue

      let html = '<h2 style="font-size:12px;color:#58a6ff;margin-bottom:4px">' + specPath + '</h2>'
      html += '<div class="file-viewer" style="max-height:calc(100vh - 100px);font-size:11px"><table>'
      const lines = data.content.split('\\n')
      for (let i = 0; i < lines.length; i++) {
        html += '<tr><td class="line-no">' + (i + 1) + '</td><td class="line-code">' + renderMarkdownLine(escapeHtml(lines[i])) + '</td></tr>'
      }
      html += '</table></div>'
      docsPanel.innerHTML = html
      return
    } catch {}
  }

  docsPanel.innerHTML = '<h2 style="color:#8b949e">No spec file found for ' + moduleName + '</h2>' +
    '<p style="color:#484f58;font-size:11px">Searched:<br>' + specPaths.join('<br>') + '</p>'
}

function renderMarkdownLine (line) {
  // Minimal markdown rendering for spec files
  if (line.startsWith('# ')) return '<span style="color:#58a6ff;font-weight:bold;font-size:14px">' + line.slice(2) + '</span>'
  if (line.startsWith('## ')) return '<span style="color:#58a6ff;font-weight:bold;font-size:13px">' + line.slice(3) + '</span>'
  if (line.startsWith('### ')) return '<span style="color:#58a6ff;font-size:12px">' + line.slice(4) + '</span>'
  if (line.startsWith('- ')) return '<span style="color:#8b949e">  </span>' + line.slice(2)
  if (line.startsWith('**') && line.includes(':**')) {
    return line.replace(/\*\*([^*]+)\*\*/g, '<span style="color:#ffa657;font-weight:bold">$1</span>')
  }
  // Inline code (use char code to avoid backtick in template literal)
  var bt = String.fromCharCode(96)
  var inlineCodeRe = new RegExp(bt + '([^' + bt + ']+)' + bt, 'g')
  line = line.replace(inlineCodeRe, '<span style="color:#79c0ff;background:#161b22;padding:0 3px;border-radius:2px">$1</span>')
  // Bold
  line = line.replace(new RegExp('\\\\*\\\\*([^*]+)\\\\*\\\\*', 'g'), '<span style="font-weight:bold">$1</span>')
  return line
}

async function loadFileIntoDocsTab (filePath, scrollToLine) {
  const docsPanel = document.getElementById('docs-content')
  try {
    const resp = await fetch(API + '/api/file?path=' + encodeURIComponent(filePath))
    const data = await resp.json()
    if (data.error) {
      docsPanel.innerHTML = '<p style="color:#ff7b72">' + data.error + '</p>'
      return
    }
    const lines = data.content.split('\\n')
    let html = '<h2 style="font-size:12px;color:#58a6ff;margin-bottom:4px">' + data.path + ' <span style="color:#484f58">' + lines.length + ' lines</span></h2>'
    html += '<div class="file-viewer" style="max-height:calc(100vh - 100px)"><table>'
    for (let i = 0; i < lines.length; i++) {
      const lineNum = i + 1
      const hl = scrollToLine && lineNum === scrollToLine ? ' highlight' : ''
      html += '<tr id="DL' + lineNum + '" class="' + hl + '"><td class="line-no">' + lineNum + '</td><td class="line-code">' + syntaxHighlight(escapeHtml(lines[i])) + '</td></tr>'
    }
    html += '</table></div>'
    docsPanel.innerHTML = html
    if (scrollToLine) {
      var row = document.getElementById('DL' + scrollToLine)
      if (row) row.scrollIntoView({ block: 'center' })
    }
  } catch (err) {
    docsPanel.innerHTML = '<p style="color:#ff7b72">' + err.message + '</p>'
  }
}

// ====== RIGHT PANEL TABS ======
let rightPanelTab = 'structure'

window.switchRightPanel = function (tab) {
  rightPanelTab = tab
  document.getElementById('rp-structure').style.display = tab === 'structure' ? 'block' : 'none'
  document.getElementById('rp-validation').style.display = tab === 'validation' ? 'block' : 'none'
  document.getElementById('rp-docs').style.display = tab === 'docs' ? 'block' : 'none'
  document.querySelectorAll('#right-panel-tabs .tab').forEach(t => {
    t.classList.toggle('active', t.dataset.rp === tab)
  })
}

function showStructurePanel (filePath, file, moduleName) {
  const panel = document.getElementById('structure-content')
  const rp = document.getElementById('right-panel')
  rp.style.display = 'block'
  document.getElementById('resize-right').style.display = ''
  switchRightPanel('structure')

  let html = '<h2 style="font-size:13px;color:#58a6ff;margin-bottom:8px">' + (filePath.includes('/') ? filePath.split('/').pop() : filePath) + '</h2>'

  if (file.role) html += '<span class="badge" style="margin-bottom:8px;display:inline-block">' + file.role + '</span> '

  // Exports
  if (file.exports && file.exports.length > 0) {
    html += '<div class="detail"><h3>Exports (' + file.exports.length + ')</h3>'
    for (const e of file.exports) {
      const type = e.type ? ' <span style="color:#8b949e">(' + e.type + ')</span>' : ''
      const from = e.from ? ' <span style="color:#484f58">from ' + e.from + '</span>' : ''
      html += '<div style="padding:1px 0;font-size:12px"><span style="color:#3fb950">' + e.name + '</span>' + type + from + '</div>'
    }
    html += '</div>'
  }

  // Imports
  if (file.imports && file.imports.length > 0) {
    html += '<div class="detail"><h3>Imports (' + file.imports.length + ')</h3>'
    for (const i of file.imports) {
      html += '<div style="padding:1px 0;font-size:12px"><span style="color:#79c0ff">' + i.names.join(', ') + '</span> <span style="color:#484f58">from</span> <span style="color:#8b949e">' + i.specifier + '</span></div>'
    }
    html += '</div>'
  }

  // Classes
  if (file.classes && file.classes.length > 0) {
    for (const cls of file.classes) {
      html += '<div class="detail"><h3 style="color:#d2a8ff">class ' + cls.name + (cls.extends ? ' extends ' + cls.extends : '') + '</h3>'
      if (cls.constructor) {
        html += '<div style="padding:2px 0;font-size:11px;color:#ffa657">constructor(' + cls.constructor.params.join(', ') + ')</div>'
      }
      if (cls.getters && cls.getters.length > 0) {
        html += '<div style="margin-top:4px;font-size:10px;color:#484f58;text-transform:uppercase">Getters</div>'
        for (const g of cls.getters) {
          html += '<div style="padding:1px 0;font-size:11px"><span style="color:#7ee787">get</span> ' + g.name + '</div>'
        }
      }
      html += '<div style="margin-top:4px;font-size:10px;color:#484f58;text-transform:uppercase">Methods (' + cls.methods.filter(m => m.kind !== 'field').length + ')</div>'
      for (const m of cls.methods) {
        if (m.kind === 'field') continue
        const tags = (m.async ? '<span style="color:#7ee787;font-size:9px"> async</span>' : '')
        const lineRef = m.line ? ' <span style="color:#484f58;font-size:9px;cursor:pointer" onclick="scrollFileToLine(' + m.line + ')">:' + m.line + '</span>' : ''
        html += '<div style="padding:1px 0;font-size:11px"><span style="color:#79c0ff">' + m.name + '</span>(' + m.params.join(', ') + ')' + tags + lineRef + '</div>'
      }
      html += '</div>'
    }
  }

  // Functions
  if (file.functions && file.functions.length > 0) {
    html += '<div class="detail"><h3>Functions (' + file.functions.length + ')</h3>'
    for (const fn of file.functions) {
      const tags = (fn.async ? '<span style="color:#7ee787;font-size:9px"> async</span>' : '') +
        (fn.arrow ? '<span style="color:#79c0ff;font-size:9px"> =></span>' : '')
      const lineRef = fn.line ? ' <span style="color:#484f58;font-size:9px;cursor:pointer" onclick="scrollFileToLine(' + fn.line + ')">:' + fn.line + '</span>' : ''
      html += '<div style="padding:1px 0;font-size:11px"><span style="color:#79c0ff">' + fn.name + '</span>(' + fn.params.join(', ') + ')' + tags + lineRef + '</div>'
    }
    html += '</div>'
  }

  // State
  if (file.state && file.state.length > 0) {
    html += '<div class="detail"><h3>State</h3>'
    for (const s of file.state) html += '<div style="padding:1px 0;font-size:11px;color:#ffa657">' + s + '</div>'
    html += '</div>'
  }

  // Delegation
  if (file.delegates && file.delegates.length > 0) {
    html += '<div class="detail"><h3>Delegation</h3>'
    for (const d of file.delegates) {
      html += '<div style="padding:1px 0;font-size:11px">' + d.field + ' <span style="color:#484f58">-></span> ' + d.methods.join(', ') + '</div>'
    }
    html += '</div>'
  }

  // Errors
  if (file.errorContracts && file.errorContracts.length > 0) {
    html += '<div class="detail"><h3>Error Contracts (' + file.errorContracts.length + ')</h3>'
    for (const e of file.errorContracts) {
      html += '<div style="padding:1px 0;font-size:11px;color:#ff7b72">' + escapeHtml(e) + '</div>'
    }
    html += '</div>'
  }

  // GDScript signals
  if (file.signals && file.signals.length > 0) {
    html += '<div class="detail"><h3>Signals (' + file.signals.length + ')</h3>'
    for (const sig of file.signals) {
      html += '<div style="padding:1px 0;font-size:11px;color:#f0883e">' +
        escapeHtml(sig.name) + '(' + (sig.params || []).map(escapeHtml).join(', ') + ')</div>'
    }
    html += '</div>'
  }

  // Enums (GDScript, C++)
  if (file.enums && file.enums.length > 0) {
    html += '<div class="detail"><h3>Enums (' + file.enums.length + ')</h3>'
    for (const e of file.enums) {
      html += '<div style="padding:2px 0;font-size:11px"><span style="color:#d2a8ff">' + escapeHtml(e.name) + '</span>'
      if (e.values && e.values.length > 0) {
        html += '<span style="color:#8b949e"> { ' + e.values.slice(0, 6).map(escapeHtml).join(', ') +
          (e.values.length > 6 ? ', ...' : '') + ' }</span>'
      }
      html += '</div>'
    }
    html += '</div>'
  }

  // C++ namespaces
  if (file.namespaces && file.namespaces.length > 0) {
    html += '<div class="detail"><h3>Namespaces</h3>'
    html += '<div style="padding:2px 0;font-size:11px;color:#79c0ff">' +
      file.namespaces.map(escapeHtml).join(', ') + '</div>'
    html += '</div>'
  }

  // GLSL shader resources
  if (file.uniforms && file.uniforms.length > 0) {
    html += '<div class="detail"><h3>Uniforms (' + file.uniforms.length + ')</h3>'
    for (const u of file.uniforms) {
      const binding = u.set !== null && u.binding !== null
        ? ' <span style="color:#8b949e">(set=' + u.set + ', binding=' + u.binding + ')</span>'
        : (u.type ? ' <span style="color:#8b949e">' + escapeHtml(u.type) + '</span>' : '')
      html += '<div style="padding:1px 0;font-size:11px;color:#3fb950">' +
        escapeHtml(u.name) + binding + '</div>'
    }
    html += '</div>'
  }

  if (file.buffers && file.buffers.length > 0) {
    html += '<div class="detail"><h3>Buffers (' + file.buffers.length + ')</h3>'
    for (const b of file.buffers) {
      const mode = b.readonly ? 'RO' : b.writeonly ? 'WO' : 'RW'
      html += '<div style="padding:1px 0;font-size:11px;color:#ffa657">' +
        escapeHtml(b.name) + ' <span style="color:#8b949e">' + mode +
        ' (set=' + (b.set || '?') + ', binding=' + (b.binding || '?') + ')</span></div>'
    }
    html += '</div>'
  }

  if (file.pushConstants && file.pushConstants.length > 0) {
    html += '<div class="detail"><h3>Push Constants (' + file.pushConstants.length + ')</h3>'
    for (const p of file.pushConstants) {
      html += '<div style="padding:1px 0;font-size:11px;color:#d2a8ff">' + escapeHtml(p.name) + '</div>'
    }
    html += '</div>'
  }

  if (!file.exports && !file.classes && !file.functions && !file.signals && !file.uniforms && !file.buffers) {
    html += '<p style="color:#8b949e;font-size:12px">No structural data</p>'
  }

  panel.innerHTML = html
}

window.scrollFileToLine = function (line) {
  const row = document.getElementById('L' + line)
  if (row) {
    // Clear previous highlights
    document.querySelectorAll('.file-viewer tr.highlight').forEach(r => r.classList.remove('highlight'))
    row.classList.add('highlight')
    row.scrollIntoView({ block: 'center', behavior: 'smooth' })
  }
}

// ====== VALIDATION PANEL ======
let valInitialized = false
let valFilter = 'all' // 'all' | 'fail' | 'warn' | 'pass'

function initValidation () {
  valInitialized = true
  const panel = document.getElementById('val-content')
  const v = tree.validation

  if (!v) {
    panel.innerHTML = '<p style="color:#8b949e">No spec directory found. Place specs in a <code>spec/</code> directory with <code>SYSTEM.md</code> and <code>modules/*.md</code> files.</p>'
    return
  }

  renderValidation(null) // show all
}

function renderValidation (moduleName) {
  const panel = document.getElementById('val-content')
  const v = tree.validation
  if (!v) return

  let html = ''

  // Summary bar
  html += '<div class="val-summary">'
  html += '<div class="val-pass"><span class="val-count">' + v.summary.pass + '</span>pass</div>'
  html += '<div class="val-fail"><span class="val-count">' + v.summary.fail + '</span>fail</div>'
  html += '<div class="val-warn"><span class="val-count">' + v.summary.warn + '</span>warn</div>'
  html += '<div style="color:#8b949e"><span class="val-count">' + v.summary.total + '</span>total</div>'
  html += '</div>'

  // Filter buttons
  html += '<div class="val-filter">'
  html += '<button class="' + (valFilter === 'all' ? 'active' : '') + '" onclick="setValFilter(&quot;all&quot;)">All</button>'
  html += '<button class="' + (valFilter === 'fail' ? 'active' : '') + '" onclick="setValFilter(&quot;fail&quot;)">Fails</button>'
  html += '<button class="' + (valFilter === 'warn' ? 'active' : '') + '" onclick="setValFilter(&quot;warn&quot;)">Warns</button>'
  html += '<button class="' + (valFilter === 'pass' ? 'active' : '') + '" onclick="setValFilter(&quot;pass&quot;)">Pass</button>'
  html += '</div>'

  // System checks
  if (v.system && (!moduleName || moduleName === 'system')) {
    const checks = filterChecks(v.system.checks)
    if (checks.length > 0) {
      html += '<div class="val-section"><h3>System</h3>'
      html += renderChecks(checks)
      html += '</div>'
    }
  }

  // Module checks
  const modules = moduleName && moduleName !== 'system'
    ? [[moduleName, v.modules[moduleName]]]
    : Object.entries(v.modules)

  for (const [name, modReport] of modules) {
    if (!modReport) continue
    const checks = filterChecks(modReport.checks)
    if (checks.length === 0) continue

    const failCount = modReport.checks.filter(c => c.status === 'fail').length
    const warnCount = modReport.checks.filter(c => c.status === 'warn').length
    const passCount = modReport.checks.filter(c => c.status === 'pass').length

    html += '<div class="val-section">'
    html += '<h3 onclick="renderValidation(&quot;' + name + '&quot;)" style="cursor:pointer">'
    html += name
    html += ' <span class="val-pass">' + passCount + '</span>'
    if (failCount) html += ' <span class="val-fail">' + failCount + ' fail</span>'
    if (warnCount) html += ' <span class="val-warn">' + warnCount + ' warn</span>'
    html += '</h3>'
    html += renderChecks(checks)
    html += '</div>'
  }

  // Back button if filtered to one module
  if (moduleName) {
    html += '<div style="margin-top:12px"><button style="background:#161b22;border:1px solid #30363d;border-radius:4px;color:#58a6ff;padding:4px 12px;cursor:pointer;font-family:inherit;font-size:11px" onclick="renderValidation(null)">Show all modules</button></div>'
  }

  panel.innerHTML = html
}

function filterChecks (checks) {
  if (valFilter === 'all') return checks
  return checks.filter(c => c.status === valFilter)
}

function renderChecks (checks) {
  let html = ''
  for (const c of checks) {
    const icon = c.status === 'pass' ? '&#10003;' : c.status === 'fail' ? '&#10007;' : '&#9888;'
    html += '<div class="val-check ' + c.status + '">'
    html += '<span class="val-icon">' + icon + '</span>'
    html += '<span class="val-claim">' + escapeHtml(c.claim) + '</span>'
    if (c.detail) html += '<div class="val-detail">' + escapeHtml(c.detail) + '</div>'
    html += '</div>'
  }
  return html
}

window.setValFilter = function (f) {
  valFilter = f
  renderValidation(null)
}

// Expose for onclick in rendered HTML
window.renderValidation = renderValidation

// Also update validation when clicking modules in tree view
function showValidationForModule (name) {
  if (tree.validation && tree.validation.modules[name]) {
    document.getElementById('right-panel').style.display = 'block'
    document.getElementById('resize-right').style.display = ''
    switchRightPanel('validation')
    renderValidation(name)
  }
}

// ====== TREE EXPAND/COLLAPSE ======
window.expandAll = function () {
  document.querySelectorAll('#tree-root li.collapsed').forEach(li => {
    li.classList.remove('collapsed')
    const toggle = li.querySelector(':scope > .node .toggle')
    if (toggle) toggle.textContent = '-'
  })
}

window.collapseAll = function () {
  document.querySelectorAll('#tree-root li').forEach(li => {
    if (li.querySelector('ul')) {
      li.classList.add('collapsed')
      const toggle = li.querySelector(':scope > .node .toggle')
      if (toggle) toggle.textContent = '+'
    }
  })
}

// ====== VALIDATION HELPERS ======
function getModuleValidation (name) {
  if (!tree.validation || !tree.validation.modules[name]) return null
  const checks = tree.validation.modules[name].checks
  return {
    pass: checks.filter(c => c.status === 'pass').length,
    fail: checks.filter(c => c.status === 'fail').length,
    warn: checks.filter(c => c.status === 'warn').length,
    total: checks.length
  }
}

function valBadgeHTML (name) {
  const v = getModuleValidation(name)
  if (!v) return '<span class="val-badge val-badge-none">no spec</span>'
  if (v.fail > 0) return '<span class="val-badge val-badge-fail">' + v.fail + ' fail</span>' + (v.warn > 0 ? '<span class="val-badge val-badge-warn">' + v.warn + ' warn</span>' : '')
  if (v.warn > 0) return '<span class="val-badge val-badge-warn">' + v.warn + ' warn</span>'
  return '<span class="val-badge val-badge-pass">' + v.pass + ' ok</span>'
}

// ====== DOCS CRUFT VIEW (Phase 0e) ======
// Renders the docCruft report in the main content area when the Docs
// main-tab is active. Each doc is deep-linkable at /docs/<path>.
function renderDocsCruft (focusPath) {
  const detail = document.getElementById('detail')
  if (!detail) return
  const cruft = tree.docCruft
  if (!cruft) {
    detail.innerHTML = '<div style="padding:20px;color:#8b949e">No doc cruft report available. Run codetree on a project with markdown files to see this view.</div>'
    return
  }

  let html = '<div style="padding:16px;max-width:1000px;margin:0 auto">'
  html += '<h1 style="color:#58a6ff;font-size:1.4rem;margin-bottom:0.5rem">Documentation cruft</h1>'
  html += '<div style="color:#8b949e;font-size:0.85rem;margin-bottom:1.5rem">'
  html += cruft.summary.totalDocs + ' docs, ' + cruft.summary.totalLines + ' lines &middot; '
  html += '<span style="color:#3fb950">' + cruft.summary.freshDocs + ' fresh</span> &middot; '
  html += '<span style="color:#d29922">' + cruft.summary.staleDocs + ' stale</span> &middot; '
  html += cruft.summary.validReferences + ' valid, '
  html += cruft.summary.missingReferences + ' missing refs'
  if (cruft.summary.externalReferences) {
    html += ', ' + cruft.summary.externalReferences + ' external'
  }
  html += '</div>'

  for (const doc of cruft.docs) {
    if (doc.references.total === 0 && doc.staleness.score < 0.3) continue

    const score = doc.staleness.score
    const icon = score > 0.5 ? 'STALE' : score > 0.3 ? 'WARN' : 'FRESH'
    const color = score > 0.5 ? '#f85149' : score > 0.3 ? '#d29922' : '#3fb950'

    const docHref = (API || '') + '/docs/' + doc.path.split('/').map(encodeURIComponent).join('/')
    const open = (focusPath && doc.path === focusPath) ? ' open' : ''

    html += '<details' + open + ' style="border:1px solid #21262d;border-radius:6px;padding:8px 12px;margin-bottom:6px;background:#0d1117">'
    html += '<summary style="cursor:pointer;display:flex;align-items:center;gap:0.75rem">'
    html += '<span style="font-family:monospace;font-size:0.7rem;color:' + color + ';min-width:44px">' + icon + '</span>'
    html += '<a href="' + docHref + '" style="color:#58a6ff;text-decoration:none;font-family:monospace" onclick="event.stopPropagation()">' + escapeHtml(doc.path) + '</a>'
    html += '<span style="color:#6e7681;font-size:0.75rem;margin-left:auto">'
    html += doc.lines + ' lines &middot; ' + doc.lastModified + ' &middot; score ' + score
    html += '</span>'
    html += '</summary>'

    // Per-ref details, grouped by status
    const missing = doc.references.details.filter(d => d.status === 'missing')
    const external = doc.references.details.filter(d => d.status === 'external')
    const valid = doc.references.details.filter(d => d.status === 'valid')

    html += '<div style="margin-top:10px;font-size:0.8rem">'
    html += '<div style="color:#8b949e;margin-bottom:6px">'
    html += valid.length + ' valid &middot; ' + missing.length + ' missing'
    if (external.length) html += ' &middot; ' + external.length + ' external'
    html += '</div>'

    if (missing.length > 0) {
      html += '<div style="margin-bottom:8px"><span style="color:#f85149;font-weight:bold">Missing (' + missing.length + '):</span><ul style="margin-top:4px;padding-left:20px;color:#c9d1d9">'
      for (const m of missing.slice(0, 30)) {
        html += '<li><code>' + escapeHtml(m.ref) + '</code> <span style="color:#6e7681">(' + m.type + ', line ' + m.line + ')</span></li>'
      }
      if (missing.length > 30) html += '<li style="color:#6e7681">… and ' + (missing.length - 30) + ' more</li>'
      html += '</ul></div>'
    }

    if (external.length > 0) {
      html += '<div style="margin-bottom:8px"><span style="color:#6e7681;font-weight:bold">External (' + external.length + '):</span> <span style="color:#6e7681">' +
        external.slice(0, 10).map(e => '<code>' + escapeHtml(e.ref) + '</code>').join(', ') +
        (external.length > 10 ? ', …' : '') + '</span></div>'
    }

    html += '</div></details>'
  }
  html += '</div>'

  detail.innerHTML = html

  // Intercept clicks on doc links for SPA nav
  for (const a of detail.querySelectorAll('a[href^="' + (API || '') + '/docs/"]')) {
    a.addEventListener('click', (e) => {
      if (e.button !== 0) return
      if (e.metaKey || e.ctrlKey || e.shiftKey) return
      e.preventDefault()
      const href = a.getAttribute('href')
      const path = href.replace((API || '') + '/docs/', '').split('/').map(decodeURIComponent).join('/')
      window.navigateToDocs(path)
    })
  }
}

// ====== TREE DEP HIGHLIGHTING ======
function highlightTreeDeps (moduleName) {
  // Clear previous highlights
  document.querySelectorAll('.dep-highlight, .used-by-highlight, .selected-module').forEach(el => {
    el.classList.remove('dep-highlight', 'used-by-highlight', 'selected-module')
  })

  const mod = tree.modules[moduleName]
  if (!mod) return

  // Find local deps (modules that exist in the scan)
  const localDeps = new Set()
  for (const dep of Object.keys(mod.dependencies)) {
    if (tree.modules[dep]) localDeps.add(dep)
  }

  // Find reverse deps (modules that depend on this one)
  const usedBy = new Set()
  for (const [otherName, otherMod] of Object.entries(tree.modules)) {
    if (otherName === moduleName) continue
    if (otherMod.dependencies[moduleName]) usedBy.add(otherName)
  }

  // Highlight matching module nodes in the tree
  document.querySelectorAll('#tree-root .node[data-type="module"]').forEach(node => {
    const name = node.dataset.name
    if (name === moduleName) node.classList.add('selected-module')
    else if (localDeps.has(name)) node.classList.add('dep-highlight')
    else if (usedBy.has(name)) node.classList.add('used-by-highlight')
  })
}

// ====== SEARCH ======
document.getElementById('search').addEventListener('input', (e) => {
  const q = e.target.value.toLowerCase()
  const nodes = document.querySelectorAll('#tree-root .node')
  for (const node of nodes) {
    const name = (node.dataset.name || '').toLowerCase()
    const text = node.textContent.toLowerCase()
    const match = !q || name.includes(q) || text.includes(q)
    node.parentElement.style.display = match ? '' : 'none'
    if (match && q) {
      let parent = node.parentElement.parentElement
      while (parent) {
        if (parent.classList && parent.classList.contains('collapsed')) {
          parent.classList.remove('collapsed')
          const toggle = parent.querySelector(':scope > .node .toggle')
          if (toggle) toggle.textContent = '-'
        }
        parent = parent.parentElement
      }
    }
  }
})

// ====== STATS ======
function updateStats () {
  const stats = document.getElementById('stats')
  let totalFiles = 0, totalClasses = 0, totalMethods = 0, totalExports = 0
  for (const mod of Object.values(tree.modules)) {
    totalFiles += Object.keys(mod.files).length
    totalExports += mod.publicAPI.length
    for (const file of Object.values(mod.files)) {
      if (file.classes) {
        totalClasses += file.classes.length
        for (const cls of file.classes) totalMethods += cls.methods.length + (cls.getters ? cls.getters.length : 0)
      }
    }
  }
  stats.innerHTML =
    '<span><span class="stat-value">' + tree.system.modules.length + '</span> modules</span>' +
    '<span><span class="stat-value">' + totalFiles + '</span> files</span>' +
    '<span><span class="stat-value">' + totalClasses + '</span> classes</span>' +
    '<span><span class="stat-value">' + totalMethods + '</span> members</span>' +
    '<span><span class="stat-value">' + totalExports + '</span> exports</span>'
}

// ====== HELPERS ======
function el (tag, attrs) {
  const e = document.createElement(tag)
  if (attrs) for (const [k, v] of Object.entries(attrs)) {
    if (k.startsWith('data-')) e.setAttribute(k, v)
    else e[k] = v
  }
  return e
}
function sourceLink (line) {
  if (!currentFileContext) return ''
  const modPath = currentFileContext.moduleName ? tree.modules[currentFileContext.moduleName].path + '/' : ''
  const fullPath = modPath + currentFileContext.path
  return '<span class="badge" style="cursor:pointer;color:#58a6ff;border:1px solid #58a6ff;margin-bottom:8px;display:inline-block" ' +
    'onclick="loadFileViewer(&quot;' + fullPath + '&quot;,&quot;' + (currentFileContext.moduleName || '') + '&quot;,' + (line || 'null') + ')">' +
    'View Source' + (line ? ' :' + line : '') + '</span> '
}

// Expose for onclick attributes
window.loadFileViewer = loadFileViewer

function roleIcon (role) {
  return ({ entry: 'E', lib: 'L', test: 'T', schema: 'S', bin: 'B', script: 'X', example: 'x' })[role] || 'F'
}
// NOTE: escapeHtml is declared once, near the URL-routing helpers. A second,
// weaker copy used to live here — and because a later function declaration
// silently replaces an earlier one, THAT was the version the whole page used:
// no quote escaping and no String() coercion.

// ====== RESIZABLE PANELS ======
function initResizable () {
  makeResizable('resize-left', 'sidebar', 'right')
  makeResizable('resize-right', 'right-panel', 'left')
}

function makeResizable (handleId, panelId, side) {
  const handle = document.getElementById(handleId)
  const panel = document.getElementById(panelId)
  if (!handle || !panel) return

  let startX, startW

  handle.addEventListener('mousedown', (e) => {
    e.preventDefault()
    e.stopPropagation()
    startX = e.clientX
    startW = panel.offsetWidth
    handle.classList.add('dragging')
    document.body.style.cursor = 'col-resize'
    document.body.style.userSelect = 'none'

    const onMove = (e) => {
      const dx = e.clientX - startX
      const newW = side === 'right' ? startW + dx : startW - dx
      panel.style.width = Math.max(150, Math.min(800, newW)) + 'px'
    }

    const onUp = () => {
      handle.classList.remove('dragging')
      document.body.style.cursor = ''
      document.body.style.userSelect = ''
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('mouseup', onUp)
    }

    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)
  })
}

${NODE_VIEW_JS}

// ====== INIT ======
//
// The shared modules are a HARD dependency, not a hopeful global: they are the
// representation this page renders from — the dirTree walk, the categories, the
// layout. A deferred module script means waiting on window.__ct to
// appear is a race, and losing it renders an empty page. Await it, once, here.
;(async function boot () {
  if (!window.__ct) {
    try {
      const [cards, layoutMod, locationMod, routingMod] = await Promise.all([
        import(API + '/floor/cards.js'),
        import(API + '/floor/layout.js'),
        import(API + '/floor/location.js'),
        import(API + '/floor/routing.js')
      ])
      window.__ct = cards
      window.__ctLayout = layoutMod.layout
      window.__ctLoc = locationMod
      window.__ctRoute = routingMod
    } catch (err) {
      document.getElementById('detail').innerHTML =
        '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' +
        escapeHtml(String(err && err.message ? err.message : err)) + '</div>'
      return
    }
  }

  buildTree()
  initResizable()

  // The tab is part of WHERE YOU ARE, so it belongs in the URL like every other
  // view here (invariant I5). It is also the only way to review a tab without a
  // human clicking it.
  const wanted = new URLSearchParams(location.search).get('tab')
  if (wanted) {
    const tab = document.querySelector('.main-tab[data-tab="' + wanted + '"]')
    if (tab) tab.click()
  }
})()

// ====== URL ROUTING — popstate + initial URL ======
// On cold page load, parse location.pathname and route to the right view.
// popstate (back/forward button) re-derives the route from the URL and
// applies it without pushing new history.
(function initUrlRouting () {
  window.addEventListener('popstate', () => {
    const route = routeFromUrl(window.location.pathname)
    if (route.kind === 'module-graph' || route.kind === 'path-graph') {
      ensureGraphTabActive()
    }
    applyRouteFromUrl(route)
  })

  function ensureGraphTabActive () {
    const tabs = document.querySelectorAll('.main-tab')
    for (const t of tabs) {
      if (t.dataset.tab === 'graph' && !t.classList.contains('active')) {
        t.click()
        return
      }
    }
  }

  const initialRoute = routeFromUrl(window.location.pathname)
  if (initialRoute.kind === 'module-graph' || initialRoute.kind === 'path-graph') {
    ensureGraphTabActive()
    Promise.resolve().then(() => {
      applyRouteFromUrl(initialRoute)
      replaceStateForStack()
    })
  } else if (initialRoute.kind !== 'system') {
    // /validation, /docs, /suggestions — apply directly, the handlers switch tabs
    Promise.resolve().then(() => {
      applyRouteFromUrl(initialRoute)
    })
  } else {
    replaceStateForStack()
  }
})()
</script>
</body>
</html>`
}