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("structure")">Structure</button>
<button class="tab" data-rp="validation" onclick="switchRightPanel("validation")">Validation</button>
<button class="tab" data-rp="docs" onclick="switchRightPanel("docs")">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">=></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("' + modPath + path + '","' + (moduleName||'') + '")">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(' · ')
}
// ====== 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 · ' + (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">></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 ›</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 => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[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("forceStrength",+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("spacing",+this.value)">' +
'</div>' +
'<div class="gs-divider"></div><h4>Grouping</h4>' +
'<div class="gs-row"><label>Group by</label>' +
'<select onchange="gfxSet("groupBy",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("showRegions",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("sizeBy",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("labelPos",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("showEdgeLabels",this.checked)"><label>Show edge import names</label></div>' +
'<div class="gs-row"><input type="checkbox"' + (gfx.showLabels ? ' checked' : '') + ' onchange="gfxSet("showLabels",this.checked)"><label>Show node labels</label></div>' +
'<div class="gs-row"><input type="checkbox"' + (gfx.dimTests ? ' checked' : '') + ' onchange="gfxSet("dimTests",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("("[^&]*"|'[^&]*'|'[^']*'|" + '"[^"]*")', '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("all")">All</button>'
html += '<button class="' + (valFilter === 'fail' ? 'active' : '') + '" onclick="setValFilter("fail")">Fails</button>'
html += '<button class="' + (valFilter === 'warn' ? 'active' : '') + '" onclick="setValFilter("warn")">Warns</button>'
html += '<button class="' + (valFilter === 'pass' ? 'active' : '') + '" onclick="setValFilter("pass")">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("' + name + '")" 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' ? '✓' : c.status === 'fail' ? '✗' : '⚠'
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 · '
html += '<span style="color:#3fb950">' + cruft.summary.freshDocs + ' fresh</span> · '
html += '<span style="color:#d29922">' + cruft.summary.staleDocs + ' stale</span> · '
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 · ' + doc.lastModified + ' · 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 · ' + missing.length + ' missing'
if (external.length) html += ' · ' + 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("' + fullPath + '","' + (currentFileContext.moduleName || '') + '",' + (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>`
}